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