Files
mcm-26-b/scripts/build_paper_support.py
2026-09-13 18:05:59 +08:00

485 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build vector figure sources, comparison tables, the formal-data table, and a support ZIP.
The generated table rows are f-strings whose value equals the LaTeX line to be
written; wrapping them would obscure that one-to-one correspondence, so this module
is exempt from the line-length check (the only pylint exception in scripts/).
"""
# pylint: disable=line-too-long
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import zipfile
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PAPER = ROOT / "docs/paper"
PROJECT_RESULTS = ROOT / "project/results"
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def write_tex(path: Path, text: str) -> None:
"""写 LaTeX 片段;强制 LF 换行。
Windows 下的 CRLF 会让 booktabs 的 ``\\bottomrule`` 在表格内部变成
"Misplaced \\noalign":``\\input`` 读入的首行在 TeX 缓冲中已被 ``\\par`` 化,
表格因此提前结束。所有 ``data/*.tex`` 一律使用 LF。
"""
path.write_text(text.replace("\r\n", "\n").replace("\r", "\n"), "utf-8", newline="\n")
def figures(rows: list[dict]) -> None:
out = PAPER / "figures"
out.mkdir(parents=True, exist_ok=True)
plan = json.loads((ROOT / "src/mcm26b/guarantee/plans/q4_mincover_v2.json").read_text("utf-8"))
lines = [
r"\begin{figure}[htbp]\centering",
r"\begin{tikzpicture}[x=0.00145cm,y=0.00145cm,font=\small]",
r"\fill[inkblue!5] (0,0) circle (1800);",
r"\draw[thick] (0,0) circle (1800);",
r"\draw[->,warmgray] (-2900,0)--(2950,0) node[right] {$x$/m};",
r"\draw[->,warmgray] (0,-2800)--(0,2850) node[above] {$y$/m};",
]
for x in (-2000, -1000, 1000, 2000):
lines += [fr"\draw ({x},-35)--({x},35);",
fr"\node[below,font=\scriptsize] at ({x},-50) {{{x}}};"]
for x, y in plan["points"]:
lines.append(fr"\fill[inkblue] ({x},{y}) circle (33);")
lines += [
r"\node[anchor=west,align=left] at (3250,900) {圆域:源位置先验\\蓝点:31个检测站};",
r"\node[anchor=west,align=left] at (3250,-700) {部分站点在圆域外\\用于接收向外发射的源};",
r"\end{tikzpicture}",
r"\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}",
r"\label{fig:q4plan}\end{figure}",
]
write_tex(out / "q4-plan.tex", "\n".join(lines) + "\n")
colors = ["inkblue", "inkblue!50", "warmgray!70", "black!18"]
bars = [
r"\begin{figure}[htbp]\centering",
r"\begin{tikzpicture}[x=0.00108cm,y=0.85cm,font=\small]",
]
for j, row in enumerate(rows):
y = len(rows) - 1 - j
components = [Decimal(row["derived"]["inferred_movement_time_s"]),
Decimal(5 * row["measure_accepted_count"]),
Decimal(row["channel_switch_count"]),
Decimal(5 * row["cleared_jammer_count"] + 3 * row["clear_failure_count"])]
label = f"Q{row['problem_no']}-{row['formal_index']}"
bars.append(fr"\node[anchor=east] at (-100,{y + 0.3}) {{{label}}};")
current = Decimal(0)
for amount, color in zip(components, colors):
bars.append(fr"\fill[{color}] ({current},{y}) rectangle ({current + amount},{y + 0.6});")
current += amount
bars.append(fr"\node[anchor=west,font=\scriptsize] at ({current + 80},{y + 0.3}) {{{current:.1f}}};")
bars.append(r"\draw[->] (0,-0.25)--(10500,-0.25);")
for tick in range(0, 10001, 2000):
bars.append(fr"\draw ({tick},-0.25)--({tick},-0.35) node[below,font=\scriptsize] {{{tick}}};")
bars.append(r"\node[anchor=west,font=\scriptsize] at (10400,-0.65) {s};")
for x, color, label in zip((0, 2600, 5200, 7600), colors, ("移动", "检测", "换频", "清除")):
bars += [fr"\fill[{color}] ({x},-1.3) rectangle ({x + 350},-1.05);",
fr"\node[anchor=west] at ({x + 450},-1.175) {{{label}}};"]
bars += [
r"\end{tikzpicture}",
r"\caption{正式测试的累计虚拟耗时分解。移动时间由总耗时与各动作计数计算。}",
r"\label{fig:costbars}\end{figure}",
]
write_tex(out / "formal-time-bars.tex", "\n".join(bars) + "\n")
def fixed(value: float | None, digits: int = 3) -> str:
if value is None:
return "--"
return str(Decimal(str(value)).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))
def percent(value: float | None, digits: int = 1) -> str:
if value is None:
return "--"
return fixed(100 * value, digits) + r"\%"
def flag(value: bool | None) -> str:
return "--" if value is None else ("是" if value else "否")
def project_tables() -> None:
"""Generate the optimal-versus-baseline comparison tables from project/results."""
out = PAPER / "data"
out.mkdir(parents=True, exist_ok=True)
data = {name: json.loads((PROJECT_RESULTS / f"{name}.json").read_text("utf-8"))
for name in ("q1", "q2", "q3", "q4")}
examples, random = data["q1"]["aggregate_examples"], data["q1"]["aggregate_random"]
two = data["q2"]["aggregate"]
three, paired3 = data["q3"]["aggregate"]["variants"], data["q3"]["aggregate"]["paired"]
four, paired4 = data["q4"]["aggregate"]["variants"], data["q4"]["aggregate"]["paired"]
head = "% Generated by scripts/build_paper_support.py from project/results/*.json."
macros = [
("ProjQOneExampleCases", str(examples["cases"])),
("ProjQOneRandomCases", str(random["cases"])),
("ProjQOneFixedStatusBase", percent(examples["status_accuracy_baseline"])),
("ProjQOneFixedMaeBase", fixed(examples["diameter_mae_baseline_m"])),
("ProjQOneFixedMaeMaxBase", fixed(examples["diameter_max_err_baseline_m"])),
("ProjQOneFixedCoverBase", percent(examples["coverage_accuracy_baseline"])),
("ProjQOneFixedMsOpt", fixed(examples["runtime_optimal_mean_ms"])),
("ProjQOneFixedMsBase", fixed(examples["runtime_baseline_mean_ms"])),
("ProjQOneRandStatusBase", percent(random["status_accuracy_baseline"])),
("ProjQOneRandMaeBase", fixed(random["diameter_mae_baseline_m"])),
("ProjQOneRandMaeMaxBase", fixed(random["diameter_max_err_baseline_m"])),
("ProjQOneRandCoverBase", percent(random["coverage_accuracy_baseline"])),
("ProjQOneRandMsOpt", fixed(random["runtime_optimal_mean_ms"])),
("ProjQOneRandMsBase", fixed(random["runtime_baseline_mean_ms"])),
("ProjQTwoCases", str(two["optimal"]["cases"])),
("ProjQTwoInitialRadius", fixed(two["_initial_radius_mean_m"])),
("ProjQTwoGuaranteeOpt", percent(two["optimal"]["guaranteed_rate"])),
("ProjQTwoGuaranteeMec", percent(two["baseline_mec"]["guaranteed_rate"])),
("ProjQTwoGuaranteePerp", percent(two["baseline_perp"]["guaranteed_rate"])),
("ProjQTwoWorstOpt", fixed(two["optimal"]["worst_posterior_mean_m"])),
("ProjQTwoWorstRef", fixed(two["reference"]["worst_posterior_mean_m"])),
("ProjQTwoRatioOpt", fixed(two["optimal"]["final_ratio_mean"])),
("ProjQTwoRatioMec", fixed(two["baseline_mec"]["final_ratio_mean"])),
("ProjQTwoWorstPerp", fixed(two["baseline_perp"]["worst_posterior_mean_m"])),
("ProjQTwoWorstMec", fixed(two["baseline_mec"]["worst_posterior_mean_m"])),
("ProjQThreeScenes", str(three["optimal"]["episodes"])),
("ProjQThreeMeanOpt", fixed(three["optimal"]["virtual_time_mean_s"])),
("ProjQThreeMeanSeven", fixed(three["seven"]["virtual_time_mean_s"])),
("ProjQThreeMeanBase", fixed(three["baseline"]["virtual_time_mean_s"])),
("ProjQThreeDropOpt", percent(-paired3["optimal_vs_baseline"]["mean_relative_change"])),
("ProjQThreeDropMedianOpt",
percent(-paired3["optimal_vs_baseline"]["median_relative_change"])),
("ProjQThreeDropSeven", percent(-paired3["seven_vs_baseline"]["mean_relative_change"])),
("ProjQThreeFasterOpt",
f"{paired3['optimal_vs_baseline']['faster_scenes']}/{paired3['optimal_vs_baseline']['compared']}"),
("ProjQThreeMeasuresOpt", fixed(three["optimal"]["measures_mean"])),
("ProjQThreeMeasuresBase", fixed(three["baseline"]["measures_mean"])),
("ProjQThreeMoveOpt", fixed(three["optimal"]["move_distance_mean_m"] / 1000)),
("ProjQThreeMoveBase", fixed(three["baseline"]["move_distance_mean_m"] / 1000)),
("ProjQThreeWallOpt", fixed(three["optimal"]["compute_wall_mean_s"])),
("ProjQFourScenes", str(four["optimal"]["episodes"])),
("ProjQFourMeanOpt", fixed(four["optimal"]["virtual_time_mean_s"])),
("ProjQFourMeanGrid", fixed(four["baseline_grid"]["virtual_time_mean_s"])),
("ProjQFourMeanBase", fixed(four["baseline"]["virtual_time_mean_s"])),
("ProjQFourClearOpt", percent(four["optimal"]["cleared_ratio_mean"])),
("ProjQFourClearGrid", percent(four["baseline_grid"]["cleared_ratio_mean"])),
("ProjQFourClearBase", percent(four["baseline"]["cleared_ratio_mean"])),
("ProjQFourAbsentBase", str(four["baseline"]["absent_misjudged_total"])),
("ProjQFourMeasuresOpt", fixed(four["optimal"]["measures_mean"])),
("ProjQFourMeasuresBase", fixed(four["baseline"]["measures_mean"])),
("ProjQFourFallbackOpt", fixed(four["optimal"]["fallback_clear_attempts_mean"])),
("ProjQFourValidOpt",
f"{four['optimal']['valid_episodes']}/{four['optimal']['episodes']}"),
("ProjQFourValidGrid",
f"{four['baseline_grid']['valid_episodes']}/{four['baseline_grid']['episodes']}"),
("ProjQFourRiseOpt", percent(paired4["optimal_vs_baseline"]["mean_relative_change"])),
("ProjQFourMoveOpt", fixed(four["optimal"]["move_distance_mean_m"] / 1000)),
("ProjQFourMoveBase", fixed(four["baseline"]["move_distance_mean_m"] / 1000)),
]
lines = [head] + [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros]
write_tex(out / "project-macros.tex", "\n".join(lines) + "\n")
def write(stem: str, rows: list[str]) -> None:
write_tex(out / stem, "\n".join([head, *rows]) + "\n")
write("project-q1-summary-rows.tex", [
f"最优(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_optimal'])} & "
f"{fixed(examples['diameter_mae_optimal_m'])} & {fixed(examples['diameter_max_err_optimal_m'])} & "
f"{percent(examples['coverage_accuracy_optimal'])} & {fixed(examples['runtime_optimal_mean_ms'])} " + r"\\",
f"最优(随机 {random['cases']} 例) & {percent(random['status_accuracy_optimal'])} & "
f"{fixed(random['diameter_mae_optimal_m'])} & {fixed(random['diameter_max_err_optimal_m'])} & "
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['runtime_optimal_mean_ms'])} " + r"\\",
f"基线(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_baseline'])} & "
f"{fixed(examples['diameter_mae_baseline_m'])} & {fixed(examples['diameter_max_err_baseline_m'])} & "
f"{percent(examples['coverage_accuracy_baseline'])} & {fixed(examples['runtime_baseline_mean_ms'])} " + r"\\",
f"基线(随机 {random['cases']} 例) & {percent(random['status_accuracy_baseline'])} & "
f"{fixed(random['diameter_mae_baseline_m'])} & {fixed(random['diameter_max_err_baseline_m'])} & "
f"{percent(random['coverage_accuracy_baseline'])} & {fixed(random['runtime_baseline_mean_ms'])} " + r"\\",
])
def variant_rows(table: dict, mapping: list[tuple[str, str]]) -> list[str]:
return [
f"{label} & {table[key]['valid_episodes']}/{table[key]['episodes']} & "
f"{percent(table[key]['cleared_ratio_mean'])} & {table[key]['absent_misjudged_total']} & "
f"{fixed(table[key]['virtual_time_mean_s'])} & {fixed(table[key]['virtual_time_median_s'])} & "
f"{fixed(table[key]['virtual_time_max_s'])} & {fixed(table[key]['measures_mean'], 1)} & "
f"{fixed(table[key]['move_distance_mean_m'] / 1000)} & {fixed(table[key]['fallback_clear_attempts_mean'], 1)} "
+ r"\\"
for key, label in mapping
]
def table_environment(caption: str, label: str, colspec: str, header: str,
body: list[str], placement: str = "htbp",
tabcolsep: str = "4pt") -> str:
"""整表输出:表格不能被 ``\\input`` 切开,否则注释行会在表内注入 ``\\par``。"""
return "\n".join([
f"\\begin{{table}}[{placement}]",
"\\centering\\small",
f"\\caption{{{caption}}}",
f"\\setlength{{\\tabcolsep}}{{{tabcolsep}}}",
f"\\begin{{tabular}}{{{colspec}}}",
"\\toprule",
header,
"\\midrule",
*body,
"\\bottomrule",
"\\end{tabular}",
f"\\label{{{label}}}",
"\\end{table}",
"",
])
write("project-q3-table.tex", [table_environment(
f"问题三:三种策略在 {three['optimal']['episodes']} 个全向源场景上的对比(时间:s;"
f"三种策略的缺席误判均为 {three['optimal']['absent_misjudged_total']})",
"tab:q3compare", "lrrrrrrrr",
"策略 & 有效局 & 全清率 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值 & "
"位移/km & 后备清除" + r"\\",
# 问题三三种策略的缺席误判均为 0,该列改写入表题以控制表宽。
[" & ".join(c for i, c in enumerate(row.split(" & ")) if i != 3) + r" \\"
for row in variant_rows(three, [
("optimal", "bayes(默认策略)"), ("seven", "seven(七点批量)"),
("baseline", "grid(49 点网格)")])], tabcolsep="2pt")])
write("project-q4-table.tex", [table_environment(
f"问题四:三种策略在 {four['optimal']['episodes']} 个混合源场景上的对比(时间:s)",
"tab:q4compare", "lrrrrrrr",
"策略 & 有效局 & 全清率 & 缺席误判 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值" + r"\\",
# 问题四表去掉位移与后备清除两列(正文单独说明后备清除次数),以控制表宽。
[" & ".join(row.split(" & ")[:8]) + r" \\"
for row in variant_rows(four, [
("optimal", "directional(默认策略)"), ("baseline_grid", "grid(问题三网格)"),
("baseline", "bayes(问题三策略)")])], tabcolsep="4pt")])
def listings(rows: list[dict]) -> None:
"""生成附录的正式数据表格;核心函数源码清单由 build_code_appendix.py 单独生成。
论文附录只收录四问主线的核心函数(数据源为单文件核心代码 ipynb),
评测机、命令行外壳、采集/绘图脚本、测试与未采用方案不再逐字排版。
正式结果表同样整表输出:被 ``\\input`` 的文件若以注释行开头,
会在表格内部注入 ``\\par``,使 booktabs 的 ``\\bottomrule`` 报错。
"""
data = PAPER / "data"
head = ("% Generated by scripts/build_paper_support.py; 本文件是完整 table 环境,"
"供附录直接 \\input。")
table = [
r"\begin{longtable}{p{1.15cm}p{13.3cm}}",
r"\caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\",
r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead",
r"\toprule 案例 & 原始文件名与 SHA-256\\\midrule\endhead",
]
for row in rows:
h = row["package_sha256"]
label = f"Q{row['problem_no']}-{row['formal_index']}"
table += [
fr"{label} & \pathtext{{{Path(row['file']).name}}}\\",
fr"& {{\scriptsize\texttt{{{h[:32]}}}\allowbreak\texttt{{{h[32:]}}}}}\\[4pt]",
]
table += [r"\bottomrule\end{longtable}"]
write_tex(data / "formal-file-list.tex", "\n".join(table) + "\n")
def formal_table(caption: str, label: str, header: str, body: list[str]) -> str:
return "\n".join([
r"\begin{table}[htbp]",
r"\centering\small",
f"\\caption{{{caption}}}",
r"\setlength{\tabcolsep}{4pt}",
r"\begin{tabularx}{\linewidth}{l X r r r}",
r"\toprule",
header,
r"\midrule",
*body,
r"\bottomrule",
r"\end{tabularx}",
f"\\label{{{label}}}",
r"\end{table}",
"",
])
formal_head = r"测试 & 测试案例编码 & 清除数 & 总虚拟时间/s & 程序运行时间/s\\"
for problem in (3, 4):
subset = sorted((r for r in rows if r["problem_no"] == problem),
key=lambda r: r["formal_index"])
body = [
f"测试 {r['formal_index']} & \\texttt{{{r['case_code']}}} & "
f"{r['cleared_jammer_count']} & {fixed(r['virtual_time_s'])} & "
f"{fixed(r['program_run_duration_s'])} " + r"\\"
for r in subset
]
write_tex(data / f"formal-q{problem}-table.tex", "\n".join([head, formal_table(
f"问题{'三四'[problem - 3]}正式测试结果(时间单位:s)",
f"tab:formal{problem}", formal_head, body)]))
body = []
for r in sorted(rows, key=lambda r: (r["problem_no"], r["formal_index"])):
body.append(
f"Q{r['problem_no']}-{r['formal_index']} & {r['measure_accepted_count']} & "
f"{r['channel_switch_count']} & {r['clear_failure_count']} & "
f"{fixed(r['virtual_time_s'])} & {fixed(r['inferred_movement_time_s'])} & "
f"{percent(r['movement_share_percent'] / 100)} " + r"\\")
write_tex(data / "formal-cost-table.tex", "\n".join([
head,
r"\begin{table}[htbp]",
r"\centering\small",
r"\caption{六次正式测试的动作计数与移动耗时}",
r"\setlength{\tabcolsep}{4.5pt}",
r"\begin{tabular}{lrrrrrr}",
r"\toprule",
r"案例 & 检测数 & 换频数 & 失败清除 & 总虚拟时间/s & 移动时间/s & 移动占比\\",
r"\midrule",
*body,
r"\bottomrule",
r"\end{tabular}",
r"\label{tab:formalcost}",
r"\end{table}",
""]))
SUPPORT_README = """# 论文支撑材料
汇总自建模拟器(评测机 `referee/`)的演练局日志与官方模拟器 `test` 中的六份正式导出。
正文的机制论证与定量结论来自演练局,官方正式数据只用于附录中的效果验证。
## 环境和运行
Python >=3.10;安装 requests,可选 numpy 加速。
在本目录使用 PowerShell:
$env:PYTHONPATH = (Resolve-Path src).Path
python -m mcm26b.cli --help
python -m mcm26b.cli run --problem 3 --strategy bayes --robot-id <模拟器登录队号>
python -m mcm26b.cli run --problem 4 --strategy directional --robot-id <模拟器登录队号>
在线命令需要先由使用者在官方模拟器中选择测试、待接口就绪。
自建模拟器可用 `python -m referee` 启动,演练局日志写入 `referee-data/sessions/`。
## 重算论文数据和图表
python scripts/build_practice_data.py # 演练局复算,生成正文数据与宏文件
python scripts/plot_paper_figures.py # 生成正文全部插图
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
python scripts/build_paper_support.py # 附录正式数据表与支撑包
第一条命令按题面计时模型复算 `referee-data/sessions/` 下的每局演练日志,
输出论文引用的宏、表格与插图数据;复算值与日志记录的最大偏差为 0.000000 s。
正式统计快照来自官方统计队列,通过票据哈希匹配所选包;
重新验证来源匹配需原电脑的统计队列。
连续覆盖与混合类型推断的扩展见论文未来工作。
## 编译
安装 TeX Live(XeLaTeX、ctex、TikZ),进入 docs/paper:
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
版式使用原项目模板。编译前请将原项目 docs/paper/fonts 目录放回
同一相对位置。使用 latexmk 自动完成交叉引用所需的多遍编译。
`data/*.tex` 由脚本生成,一律使用 LF 换行:CRLF 会让 `\\input` 的表格行
在 TeX 缓冲中提前 `\\par` 化,触发 booktabs 的 "Misplaced \\noalign"。
support-manifest.json 列出包内全部材料(清单自身除外)及 SHA-256。
"""
def package(rows: list[dict]) -> Path:
selected = set((ROOT / "src/mcm26b").rglob("*.py"))
selected.update((ROOT / "src/mcm26b/guarantee/plans").glob("*.json"))
selected.update((PAPER / "data").rglob("*"))
selected.update((PAPER / "figures").glob("*.tex"))
selected.update((PAPER / "sections").glob("*.tex"))
selected.update((ROOT / "docs/figures").glob("*.png"))
selected.update((ROOT / "docs/figures").glob("*.json"))
selected.add(ROOT / "docs/figures/README.md")
selected.update(path for path in (ROOT / "project").rglob("*")
if path.is_file() and "__pycache__" not in path.parts)
# 自建模拟器与其行为日志是正文数据的来源,必须随支撑包提交。
selected.update(path for path in (ROOT / "referee").rglob("*")
if path.is_file() and "__pycache__" not in path.parts
and ".pytest_cache" not in path.parts)
selected.update((ROOT / "referee-data/sessions").glob("*/*.json*"))
# 被排除的演练局也随包提交:论文附录用它们说明"未优化完全"的判定依据。
selected.update((ROOT / "referee-data/rejected-sessions").glob("*/*.json*"))
selected.update((ROOT / "referee-data/superseded-sessions").glob("*/*.json*"))
selected.update((ROOT / "examples/replay").glob("*.jsonl"))
selected.update([PAPER / "main.tex", PAPER / "mcm26b-paper.cls",
PAPER / "README.md", PAPER / "REWRITE_NOTES.md",
PAPER / "data/formal-macros.tex",
ROOT / "mcm26b-核心代码.ipynb",
ROOT / "scripts/audit_paper_formal.py",
ROOT / "scripts/build_code_appendix.py",
ROOT / "scripts/build_practice_data.py",
ROOT / "scripts/run_practice_episodes.py",
ROOT / "scripts/build_paper_support.py"])
for row in rows:
path = ROOT / "Jammers-simulator-full" / row["file"]
if digest(path.read_bytes()) != row["package_sha256"]:
raise ValueError("Original formal package changed")
selected.add(path)
blobs = {p.relative_to(ROOT).as_posix(): p.read_bytes() for p in sorted(selected) if p.is_file()}
blobs["README.md"] = SUPPORT_README.encode("utf-8")
blobs["requirements.txt"] = b"requests>=2.31\nnumpy>=1.24\n"
manifest = [{"file": name, "bytes": len(value), "sha256": digest(value)} for name, value in sorted(blobs.items())]
blobs["support-manifest.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
out = PAPER / "support-materials.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for name, value in sorted(blobs.items()):
entry = zipfile.ZipInfo(name, (2026, 9, 13, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(entry, value)
if out.stat().st_size > 20_000_000:
raise ValueError("Support archive exceeds 20 MB")
print(f"Support archive: {len(blobs)} files, {out.stat().st_size} bytes")
return out
def formal_rows() -> list[dict]:
"""读正式测试快照。
表格需要逐局的移动时间与位移占比,这两项只在 ``formal-results.csv`` 中,
哈希与文件名清单所需的 ``package_sha256`` 则来自 JSON 快照,故两者合并。
"""
records = json.loads((PAPER / "data/formal-results.json").read_text("utf-8"))["records"]
numbers = {r["case_code"]: r for r in csv_rows()}
merged = []
for record in records:
extra = numbers.get(record["case_code"], {})
merged.append({**record, **{k: v for k, v in extra.items() if k not in record}})
return merged
def csv_rows() -> list[dict]:
"""按 CSV 表头类型读回 ``formal-results.csv``。"""
numeric_int = ("problem_no", "formal_index", "cleared_jammer_count",
"measure_accepted_count", "channel_switch_count", "clear_failure_count")
numeric_float = ("virtual_time_s", "program_run_duration_s",
"inferred_movement_time_s", "movement_share_percent")
rows = []
with (PAPER / "data/formal-results.csv").open(encoding="utf-8-sig") as handle:
for row in csv.DictReader(handle):
for key in numeric_int:
row[key] = int(row[key])
for key in numeric_float:
row[key] = float(row[key])
rows.append(row)
return rows
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", action="store_true")
args = parser.parse_args()
rows = formal_rows()
figures(rows)
project_tables()
listings(rows)
if args.package:
package(rows)
if __name__ == "__main__":
main()