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

570 lines
29 KiB
Python
Raw 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.
"""自建模拟器演练局的统计提取:把 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())