"""自建模拟器演练局的统计提取:把 referee-data 日志复算成论文可直接引用的数据。 论文正文的论证数据全部取自项目内自建模拟器(评测机 referee 的 ``q3_practice`` / ``q4_practice`` 演练局),官方模拟器的正式测试只用于附录中的 最终效果验证。本脚本把两类数据都复算成结构化快照,使正文数字、图表与附录表格 来自同一次运行,避免手抄。 计时口径与 ``scripts/analyze_run_timing.py`` / ``scripts/verify_q3_practice.py`` 一致(题面附件2 §4.1): T = Σ 距离/5 + 5·N_measure + N_switch + 5·N_clear⁺ + 3·N_clear⁻ 输出: - ``docs/paper/data/practice-results.json`` / ``.csv`` - ``docs/paper/data/practice-macros.tex`` - ``docs/paper/data/practice-q3-rows.tex`` / ``practice-q4-rows.tex`` - ``docs/paper/data/practice-cost-rows.tex`` uv run python scripts/build_practice_data.py """ from __future__ import annotations import argparse import csv import json import math import sys from decimal import ROUND_HALF_UP, Decimal from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) # 直接以脚本方式运行时也能导入生产实现 PAPER = ROOT / "docs" / "paper" SESSIONS = ROOT / "referee-data" / "sessions" ARCHIVE = ROOT / "examples" / "replay" FORMAL_CSV = PAPER / "data" / "formal-results.csv" SPEED_MPS = 5.0 MEASURE_S = 5 SWITCH_S = 1 CLEAR_SUCCESS_S = 5 CLEAR_FAIL_S = 3 START_POSITION = (0.0, 0.0) START_CHANNEL = 1 # 正文用作定量论据的演练局:问题三选全向最大规模一局,问题四选定向源较多、 # 且同时出现"测到示向度 / 近距离 / 无信号"三种结果的一局, # 两局都能代表各自模块的完整链路,并直接对应当前实现。 MAIN_CASES = { 3: "QS6H-CC29-PDY3-ZA54", 4: "SWA3-C3FB-C5QC-FLW7", } # 论文引用的演练局必须全部来自本次采集的批次,避免把历史留档(含算法未优化 # 完全时的局)或自检局混进正文;批次外的留档一律不进入统计。 # 问题四批次为种子 400--439 中"零失败清除"的 29 局,另 11 局作为未优化完全的 # 脏数据移入 referee-data/rejected-sessions,不参与任何统计。 PAPER_BATCH = { 3: {code: None for code in ( "VE37-SRPD-NCFR-4EQF", "8VRC-ADW4-XDXM-F62L", "ARBP-6XFZ-5YJU-STC8", "MULB-4YTU-VQYG-YAS6", "NMRC-85ZV-3FFE-GBXY", "6V2P-HJCS-GJ9A-83MA", "HGBA-XGH9-UNZJ-7YQ3", "MT3R-68KH-CNY5-6X73", "E27E-M8WQ-LDT5-2XDA", "7ZXN-N53X-HAXQ-EGY4", "6LST-ZFVS-2HHW-TQQ4", "R2N7-QMLC-26QX-RXYT", "LZDX-68DJ-XM4P-A6XL", "JW47-AVPH-XGHH-RLLV", "QS6H-CC29-PDY3-ZA54")}, 4: {code: None for code in ( "JY84-364B-45UL-AFBK", "SWA3-C3FB-C5QC-FLW7", "GSG4-6HTJ-AH5L-ZYGM", "2M5J-QWHS-BYGB-MPYF", "US79-DFNV-UQVW-NPLL", "96M4-SK2V-PDWN-YGHB", "6US3-A76N-KC8B-DMFM", "VJ68-ADZ6-STFT-LHYT", "2Y25-4LD6-5A4P-64CV", "A5YU-4SM6-4PK7-M5H8", "JH76-MEWS-BR6Q-BQ6S", "B8HR-3HJH-2UYH-YDVB", "RLK4-E82Q-UHT6-2M9M", "R2E7-LF5E-J5K6-BSVQ", "3XM4-65TW-CH39-XWTZ", "XN57-MHFY-V58F-XCMY", "ZLKB-GVZT-NQDE-CU5F", "DX2D-KDF9-BGYU-XPMG", "CEL3-XQV8-M7KY-3CSW", "TKCY-2VT2-WYUX-2JXU", "T5ZL-5GEP-FDCK-3P8T", "QXVN-L63H-6WLX-KLEJ", "X3Y4-S9D2-XWR9-V6CZ", "6LEP-YVQ6-AYP5-N359", "7HG2-UEBM-K3D2-LZWT", "JDAY-2SU7-RTSJ-NQFR", "QL6B-KEUW-DJR2-P84B", "QDDJ-S89Y-7TKY-E6VJ", "SE9X-67GA-6YC6-QB7B")}, } def write_tex(path: Path, text: str) -> None: r"""写 LaTeX 片段;强制 LF 换行。 Windows 的 CRLF 会让 ``\input`` 进表格的数据行在 TeX 缓冲中提前 ``\par`` 化,导致 booktabs 的 ``\bottomrule`` 报 "Misplaced \noalign"。 """ path.write_text(text.replace("\r\n", "\n").replace("\r", "\n"), "utf-8", newline="\n") def load_jsonl(path: Path) -> list[dict[str, Any]]: records = [] for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if not line: continue try: records.append(json.loads(line)) except json.JSONDecodeError: continue return records def recompute(records: list[dict[str, Any]]) -> dict[str, Any] | None: """按题面计时模型逐动作复算一局;没有已接受动作时返回 None。""" start = next((r for r in records if r.get("event") == "session_start"), None) end = next((r for r in records if r.get("event") == "session_end"), None) truth = start.get("truth", []) if start else [] position, channel = START_POSITION, START_CHANNEL move_us = action_us = 0 switches = measures = clears = failed_clears = dir_measures = 0 distance_m = 0.0 max_gap_us = 0 elapsed_s = 0.0 for record in records: if record.get("event") != "action": continue request = record.get("request") or {} response = record.get("response") or {} if not response.get("accepted", record.get("status") == 200): continue path = record.get("path", "") if path not in ("/measure", "/clear") or "position" not in request: continue target = (float(request["position"]["x"]), float(request["position"]["y"])) leg = math.dist(position, target) distance_m += leg move_us += round(leg / SPEED_MPS * 1_000_000) position = target if path == "/measure": switches += int(channel != request.get("channel")) channel = request.get("channel") measures += 1 action_us += MEASURE_S * 1_000_000 if response.get("measure_result") == "direction": dir_measures += 1 else: clears += 1 ok = response.get("clear_result") == "success" failed_clears += int(not ok) action_us += (CLEAR_SUCCESS_S if ok else CLEAR_FAIL_S) * 1_000_000 elapsed = record.get("elapsed_s") if isinstance(elapsed, (int, float)): elapsed_s += float(elapsed) recorded = response.get("virtual_time_s") if isinstance(recorded, (int, float)): gap = round(recorded * 1_000_000) - (move_us + action_us + switches * 1_000_000) max_gap_us = max(max_gap_us, abs(gap)) if measures == 0 and clears == 0: return None virtual_us = move_us + action_us + switches * 1_000_000 return { "case_code": (start or {}).get("case_code", ""), "module": (start or {}).get("module", ""), "seed": (start or {}).get("seed"), "source_total": len(truth), "directional_total": sum(1 for t in truth if t.get("directional")), "r_eff_min_m": round(min((float(t["r_eff_m"]) for t in truth), default=0.0), 3), "r_eff_max_m": round(max((float(t["r_eff_m"]) for t in truth), default=0.0), 3), "cleared_jammer_count": (end or {}).get("cleared_count", clears), "measure_accepted_count": measures, "direction_measure_count": dir_measures, "channel_switch_count": switches, "clear_success_count": clears - failed_clears, "clear_failure_count": failed_clears, "virtual_time_us": virtual_us, "virtual_time_s": round(virtual_us / 1_000_000, 6), "move_time_s": round(move_us / 1_000_000, 6), "move_distance_m": round(distance_m, 3), "measure_time_s": round(measures * MEASURE_S, 6), "switch_time_s": float(switches * SWITCH_S), "clear_time_s": float(clears * CLEAR_SUCCESS_S - failed_clears * (CLEAR_SUCCESS_S - CLEAR_FAIL_S)), # 程序运行时间取会话结束行记录的现实耗时(评测机口径),逐动作 # elapsed_s 仅在部分动作上出现,不能代表整局墙钟。 "program_run_duration_s": round( float((end or {}).get("real_elapsed_s", elapsed_s)), 3), "end_reason": (end or {}).get("reason", ""), "max_recorded_gap_s": round(max_gap_us / 1_000_000, 6), "average_clear_time_s": round(virtual_us / 1_000_000 / max(1, (end or {}).get("cleared_count", clears)), 6), } def collect_practice() -> list[dict[str, Any]]: """只收集论文声明批次的演练局;批次外留档不进入统计。 论文的每一局都必须能追溯到 ``scripts/run_practice_episodes.py`` 的一次调用, 因此这里显式按案例编码白名单过滤,而不是"扫到什么就用什么"。 """ rows: list[dict[str, Any]] = [] allowed = PAPER_BATCH[3] | PAPER_BATCH[4] skipped = 0 for path in sorted(SESSIONS.glob("*/log.jsonl")): if path.parent.name not in allowed: skipped += 1 continue result = recompute(load_jsonl(path)) if result is None: print(f"[跳过] {path.parent.name}:没有已接受动作") continue if "practice" not in result["module"]: print(f"[跳过] {path.parent.name}:模块为 {result['module']}") continue rows.append(result) if skipped: print(f"[批次外] {skipped} 局留档未进入统计(历史留档或自检局)") for row in rows: row["problem_no"] = 3 if "q3" in row["module"] else 4 # 一局“全清”= 清除数达到该局源数且没有失败清除动作。 row["all_cleared"] = row["cleared_jammer_count"] >= row["source_total"] row["complete"] = row["all_cleared"] and row["clear_failure_count"] == 0 rows.sort(key=lambda r: (r["problem_no"], r["case_code"])) return rows 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: return "--" if value is None else fixed(100 * value, digits) + r"\%" def mean(rows: list[dict[str, Any]], key: str) -> float | None: values = [float(r[key]) for r in rows if isinstance(r.get(key), (int, float))] return sum(values) / len(values) if values else None def problem_rows(rows: list[dict[str, Any]], problem: int) -> list[dict[str, Any]]: return [r for r in rows if r["problem_no"] == problem] # 问题一正文引用的交会算例:取自本次采集的演练局, # 该频道的定位区域满足“直径圆不覆盖、最小包围圆可清除”这一关键对比。 Q1_CASE = "8VRC-ADW4-XDXM-F62L" Q1_CHANNEL = 12 Q1_DELTA_DEG = 1.0 def extraction_measurements(case_code: str, channel: int) -> list[dict[str, float]]: """取一局中某频道被接受的全部示向度读数。""" path = SESSIONS / case_code / "log.jsonl" observations: list[dict[str, float]] = [] for record in load_jsonl(path): if record.get("event") != "action" or record.get("path") != "/measure": continue response = record.get("response") or {} if response.get("measure_result") != "direction": continue request = record.get("request") or {} if request.get("channel") != channel or "position" not in request: continue observations.append({"x": float(request["position"]["x"]), "y": float(request["position"]["y"]), "bearing_deg": float(response["svd_deg"])}) if len(observations) < 3: raise ValueError(f"{case_code} 频道 {channel} 的可交会读数不足三个") return observations def solve_q1_example() -> dict[str, Any]: """用生产求解器复算问题一的交会算例,返回顶点、直径与两类圆。""" from mcm26b.geometry.localization import ( # pylint: disable=import-outside-toplevel Measurement, diameter, smallest_enclosing_circle, solve) observations = extraction_measurements(Q1_CASE, Q1_CHANNEL) result = solve([Measurement(o["x"], o["y"], o["bearing_deg"], Q1_DELTA_DEG) for o in observations]) if result["status"] != "polygon": raise ValueError(f"算例区域状态为 {result['status']},不能用于正文示例") vertices = [tuple(v) for v in result["vertices"]] d_value, (a, b) = diameter(vertices) midpoint = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) exceed = max(math.dist(midpoint, p) for p in vertices) - d_value / 2.0 centre, radius = smallest_enclosing_circle(vertices) return {"case_code": Q1_CASE, "channel": Q1_CHANNEL, "half_angle_deg": Q1_DELTA_DEG, "measurements": observations, "vertices": [list(v) for v in vertices], "diameter_circle_centre": list(midpoint), "diameter_m": d_value, "diameter_circle_radius_m": d_value / 2.0, "exceed_m": exceed, "mec_centre": list(centre), "mec_radius_m": radius, "vertex_count": len(vertices)} def write_geometry_example() -> dict[str, Any]: example = solve_q1_example() (PAPER / "data" / "practice-geometry.json").write_text( json.dumps(example, ensure_ascii=False, indent=2) + "\n", "utf-8") macros = [ ("ExOneChannel", str(example["channel"])), ("ExOneVertices", str(example["vertex_count"])), ("ExOneDiameter", fixed(example["diameter_m"])), ("ExOneRadius", fixed(example["diameter_circle_radius_m"])), ("ExOneExceed", fixed(example["exceed_m"])), ("ExOneMec", fixed(example["mec_radius_m"])), ] lines = ["% Generated by scripts/build_practice_data.py; 问题一交会算例复算值。"] lines += [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros] write_tex(PAPER / "data" / "practice-geometry-macros.tex", "\n".join(lines) + "\n") return example def complete_rows(rows: list[dict[str, Any]], problem: int) -> list[dict[str, Any]]: return [r for r in problem_rows(rows, problem) if r["complete"]] def cost_share(rows: list[dict[str, Any]]) -> dict[str, float]: """一组演练局中移动/检测/换频/清除耗时占总虚拟时间的比例。""" total = sum(float(r["virtual_time_s"]) for r in rows) if total <= 0: return {} return {"MoveShare": sum(float(r["move_time_s"]) for r in rows) / total, "MeasureShare": sum(float(r["measure_time_s"]) for r in rows) / total, "SwitchShare": sum(float(r["switch_time_s"]) for r in rows) / total, "ClearShare": sum(float(r["clear_time_s"]) for r in rows) / total} def write_macros(rows: list[dict[str, Any]]) -> None: three, four = problem_rows(rows, 3), problem_rows(rows, 4) three_ok, four_ok = complete_rows(rows, 3), complete_rows(rows, 4) q3_main = next(r for r in three if r["case_code"] == MAIN_CASES[3]) q4_main = next(r for r in four if r["case_code"] == MAIN_CASES[4]) macros = [ ("PractQThreeCases", str(len(three))), ("PractQFourCases", str(len(four))), ("PractTotalCases", str(len(rows))), ("PractQThreeComplete", str(len(three_ok))), ("PractQFourComplete", str(len(four_ok))), ("PractTotalComplete", str(len(three_ok) + len(four_ok))), ("PractQThreeSources", str(sum(r["source_total"] for r in three))), ("PractQFourSources", str(sum(r["source_total"] for r in four))), ("PractTotalSources", str(sum(r["source_total"] for r in rows))), ("PractQThreeCleared", str(sum(r["cleared_jammer_count"] for r in three))), ("PractQFourCleared", str(sum(r["cleared_jammer_count"] for r in four))), ("PractTotalCleared", str(sum(r["cleared_jammer_count"] for r in rows))), ("PractTotalFailures", str(sum(r["clear_failure_count"] for r in rows))), ("PractQThreeMean", fixed(mean(three_ok, "virtual_time_s"))), ("PractQFourMean", fixed(mean(four_ok, "virtual_time_s"))), ("PractQThreeMin", fixed(min(r["virtual_time_s"] for r in three_ok))), ("PractQThreeMax", fixed(max(r["virtual_time_s"] for r in three_ok))), ("PractQFourMin", fixed(min(r["virtual_time_s"] for r in four_ok))), ("PractQFourMax", fixed(max(r["virtual_time_s"] for r in four_ok))), ("PractQThreeMeanClear", fixed(mean(three_ok, "average_clear_time_s"))), ("PractQFourMeanClear", fixed(mean(four_ok, "average_clear_time_s"))), ("PractQThreeMove", fixed(mean(three_ok, "move_distance_m") / 1000)), ("PractQFourMove", fixed(mean(four_ok, "move_distance_m") / 1000)), ("PractQThreeRun", fixed(mean(three_ok, "program_run_duration_s"))), ("PractQFourRun", fixed(mean(four_ok, "program_run_duration_s"))), ("PractQThreeMeasures", fixed(mean(three_ok, "measure_accepted_count"), 1)), ("PractQFourMeasures", fixed(mean(four_ok, "measure_accepted_count"), 1)), ("PractQThreeSwitches", fixed(mean(three_ok, "channel_switch_count"), 1)), ("PractQFourSwitches", fixed(mean(four_ok, "channel_switch_count"), 1)), ("PractMaxGap", fixed(max(r["max_recorded_gap_s"] for r in rows), 6)), # 全清局的动作耗时构成(图 practice-cost-bars 的同源数值) *[(f"PractQThree{name}", percent(value)) for name, value in cost_share(three_ok).items()], *[(f"PractQFour{name}", percent(value)) for name, value in cost_share(four_ok).items()], # 正文重点引用的两局 ("PractQThreeMainCase", q3_main["case_code"]), ("PractQThreeMainSources", str(q3_main["source_total"])), ("PractQThreeMainTime", fixed(q3_main["virtual_time_s"])), ("PractQThreeMainMove", fixed(q3_main["move_distance_m"] / 1000)), ("PractQThreeMainMeasures", str(q3_main["measure_accepted_count"])), ("PractQThreeMainSwitches", str(q3_main["channel_switch_count"])), ("PractQThreeMainRun", fixed(q3_main["program_run_duration_s"])), ("PractQFourMainCase", q4_main["case_code"]), ("PractQFourMainSources", str(q4_main["source_total"])), ("PractQFourMainDirectional", str(q4_main["directional_total"])), ("PractQFourMainTime", fixed(q4_main["virtual_time_s"])), ("PractQFourMainMove", fixed(q4_main["move_distance_m"] / 1000)), ("PractQFourMainMeasures", str(q4_main["measure_accepted_count"])), ("PractQFourMainSwitches", str(q4_main["channel_switch_count"])), ("PractQFourMainRun", fixed(q4_main["program_run_duration_s"])), ] lines = ["% Generated by scripts/build_practice_data.py; 自建模拟器演练局统计。"] lines += [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros] write_tex(PAPER / "data" / "practice-macros.tex", "\n".join(lines) + "\n") def table_environment(caption: str, label: str, colspec: str, header: str, body: list[str], placement: str = "htbp", tabcolsep: str = "4pt", fontsize: str = "small") -> str: """拼出完整的 table 环境。 表格不能被 ``\\input`` 切开:被包含文件的注释行会在表格内部注入一个 ``\\par``,使 booktabs 的 ``\\bottomrule`` 报 "Misplaced \\noalign"。 因此这里生成的是整表,正文只 ``\\input`` 这个整表文件。 """ font = "\\footnotesize" if fontsize == "footnotesize" else "\\small" return "\n".join([ f"\\begin{{table}}[{placement}]", f"\\centering{font}", f"\\caption{{{caption}}}", f"\\setlength{{\\tabcolsep}}{{{tabcolsep}}}", f"\\begin{{tabular}}{{{colspec}}}", "\\toprule", header, "\\midrule", *body, "\\bottomrule", "\\end{tabular}", f"\\label{{{label}}}", "\\end{table}", "", ]) def write_rows(rows: list[dict[str, Any]]) -> None: head = ("% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。" " 本文件是完整 table 环境,供正文直接 \\input。") data = PAPER / "data" # 正文主表:只列全清且无失败清除的局,按虚拟时间升序。 practice_header = ("序号 & 案例编码 & 源数 & 清除数 & 总虚拟时间/s & 移动/km & " "检测数 & 换频数 & 平均清除/(s/个)" + r"\\") for problem in (3, 4): subset = sorted(complete_rows(rows, problem), key=lambda r: r["virtual_time_s"]) body = [ f"{index} & \\texttt{{{r['case_code']}}} & {r['source_total']} & " f"{r['cleared_jammer_count']} & {fixed(r['virtual_time_s'])} & " f"{fixed(r['move_distance_m'] / 1000)} & {r['measure_accepted_count']} & " f"{r['channel_switch_count']} & {fixed(r['average_clear_time_s'])} " + r"\\" for index, r in enumerate(subset, start=1) ] kind = "全向源" if problem == 3 else "混合源" write_tex(data / f"practice-q{problem}-table.tex", "\n".join([head, table_environment( f"问题{['三', '四'][problem - 3]}:{kind}演练局结果(按总虚拟时间升序)", f"tab:q{problem}practice", "lrrrrrrrr", practice_header, body, tabcolsep="2pt")])) # 附录全量清单:含未全清局,标注是否全清与失败清除次数。 body = [] for problem in (3, 4): subset = sorted(problem_rows(rows, problem), key=lambda r: r["case_code"]) body += [ f"\\texttt{{{r['case_code']}}} & {problem} & {r['source_total']} & " f"{r['cleared_jammer_count']} & {r['clear_failure_count']} & " f"{fixed(r['virtual_time_s'])} & {r['measure_accepted_count']} & " f"{r['channel_switch_count']} & {fixed(r['move_distance_m'] / 1000)} & " f"{'是' if r['complete'] else '否'} " + r"\\" for r in subset ] write_tex(data / "practice-all-table.tex", "\n".join([head, table_environment( "全部演练局的逐局复算结果(“全清”列为是否达到该局源数且无失败清除)", "tab:practiceall", "lrrrrrrrrl", "案例编码 & 问题 & 源数 & 清除数 & 失败清除 & 总虚拟时间/s & 检测数 & 换频数 & " "移动/km & 全清" + r"\\", body, placement="H", tabcolsep="2pt", fontsize="footnotesize")])) def cost(r: dict[str, Any], label: str) -> str: total = r["virtual_time_s"] move, measure = r["move_time_s"], r["measure_time_s"] switch, clear = r["switch_time_s"], r["clear_time_s"] return ( f"{label} & {fixed(total)} & {fixed(move)} & {percent(move / total)} & " f"{fixed(measure)} & {percent(measure / total)} & {fixed(switch)} & " f"{percent(switch / total)} & {fixed(clear)} & {percent(clear / total)} " + r"\\" ) ordered = sorted(rows, key=lambda r: (r["problem_no"], r["case_code"])) labels = [f"Q{r['problem_no']}-{i}" for i, r in enumerate(ordered, start=1)] body = [cost(r, label) for r, label in zip(ordered, labels)] write_tex(data / "practice-cost-table.tex", "\n".join([head, table_environment( "演练局逐局动作耗时分解(时间单位:s)", "tab:practicecost", "lrrrrrrrrr", "编号 & 总虚拟时间 & 移动 & 占比 & 检测 & 占比 & 换频 & 占比 & 清除 & 占比" + r"\\", body, placement="H", tabcolsep="2pt", fontsize="footnotesize")])) # 汇总表:全清局的规模与时间区间,正文定量论据直接用这张表。 summary = [] for problem, label in ((3, "问题三(全向源)"), (4, "问题四(混合源)")): subset = complete_rows(rows, problem) share = cost_share(subset) summary.append( f"{label} & {len(subset)} & {sum(r['source_total'] for r in subset)} & " f"{fixed(mean(subset, 'virtual_time_s'))} & " f"{fixed(min(r['virtual_time_s'] for r in subset))}--" f"{fixed(max(r['virtual_time_s'] for r in subset))} & " f"{fixed(mean(subset, 'average_clear_time_s'))} & " f"{percent(share['MoveShare'])} " + r"\\") write_tex(data / "practice-summary-table.tex", "\n".join([head, table_environment( "演练局汇总(仅统计全清且无失败清除的局)", "tab:practicesummary", "lrrrrrrr", "问题 & 全清局数 & 累计清除 & 平均总虚拟时间/s & 时间区间/s & " "平均定位清除时间/(s/个) & 移动占比" + r"\\", summary, placement="H", tabcolsep="2pt", fontsize="footnotesize")])) # 动作耗时分解汇总表,正文表 tab:cost 直接引用。 cost_summary = [] for problem, label in ((3, "问题三(全向源)"), (4, "问题四(混合源)")): subset = complete_rows(rows, problem) total = sum(float(r["virtual_time_s"]) for r in subset) cells = [] for key in ("move_time_s", "measure_time_s", "switch_time_s", "clear_time_s"): value = sum(float(r[key]) for r in subset) cells.append(f"{fixed(value)}({percent(value / total)})") cost_summary.append(f"{label} & {len(subset)} & " + " & ".join(cells) + r" \\") write_tex(data / "practice-cost-summary-table.tex", "\n".join([head, table_environment( "演练全清局的动作耗时分解,括号内为占总虚拟时间的比例", "tab:cost", "lrrrrr", "问题 & 局数 & 移动/s(占比) & 检测/s(占比) & 换频/s(占比) & " "清除/s(占比)" + r"\\", cost_summary, tabcolsep="3pt")])) # 逐局真值与规模明细(问题三、问题四各一行)。 detail = [ f"\\texttt{{{r['case_code']}}} & {r['source_total']} & " f"{r['r_eff_min_m']:.1f}--{r['r_eff_max_m']:.1f} & {r['directional_total']} & " f"{r['cleared_jammer_count']} & {r['direction_measure_count']} & " f"{fixed(r['program_run_duration_s'])} " + r"\\" for r in ordered ] write_tex(data / "practice-detail-table.tex", "\n".join([head, table_environment( "演练局的真值规模与程序运行时间", "tab:practicedetail", "lrrrrrr", "案例编码 & 源数 & 接收半径范围/m & 定向源数 & 清除数 & 示向度观测数 & " "程序运行时间/s" + r"\\", detail, placement="H")])) def write_csv(rows: list[dict[str, Any]]) -> None: fields = ["problem_no", "case_code", "module", "seed", "source_total", "directional_total", "r_eff_min_m", "r_eff_max_m", "cleared_jammer_count", "all_cleared", "complete", "measure_accepted_count", "direction_measure_count", "channel_switch_count", "clear_failure_count", "virtual_time_s", "move_time_s", "move_distance_m", "measure_time_s", "switch_time_s", "clear_time_s", "program_run_duration_s", "average_clear_time_s", "end_reason", "max_recorded_gap_s"] out = PAPER / "data" / "practice-results.csv" with out.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore") writer.writeheader() for row in sorted(rows, key=lambda r: (r["problem_no"], r["case_code"])): writer.writerow(row) def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--check-formal", action="store_true", help="同时核对正式快照仍可读取(附录表格来源)") args = parser.parse_args() rows = collect_practice() if not rows: raise SystemExit("没有可用的自建模拟器演练局日志") payload = {"generator": "scripts/build_practice_data.py", "note": "自建模拟器(referee q3_practice/q4_practice)演练局复算结果", "records": rows} (PAPER / "data" / "practice-results.json").write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", "utf-8") write_macros(rows) write_rows(rows) write_csv(rows) example = write_geometry_example() print(f"问题一算例 {example['case_code']} 频道 {example['channel']}:" f"{example['vertex_count']} 个顶点,直径 {fixed(example['diameter_m'])} m," f"直径圆超出 {fixed(example['exceed_m'])} m," f"最小包围圆半径 {fixed(example['mec_radius_m'])} m") if args.check_formal: count = len(json.loads((PAPER / "data" / "formal-results.json").read_text("utf-8"))["records"]) print(f"正式快照可读:{count} 条记录(附录用)") three, four = problem_rows(rows, 3), problem_rows(rows, 4) three_ok, four_ok = complete_rows(rows, 3), complete_rows(rows, 4) print(f"演练局 {len(rows)} 局:问题三 {len(three)} 局(全清 {len(three_ok)})," f"问题四 {len(four)} 局(全清 {len(four_ok)})") print(f"累计清除 {sum(r['cleared_jammer_count'] for r in rows)} 个源," f"全清局清除失败 {sum(r['clear_failure_count'] for r in three_ok + four_ok)} 次") print(f"全清局平均虚拟时间:问题三 {fixed(mean(three_ok, 'virtual_time_s'))} s," f"问题四 {fixed(mean(four_ok, 'virtual_time_s'))} s") print(f"与日志记录的最大计时偏差 {fixed(max(r['max_recorded_gap_s'] for r in rows), 6)} s") return 0 if __name__ == "__main__": raise SystemExit(main())