论文完成

This commit is contained in:
2026-09-13 18:05:59 +08:00
parent 9fb15f53f5
commit 89021eb9f8
234 changed files with 28597 additions and 2164 deletions
+569
View File
@@ -0,0 +1,569 @@
"""自建模拟器演练局的统计提取:把 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())