"""从行为日志 JSONL 复现 WebUI"回放"视图,并出论文级 PNG/SVG。 本脚本是 docs/figures/ 全部插图的唯一生成入口: uv run python scripts/build_docs_figures.py # 全量 uv run python scripts/build_docs_figures.py --only flow,replay uv run python scripts/build_docs_figures.py --list 三类数据来源: 1. **题目/规则常量**(``mcm26b.config`` 与 ``docs/`` 中的解析结论)——流程图、 计时示例、七点/31 点覆盖示意图,全部由公式现算,不抄数字; 2. **实测行为日志 JSONL**——执行时序分解图与回放复现图。优先读取仓库内 ``referee-data/sessions/*/log.jsonl``(评测机日志,含真值),其次读取 ``examples/replay/*.jsonl``(去重后的归档,供无本机评测机数据时复现); 3. **官方演练留档**(``output/q1/practice/*_observations.json``,若存在)—— 问题一交会定位区域图;缺失时回落到 ``examples/q1/triangle.json`` 反例算例。 所有图同时输出 SVG(WebUI/文档内联预览)与 PNG(论文插图,300+ dpi 等效), 并把数据来源与 sha256 写入 ``docs/figures/manifest.json``。 """ from __future__ import annotations import argparse import json import math import shutil import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable, Sequence sys.path.insert(0, str(Path(__file__).resolve().parent)) from figkit import (DEFAULT_RASTERIZER, PALETTE, ROOT, Figure, # noqa: E402 write_manifest) OUT_DIR = ROOT / "docs" / "figures" ARCHIVE_DIR = ROOT / "examples" / "replay" SESSIONS_DIR = ROOT / "referee-data" / "sessions" # 题面常量(与 src/mcm26b/config.py 一致;此处仅用于画图,不参与求解) OMEGA_R = 1800.0 R_EFF = 1000.0 SPEED_MPS = 5.0 MEASURE_S = 5.0 SWITCH_S = 1.0 CLEAR_OK_S = 5.0 CLEAR_NO_S = 3.0 RING_RADIUS_M = 1159.7 SEVEN_R = 1800.0 GRID_STEP_M = 700.0 Q4_DESIGN_R = 965.0 Q4_HEADING_MARGIN_DEG = 5.0 Q4_WORST_HIT_M = 990.051 # =========================================================================== # 行为日志解析:评测机日志与参赛队客户端会话日志统一成同一事件流 # =========================================================================== @dataclass class LogRun: """一局可回放的行为日志(与 referee `replay.js` 的归一化口径一致)。""" label: str case_code: str module: str robot_id: str truth: list[dict[str, Any]] actions: list[dict[str, Any]] # 保序:{seq, path, request, response} source: Path @property def clears(self) -> int: return sum(1 for a in self.actions if a["path"] == "/clear") @property def measures(self) -> int: return sum(1 for a in self.actions if a["path"] == "/measure") @property def virtual_s(self) -> float: last = self.actions[-1]["response"] if self.actions else {} return float(last.get("virtual_time_s") or 0.0) def solution_channels(self) -> list[int]: return sorted({int(a["request"]["channel"]) for a in self.actions if a["path"] == "/clear" and "channel" in a["request"]}) def parse_jsonl(path: Path) -> LogRun: """兼容两种日志:评测机 ``event=action/session_start`` 与客户端 ``event=accepted/attempt/step``(取最终 accepted,重试不重复计)。""" meta: dict[str, Any] = {} actions: list[dict[str, Any]] = [] fallback: dict[str, dict[str, Any]] = {} seq = 0 for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: ev = json.loads(line) except json.JSONDecodeError: continue kind = ev.get("event") if kind == "session_start": meta = ev elif kind == "action": seq += 1 actions.append(dict(seq=ev.get("seq", seq), path=ev.get("path", ""), request=ev.get("request") or {}, response=ev.get("response") or {}, wall_ts_ms=ev.get("wall_ts_ms"))) elif kind == "accepted": fallback[ev.get("request_id", "")] = ev elif kind == "step" and ev.get("request_id") not in fallback: fallback[ev.get("request_id", "")] = ev if not actions: # 客户端日志:attempt → accepted → step for ev in fallback.values(): if not ev.get("path"): continue seq += 1 actions.append(dict(seq=seq, path=ev["path"], request=ev.get("request") or {}, response=ev.get("response") or {}, wall_ts_ms=ev.get("logged_at_unix_s", 0) * 1000 if ev.get("logged_at_unix_s") else None)) if not actions: raise ValueError(f"{path} 中没有可回放的动作事件") return LogRun(label=path.stem, case_code=meta.get("case_code") or path.parent.name, module=meta.get("module", "?"), robot_id=str(meta.get("robot_id", "?")), truth=list(meta.get("truth") or []), actions=actions, source=path) @dataclass class Timeline: """逐动作的计时分解:与评测机引擎/附件规则同源。""" rows: list[dict[str, Any]] total_virtual_s: float total_real_s: float total_move_m: float def decompose(run: LogRun) -> Timeline: """按附件计时规则逐动作复算:move/switch/measure/clear 四类耗时。""" pos = (0.0, 0.0) channel: int | None = None rows: list[dict[str, Any]] = [] total_move = 0.0 for act in run.actions: req, res = act["request"], act["response"] move_m = switch = action_s = 0.0 detail = "" if act["path"] in ("/measure", "/clear") and "position" in req: tgt = (float(req["position"]["x"]), float(req["position"]["y"])) move_m = math.dist(pos, tgt) if move_m > 0: pos = tgt if act["path"] == "/measure": ch = int(req.get("channel", 0)) switch = 0.0 if channel in (None, ch) else SWITCH_S channel = ch action_s = MEASURE_S elif act["path"] == "/clear": ok = bool(res.get("clear_result") == "cleared") action_s = CLEAR_OK_S if ok else CLEAR_NO_S detail = "清除成功" if ok else "未发现" rows.append(dict( seq=act["seq"], path=act["path"], move_s=move_m / SPEED_MPS, switch_s=switch, action_s=action_s, move_m=move_m, virtual_s=float(res.get("virtual_time_s") or 0.0), detail=detail, channel=req.get("channel"), x=req.get("position", {}).get("x"), y=req.get("position", {}).get("y"))) total_move += move_m wall = [r for r in run.actions if r.get("wall_ts_ms")] total_real = (wall[-1]["wall_ts_ms"] - wall[0]["wall_ts_ms"]) / 1000.0 if len(wall) > 1 else 0.0 return Timeline(rows=rows, total_virtual_s=run.virtual_s, total_real_s=total_real, total_move_m=total_move) # =========================================================================== # 数据源选择 # =========================================================================== def discover_runs() -> list[LogRun]: """枚举可回放的日志:优先评测机留档,其次 examples/replay 归档。""" runs: list[LogRun] = [] if SESSIONS_DIR.is_dir(): for log in sorted(SESSIONS_DIR.glob("*/log.jsonl")): try: runs.append(parse_jsonl(log)) except ValueError: continue if not runs and ARCHIVE_DIR.is_dir(): for log in sorted(ARCHIVE_DIR.glob("*.jsonl")): try: runs.append(parse_jsonl(log)) except ValueError: continue return runs def pick_run(runs: Sequence[LogRun], *, module: str, truth: int) -> LogRun | None: """挑一局"完整、干净、点数匹配"的日志:优先全清且动作数适中的那局。""" cands = [r for r in runs if r.module.startswith(module) and len(r.truth) >= truth and r.measures > 0 and r.solution_channels()] if not cands: cands = [r for r in runs if r.module.startswith(module)] if not cands: return None return sorted(cands, key=lambda r: (abs(len(r.truth) - truth), len(r.actions)))[0] def q1_practice_case() -> dict[str, Any] | None: """官方演练问题一留档(输出的定位多边形/圆都由当时的求解器算出)。""" for path in sorted((ROOT / "output" / "q1" / "practice").glob("*_observations.json")): doc = json.loads(path.read_text(encoding="utf-8")) if doc.get("result"): doc["_path"] = path return doc return None # =========================================================================== # 通用画法 # =========================================================================== def layer_band(fig: Figure, y: float, h: float, tag: str, label: str, color: str, *, x: float = 40.0, w: float = 120.0) -> None: """左侧"层"标签带 + 右侧标题文字。""" fig.rect(x, y, w, h, fill=color, stroke=None, radius=6.0) fig.text(x + w / 2, y + h / 2, tag, size=13, font="hei_bold", color=PALETTE["paper"], anchor="middle", align="middle") fig.text(x + w + 12, y + 16, label, size=13.5, font="hei_bold", color=color) def box(fig: Figure, x: float, y: float, w: float, h: float, title: str, body: str = "", *, fill: str | None = None, stroke: str = PALETTE["primary"], title_font: str = "hei_bold", title_size: float = 12.5, body_size: float = 10.5, body_lines: int | None = None, text_color: str | None = None, radius: float = 6.0) -> None: """圆角框 + 居中标题(+ 可选多行说明)。""" fig.rect(x, y, w, h, fill=fill if fill is not None else PALETTE["primary_soft"], stroke=stroke, sw=1.2, radius=radius) lines = body.split("\n") if body else [] n = len(lines) if body: block = title_size + 4.0 + n * (body_size + 3.0) ty = y + (h - block) / 2 + title_size * 0.5 else: ty = y + h / 2 fig.text(x + w / 2, ty, title, size=title_size, font=title_font, color=text_color or PALETTE["ink"], anchor="middle", align="middle") for i, line in enumerate(lines): fig.text(x + w / 2, ty + title_size * 0.5 + 4.0 + body_size * 0.5 + i * (body_size + 3.0), line, size=body_size, color=PALETTE["muted"], anchor="middle", align="middle") if body_lines is not None: assert n <= body_lines, f"说明行数 {n} 超过预留 {body_lines}:{title}" def panel_title(fig: Figure, x: float, y: float, text: str, *, size: float = 12.5, color: str | None = None) -> None: fig.text(x, y, text, size=size, font="hei_bold", color=color or PALETTE["primary"]) def footer_note(fig: Figure, text: str, *, y: float | None = None) -> None: fig.text(24, y if y is not None else fig.height - 22, text, size=10, color=PALETTE["muted"]) def axis_x(fig: Figure, x0: float, x1: float, y: float, label: str, *, ticks: Sequence[float] | None = None, unit: str = "", size: float = 10.0, color: str | None = None, fmt: str = "{:.0f}") -> None: color = color or PALETTE["muted"] fig.line(x0, y, x1, y, stroke=color, sw=1.0) fig.text((x0 + x1) / 2, y + 30, label, size=size + 0.5, color=PALETTE["ink"], anchor="middle") if ticks: for t in ticks: px = x0 + (x1 - x0) * t fig.line(px, y - 3, px, y + 3, stroke=color, sw=1.0) fig.text(px, y + 14, (fmt.format(t) if not unit else fmt.format(t) + unit), size=size, color=color, anchor="middle") # =========================================================================== # 图 1:总体思路与算法主线 # =========================================================================== def fig_flow() -> Figure: fig = Figure(1180, 700, title="B 题总体思路与算法主线", caption="四问共享同一套"协议计时 + 有界误差几何 + 信念更新 + 覆盖计划"主线") fig.source("src/mcm26b/config.py") fig.source("docs/B题建模分析与实施计划.md") # L0 题面抽象 layer_band(fig, 66, 46, "L0", "题面抽象:把文字约束变成可计算的集合", PALETTE["primary"], w=110) l0 = [("区域 Ω:半径 1800 m 的圆盘", PALETTE["faint"]), ("动作耗时:5 m/s · 检测 5 s · 换频 1 s · 清除 5/3 s", PALETTE["faint"]), ("观测语义:direction / no_signal / near", PALETTE["faint"]), ("源:10~16 个,频道 1~20,定向源覆盖 ±90°", PALETTE["faint"])] for i, (t, fill) in enumerate(l0): box(fig, 168 + i * 250, 62, 236, 54, t, fill=fill, stroke=PALETTE["faint"], title_font="song", title_size=10.5, text_color=PALETTE["ink"]) # L1 三层在线架构 layer_band(fig, 140, 192, "L1", "在线三层:证据 → 决策 → 执行(依赖只能向下)", PALETTE["primary"], w=110) layers = [ ("证据层", "候选集 K_c:角域交 + 负观测裁剪\n粒子/网格幸存集:位置 × 朝向", PALETTE["primary_soft"]), ("决策层", "选点:信息增益 / 最坏后验半径\n任务表:检测 · 定位 · 清除 · 滚动 2-opt", PALETTE["accent_soft"]), ("执行层", "SimulatorClient:串行 + 幂等重试\n本地截止时钟 + 预留 /exit", PALETTE["success_soft"]), ] for i, (t, b, fill) in enumerate(layers): box(fig, 168 + i * 336, 168, 320, 140, t, b, fill=fill, stroke=PALETTE["primary"]) if i < 2: fig.arrow(168 + i * 336 + 322, 238, 168 + (i + 1) * 336 - 4, 238, stroke=PALETTE["ink"], sw=1.6) # 四问主线 layer_band(fig, 356, 130, "L2", "四问主线:同一内核逐问扩展(复用而不是重写)", PALETTE["accent"], w=110) qs = [ ("问题 1\n定位解算", "半平面交 P=⋂Wᵢ\nD(P) 顶点对枚举\nMEC 半径 ≤ 20 m 即保证清除", PALETTE["primary_soft"]), ("问题 2\n第二点选择", "候选集 K₁ = Ω∩W(s₁)∩B(s₁,1500)\n安全候选盘半径 R/(2cosδ) − 1000\n最坏后验半径最小化", PALETTE["accent_soft"]), ("问题 3\n全向搜索", "七点覆盖 a=1159.7 m ⇒ 984.5 m < 1000 m\n贝叶斯闭环 + 任务表调度\n全清即证明,立即 /exit", PALETTE["success_soft"]), ("问题 4\n混合定向", "forward_half_disk 31 点最小命中集\n位置 × 朝向联合信念\n有限格后备盲清", PALETTE["warn_soft"]), ] for i, (t, b, fill) in enumerate(qs): box(fig, 168 + i * 250, 384, 236, 96, t, b, fill=fill, stroke=PALETTE["primary"], title_size=12.0, body_size=9.5) if i < 3: fig.arrow(168 + i * 250 + 238, 432, 168 + (i + 1) * 250 - 2, 432, stroke=PALETTE["muted"], sw=1.2) # 三条闭环 layer_band(fig, 508, 92, "L3", "三条闭环:发现 → 定位 → 清除 → 覆盖记账", PALETTE["success"], w=110) loops = [ ("发现闭环", "覆盖点巡回扫描,任一频道亮灯即登记新源"), ("定位闭环", "同频道 ≥2 点交会 → 直径/MEC 判可清除"), ("清除闭环", "clear 成功即销账;未发现转后备盲清"), ] for i, (t, b) in enumerate(loops): box(fig, 168 + i * 336, 530, 320, 52, t + ":" + b, fill=PALETTE["paper"], stroke=PALETTE["success"], title_font="song", title_size=10.5) fig.arrow(600, 486, 600, 528, stroke=PALETTE["muted"], sw=1.2) footer_note(fig, "口径:计时/几何/覆盖常数均来自 src/mcm26b/config.py;" "各问实现的核对结果见 docs/algo-1..4.md。") return fig # =========================================================================== # 图 2:执行步骤状态机(协议回合) # =========================================================================== def fig_steps() -> Figure: fig = Figure(1100, 560, title="执行步骤:接口状态机与一次动作回合", caption="题目没有显式协议;实际系统是"窗口驱动 + 严格串行"的动作回合") fig.source("docs/协议规则表与状态机.md") fig.source("src/mcm26b/protocol/client.py") # 上:会话状态机 panel_title(fig, 40, 44, "(a) 会话状态机:窗口开放与截止") states = [("IDLE", 90, PALETTE["faint"]), ("COUNTDOWN 5 s", 300, PALETTE["warn_soft"]), ("OPEN:接口开放", 560, PALETTE["success_soft"]), ("CLOSED:断连", 830, PALETTE["accent_soft"])] for i, (t, x, fill) in enumerate(states): box(fig, x, 72, 180, 52, t, fill=fill, stroke=PALETTE["muted"], title_size=11.5) if i < 3: fig.arrow(x + 182, 98, states[i + 1][1] - 4, 98, stroke=PALETTE["ink"], sw=1.4) fig.text(560, 138, "/enter 成功 ⇒ 现实预算 1200 s 起算;" "虚拟世界限时 360000 s(两者独立)", size=10.5, color=PALETTE["muted"], anchor="middle") fig.text(830, 156, "剩 60 s 预留 /exit", size=10, color=PALETTE["accent"], anchor="middle") # 下:单次动作回合 panel_title(fig, 40, 206, "(b) 单次动作回合:一次请求的完整生命周期") steps = [ ("本地预校验", "channel ∈ [1,20]\n坐标有限且 ≤2e6\nrequest_id 格式", PALETTE["faint"]), ("串行发送", "同一时刻只有\n一个未决请求\n(不并发)", PALETTE["primary_soft"]), ("等服务端计时", "accepted=true 才更新\n虚拟时刻以响应为准\naccepted=false 不改状态", PALETTE["warn_soft"]), ("更新本地状态", "位置 / 当前频道\n剩余现实预算\nJSONL 会话日志", PALETTE["success_soft"]), ("幂等重试(异常路径)", "网络错 / 429 / 500:\n同 ID 同内容原样重发\n400/409 立即失败", PALETTE["accent_soft"]), ] x0, w, gap = 44, 186, 26 for i, (t, b, fill) in enumerate(steps): x = x0 + i * (w + gap) box(fig, x, 240, w, 104, t, b, fill=fill, stroke=PALETTE["muted"], title_size=11.0, body_size=9.5) if i < len(steps) - 1: fig.arrow(x + w + 2, 292, x + w + gap - 4, 292, stroke=PALETTE["ink"], sw=1.3) # 计时规则表 panel_title(fig, 40, 382, "(c) 每个动作的虚拟耗时(附件1 表1 / 附件2 第4节)") rows = [ ("/enter · /exit", "0 s", "不改变虚拟时刻,/exit 不切频道"), ("移动(/measure、/clear 携带 position)", "直线距离 / 5 m·s⁻¹", "同点不动则 0 s"), ("切换测向频道(仅 /measure 触发)", "1 s", "/clear 不切换频道"), ("检测 /measure", "5 s", "无论返回 direction 还是 no_signal"), ("清除 /clear 成功 / 未发现", "5 s / 3 s", "5 s = 光学定位 3 s + 激光清除 2 s"), ] top, rh = 412, 26 fig.rect(44, top, 1012, rh, fill=PALETTE["primary_soft"], stroke=None) for j, head in enumerate(("指令 / 动作", "虚拟耗时", "说明")): fig.text([64, 560, 700][j], top + rh / 2, head, size=10.5, font="hei_bold", align="middle") for i, (a, b, c) in enumerate(rows): y = top + rh * (i + 1) if i % 2 == 0: fig.rect(44, y, 1012, rh, fill="#f7f9fb", stroke=None) fig.text(64, y + rh / 2, a, size=10.5, align="middle") fig.text(560, y + rh / 2, b, size=10.5, font="times_bold", color=PALETTE["accent"], align="middle") fig.text(700, y + rh / 2, c, size=10, color=PALETTE["muted"], align="middle") footer_note(fig, "总分:T = L/5 + 5N_m + N_s + 5N_c⁺ + 3N_c⁻(虚拟时间即成绩口径)") return fig # =========================================================================== # 图 3:附件计时示例逐步推演 # =========================================================================== EXAMPLE_STEPS = [ ("/enter", 0.0, 0.0, 0.0, "初始频道 1,虚拟时刻 0"), ("/measure (300,400) ch1", 100.0, 0.0, 5.0, "移动 500 m;频道未变"), ("/measure (300,400) ch2", 0.0, 1.0, 5.0, "换频 1 s,位置不变"), ("/clear (300,0) ch3", 80.0, 0.0, 3.0, "未发现目标;/clear 不切频道"), ("/measure (300,0) ch2", 0.0, 0.0, 5.0, "频道仍是 2(第 3 步之后未变)"), ("/exit", 0.0, 0.0, 0.0, "退出,虚拟时刻停在 199 s"), ] def fig_timing_rule() -> Figure: fig = Figure(1160, 470, title="附件计时示例逐步推演(105 / 111 / 194 / 199)", caption="协议计时的唯一权威例子:每一步的分项与累计都必须逐项复现") fig.source("docs/协议规则表与状态机.md") # 左:堆叠条 panel_title(fig, 40, 44, "(a) 每一步的耗时构成与累计虚拟时刻") x0, w, gap, base = 60, 120, 34, 300 scale = 0.62 # px per second acc = 0.0 for i, (cmd, mv, sw, ac, note) in enumerate(EXAMPLE_STEPS): x = x0 + i * (w + gap) total = mv + sw + ac y = base for value, color in ((mv, PALETTE["series"][0]), (sw, PALETTE["series"][3]), (ac, PALETTE["series"][2])): h = value * scale if h > 0: fig.rect(x, y - h, w, h, fill=color, stroke=None) fig.text(x + w / 2, y - h / 2, f"{value:.0f}", size=10.5, font="times_bold", color=PALETTE["paper"], anchor="middle", align="middle") y -= h acc += total fig.text(x + w / 2, base + 20, cmd.split(" ")[0], size=10.5, font="times_bold", anchor="middle") fig.text(x + w / 2, base + 36, (cmd.split(" ", 1)[1] if " " in cmd else ""), size=9, color=PALETTE["muted"], anchor="middle") fig.text(x + w / 2, base - total * scale - 18, f"Σ={acc:.0f}", size=11, font="times_bold", color=PALETTE["accent"], anchor="middle") if i: fig.arrow(x - gap + 4, base + 8, x - 6, base + 8, stroke=PALETTE["faint"], sw=1.0, head=6) fig.line(x0, base + 2, x0 + 6 * (w + gap) - gap, base + 2, stroke=PALETTE["muted"], sw=1.2) fig.legend_chips(60, 70, [("移动 L/5", PALETTE["series"][0]), ("换频 1 s", PALETTE["series"][3]), ("动作 5 s / 3 s", PALETTE["series"][2])], size=10.5) # 右:核对表 panel_title(fig, 700, 44, "(b) 与附件原文逐步核对") top, rh = 66, 30 heads = ("步", "指令", "本步", "累计", "关键点") widths = (34, 250, 60, 60, 316) xs = [700] for wd in widths[:-1]: xs.append(xs[-1] + wd) fig.rect(700, top, sum(widths), rh, fill=PALETTE["primary_soft"], stroke=None) for x, head in zip(xs, heads): fig.text(x + 8, top + rh / 2, head, size=10.5, font="hei_bold", align="middle") acc = 0.0 for i, (cmd, mv, sw, ac, note) in enumerate(EXAMPLE_STEPS): y = top + rh * (i + 1) acc += mv + sw + ac if i == 3: fig.rect(700, y, sum(widths), rh, fill=PALETTE["warn_soft"], stroke=None) elif i % 2 == 0: fig.rect(700, y, sum(widths), rh, fill="#f7f9fb", stroke=None) fig.text(xs[0] + 8, y + rh / 2, str(i + 1), size=10.5, align="middle") fig.text(xs[1] + 8, y + rh / 2, cmd, size=10, align="middle") fig.text(xs[2] + 8, y + rh / 2, f"{mv + sw + ac:.0f}", size=10.5, font="times_bold", align="middle") fig.text(xs[3] + 8, y + rh / 2, f"{acc:.0f}", size=10.5, font="times_bold", color=PALETTE["accent"], align="middle") fig.text(xs[4] + 8, y + rh / 2, note, size=9.5, color=PALETTE["muted"], align="middle") fig.text(700, top + rh * 7 + 18, "验收:uv run mcm26b sim-check 复现 [105, 111, 194, 199]", size=10.5, color=PALETTE["success"]) fig.text(700, top + rh * 7 + 38, "tests/test_protocol_timing.py 逐步断言移动 / 换频 / 动作三个分项", size=10, color=PALETTE["muted"]) footer_note(fig, "注:第 4 步 /clear 未发现目标,只计入 3 s 光学定位;" "第 5 步频道仍为 2,因此不产生换频耗时。") return fig # =========================================================================== # 图 4:实测日志的虚拟时间分解 # =========================================================================== def fig_timing_run(run: LogRun) -> Figure: tl = decompose(run) fig = Figure(1160, 600, title=f"实测执行时序分解:{run.case_code}({run.module}," f"{len(run.truth)} 源)", caption="同一局的虚拟时间构成、累计曲线与现实耗时对照") fig.source(run.source, case_code=run.case_code, module=run.module, actions=len(run.actions)) left, right = 70, 1120 mid = 560 # (a) 累计虚拟时间 panel_title(fig, 40, 44, "(a) 累计虚拟时间(按动作类型堆叠)") top, bottom = 76, 300 tv = max(tl.total_virtual_s, 1.0) acc = {"move": 0.0, "switch": 0.0, "action": 0.0} xs, series = [], [] for row in tl.rows: acc["move"] += row["move_s"] acc["switch"] += row["switch_s"] acc["action"] += row["action_s"] xs.append(row["seq"]) series.append(dict(acc)) n = max(len(xs), 1) def px(seq: float) -> float: return left + (right - left) * (seq - 1) / max(n - 1, 1) def py(value: float) -> float: return bottom - (bottom - top) * (value / (tv * 1.08)) for f in (0.25, 0.5, 0.75, 1.0): y = py(tv * f) fig.line(left, y, right, y, stroke=PALETTE["faint"], sw=0.8) fig.text(left - 8, y, f"{tv * f:.0f}", size=10, font="times", anchor="end", align="middle") fig.text(left - 52, (top + bottom) / 2, "虚拟时间 / s", size=10.5, anchor="middle", rotate=-90) layers = ("move", "switch", "action") colors = {"move": PALETTE["series"][0], "switch": PALETTE["series"][3], "action": PALETTE["series"][2]} for y_prev in (0.0,): pass for i, key in enumerate(layers): upper = [s[key] + sum(s[k] for k in layers[i + 1:]) for s in series] lower = [sum(s[k] for k in layers[i + 1:]) for s in series] pts = [(px(x), py(u)) for x, u in zip(xs, upper)] + \ [(px(x), py(l)) for x, l in zip(reversed(xs), reversed(lower))] fig.polygon(pts, fill=colors[key], stroke=None, opacity=0.9) fig.line(left, bottom, right, bottom, stroke=PALETTE["muted"], sw=1.2) fig.legend_chips(72, 62, [("移动 L/5", colors["move"]), ("换频 1 s", colors["switch"]), ("检测/清除动作", colors["action"])], size=10.5) fig.text(right, bottom + 22, f"动作序号 1 … {n}", size=10, anchor="end") # (b) 分项占比 panel_title(fig, 40, 334, "(b) 虚拟时间构成占比") totals = {k: sum(s[k] for s in series) for k in layers} bar_x, bar_y, bar_w, bar_h = 70, 366, 420, 26 cur = bar_x for key in ("move", "action", "switch"): seg = bar_w * (totals[key] / tv) fig.rect(cur, bar_y, seg, bar_h, fill=colors[key], stroke=None) if seg > 34: fig.text(cur + seg / 2, bar_y + bar_h / 2, f"{totals[key] / tv * 100:.0f}%", size=10.5, font="times_bold", color=PALETTE["paper"], anchor="middle", align="middle") cur += seg labels = [("移动", "move"), ("检测/清除", "action"), ("换频", "switch")] ly = bar_y + bar_h + 22 for label, key in labels: fig.rect(bar_x, ly - 5, 10, 10, fill=colors[key], stroke=None, radius=2) fig.text(bar_x + 16, ly, f"{label} {totals[key]:,.0f} s", size=10.5, align="middle") bar_x += 140 fig.text(70, bar_y + bar_h + 52, f"合计 {tv:,.1f} s = 移动 {totals['move']:,.0f} + 换频 {totals['switch']:,.0f} " f"+ 动作 {totals['action']:,.0f}", size=10.5, color=PALETTE["ink"]) fig.text(70, bar_y + bar_h + 74, f"检测 {run.measures} 次 · 清除 {run.clears} 次 · " f"移动总里程 {tl.total_move_m / 1000:,.1f} km · " f"清除频道 {len(run.solution_channels())}/{len(run.truth)}", size=10.5, color=PALETTE["muted"]) # (c) 现实耗时 panel_title(fig, 40, 470, "(c) 现实耗时对照") panel_title(fig, 620, 470, "(d) 逐动作耗时(前 40 个动作)") rw = tl.total_real_s real_x, real_y, real_w = 70, 506, 420 ratio = tv / rw if rw > 0 else 0.0 fig.rect(real_x, real_y, real_w, 20, fill=PALETTE["primary_soft"], stroke=None) fig.rect(real_x, real_y, real_w * min(1.0, 1.0), 20, fill=PALETTE["primary"], stroke=None) fig.text(real_x, real_y + 40, f"虚拟 {tv:,.1f} s ÷ 现实 {rw:.2f} s ≈ {ratio:,.0f} 倍加速", size=11, font="times_bold", color=PALETTE["accent"]) fig.text(real_x, real_y + 60, "现实耗时只受网络与服务端开销影响:本地回环约毫秒级," "因此成绩口径恒为虚拟时间。", size=9.5, color=PALETTE["muted"]) bx, by, bw, bh = 620, 494, 500, 76 subset = tl.rows[:40] mx = max((r["move_s"] + r["switch_s"] + r["action_s"]) for r in subset) or 1.0 for i, row in enumerate(subset): x = bx + bw * i / max(len(subset), 1) wpx = bw / max(len(subset), 1) * 0.72 y = by + bh for value, key in ((row["move_s"], "move"), (row["switch_s"], "switch"), (row["action_s"], "action")): h = bh * value / mx if h > 0: fig.rect(x, y - h, wpx, h, fill=colors[key], stroke=None) y -= h fig.line(bx, by + bh, bx + bw, by + bh, stroke=PALETTE["muted"], sw=1.0) fig.text(bx + bw / 2, by + bh + 24, "动作序号(1…40)", size=10, anchor="middle") fig.text(bx + bw + 6, by + 6, f"峰值 {mx:.1f} s", size=9.5, color=PALETTE["muted"]) footer_note(fig, "复算口径与 referee.simulator 同源:移动=直线距离/5 m·s⁻¹、" "换频 1 s、检测 5 s、清除成功 5 s / 未发现 3 s。") return fig # =========================================================================== # 图 5/6:问题一几何 # =========================================================================== def _world(fig: Figure, x: float, y: float, w: float, h: float, half: float, *, grid: float = 250.0) -> tuple[Any, Any]: """建立世界坐标 → 画布坐标的映射(等比例、y 轴向上)。""" def sx(wx: float) -> float: return x + w / 2 + wx / half * (w / 2) def sy(wy: float) -> float: return y + h / 2 - wy / half * (h / 2) def poly(points: Iterable[Sequence[float]]) -> list[list[float]]: return [[sx(px), sy(py)] for px, py in points] fig.rect(x, y, w, h, fill="#fbfcfd", stroke=PALETTE["faint"], sw=1.0) step = grid while step < half: fig.line(sx(-half), sy(step), sx(half), sy(step), stroke="#eef2f6", sw=0.8) fig.line(sx(-half), sy(-step), sx(half), sy(-step), stroke="#eef2f6", sw=0.8) fig.line(sx(step), sy(-half), sx(step), sy(half), stroke="#eef2f6", sw=0.8) fig.line(sx(-step), sy(-half), sx(-step), sy(half), stroke="#eef2f6", sw=0.8) step *= 2 fig.line(sx(-half), sy(0), sx(half), sy(0), stroke=PALETTE["faint"], sw=1.0) fig.line(sx(0), sy(-half), sx(0), sy(half), stroke=PALETTE["faint"], sw=1.0) return sx, sy def _wedge(fig: Figure, sx: Any, sy: Any, station: Sequence[float], bearing_deg: float, half_deg: float, reach: float, *, color: str, opacity: float = 0.28, sw: float = 1.2, dash: str | None = None) -> None: """前向角域扇形:从检测点沿示向度 ±half 度、长度 reach。""" a0 = math.radians(bearing_deg - half_deg) a1 = math.radians(bearing_deg + half_deg) pts = [station] steps = 12 for i in range(steps + 1): a = a0 + (a1 - a0) * i / steps pts.append([station[0] + reach * math.cos(a), station[1] + reach * math.sin(a)]) fig.polygon([[sx(px), sy(py)] for px, py in pts], fill=color, stroke=None, opacity=opacity) for a in (a0, a1): fig.line(sx(station[0]), sy(station[1]), sx(station[0] + reach * math.cos(a)), sy(station[1] + reach * math.sin(a)), stroke=color, sw=sw, dash=dash) fig.line(sx(station[0]), sy(station[1]), sx(station[0] + reach * math.cos(math.radians(bearing_deg))), sy(station[1] + reach * math.sin(math.radians(bearing_deg))), stroke=color, sw=sw + 0.4) def fig_q1_meetup() -> Figure: case = q1_practice_case() fig = Figure(1160, 540, title="问题一:角域交会、直径与最小包围圆", caption="半平面交给出定位区域 P;直径与最小包围圆决定"能否保证清除"") if case: title = f"官方演练案例 {case['case_code']}(频道 {case['channel']},三点示向度 ±1°)" source = case["_path"] else: title = "反例算例 examples/q1/triangle.json(三点示向度 ±1°)" source = ROOT / "examples" / "q1" / "triangle.json" panel_title(fig, 40, 44, "(a) " + title) half = 60.0 gx, gy, gw, gh = 60, 70, 560, 400 sx, sy = _world(fig, gx, gy, gw, gh, half, grid=20.0) if case: stations = [(m["x"], m["y"], m["bearing_deg"], m["half_angle_deg"]) for m in case["measurements"]] verts = case["result"]["vertices"] d_pair = case["result"]["diameter_pair"] dc = case["result"]["diameter_circle"] mec = case["result"]["minimum_enclosing_circle"] mec_r = mec["radius_m"] E = dc["max_vertex_distance_m"] - dc["radius_m"] D = case["result"]["diameter_m"] else: doc = json.loads(Path(source).read_text(encoding="utf-8")) stations = [(m["x"], m["y"], m["bearing_deg"], m.get("half_angle_deg", 1.0)) for m in doc["measurements"]] sol = _solve_q1(stations) verts, d_pair = sol["vertices"], sol["diameter_pair"] dc, mec = sol["diameter_circle"], sol["mec"] mec_r = mec["radius_m"] E = dc["max_vertex_distance_m"] - dc["radius_m"] D = math.dist(*d_pair) for s_x, s_y, bearing, hd in stations: _wedge(fig, sx, sy, (s_x, s_y), bearing, hd, 150.0, color=PALETTE["series"][0], opacity=0.14) fig.circle(sx(s_x), sy(s_y), 4.0, fill=PALETTE["primary"], stroke=None) fig.text(sx(s_x) + 7, sy(s_y) - 8, f"({s_x:.0f}, {s_y:.0f})", size=9, color=PALETTE["muted"], font="times") poly = [[sx(px), sy(py)] for px, py in verts] fig.polygon(poly, fill=PALETTE["accent_soft"], stroke=PALETTE["accent"], sw=1.4, opacity=0.95) fig.circle(sx(dc["center"][0]), sy(dc["center"][1]), dc["radius_m"] * gw / (2 * half), fill=None, stroke=PALETTE["series"][4], sw=1.2, dash="5 4") fig.circle(sx(mec["center"][0]), sy(mec["center"][1]), mec["radius_m"] * gw / (2 * half), fill=None, stroke=PALETTE["success"], sw=1.6) fig.circle(sx(mec["center"][0]), sy(mec["center"][1]), 3.5, fill=PALETTE["success"], stroke=None) fig.legend_chips(74, 486, [("前向角域 Wᵢ", PALETTE["series"][0]), ("定位区域 P", PALETTE["accent"]), ("直径圆(不覆盖)", PALETTE["series"][4]), ("最小包围圆 MEC", PALETTE["success"])], size=10) fig.text(gx + gw - 6, gy + gh - 14, "单位:米", size=9.5, color=PALETTE["muted"], anchor="end") # 右栏:结论 rx = 660 panel_title(fig, rx, 44, "(b) 关键量与判定") rows = [ ("定位区域状态", "多边形(有界)"), ("直径 D(P)", f"{ctx['D']:.4f} m"), ("最小包围圆半径 ρ", f"{ctx['rho']:.4f} m"), ("直径圆最大超出 E", f"{ctx['E']:+.4f} m"), ("直径圆是否覆盖 P", "否" if ctx["E"] > 0 else "是"), ("ρ ≤ 20 m ⇒ 可保证清除", "是" if ctx["rho"] <= 20 else "否"), ] top, rh = 76, 34 for i, (k, v) in enumerate(rows): y = top + i * rh fill = "#f7f9fb" if i % 2 == 0 else None if fill: fig.rect(rx, y, 460, rh, fill=fill, stroke=None) fig.text(rx + 10, y + rh / 2, k, size=11, align="middle") fig.text(rx + 450, y + rh / 2, v, size=11.5, font="times_bold", color=PALETTE["accent"] if i in (3, 4, 5) else PALETTE["ink"], anchor="end", align="middle") box(fig, rx, top + 6 * rh + 16, 460, 96, "两个证明要点", "① 直径只需枚举顶点对:凸集上最远点必为顶点;\n" "② 直径圆唯一可能圆心是直径中点:若 a、b 同在该圆内,\n" " 三角不等式两处同时取等 ⇒ 圆心只能是中点。", fill=PALETTE["primary_soft"], stroke=PALETTE["primary"], title_size=11.5, body_size=10) box(fig, rx, top + 6 * rh + 124, 460, 86, "结论", "直径圆不覆盖定位区域(E > 0),题目要求的是\n" "\"存在一点可保证清除\":取 MEC 圆心即可,\n" "因为 ρ ≤ 20 m 落在清除半径内。", fill=PALETTE["success_soft"], stroke=PALETTE["success"], title_size=11.5, body_size=10) fig.source(source) footer_note(fig, "求解入口:uv run mcm26b q1 .json;" "算法与退化情形见 docs/algo-1.md。") return fig def _solve_q1(stations: Sequence[Sequence[float]]) -> dict[str, Any]: """复用生产实现求解(避免图上出现与代码不一致的手算数字)。 返回 ``{"vertices", "diameter_pair", "diameter_circle", "mec"}``, 其中两个圆都是 ``{"center": [x, y], "radius_m": r}``。 """ sys.path.insert(0, str(ROOT / "src")) from mcm26b import Measurement, solve # noqa: PLC0415 ms = [Measurement(x, y, b, h) for x, y, b, h in stations] res = solve(ms) dc, mec = res["diameter_circle"], res["minimum_enclosing_circle"] return dict(vertices=res["vertices"], diameter_pair=res["diameter_pair"], diameter_circle=dict(center=dc["center"], radius_m=dc["radius_m"], max_vertex_distance_m=dc["max_vertex_distance_m"]), mec=dict(center=mec["center"], radius_m=mec["radius_m"])) def fig_q1_counterexample() -> Figure: """题目要的"直径圆不覆盖"反例:等边三角形三点观测。""" doc = json.loads((ROOT / "examples" / "q1" / "triangle.json").read_text(encoding="utf-8")) stations = [(m["x"], m["y"], m["bearing_deg"], m.get("half_angle_deg", 1.0)) for m in doc["measurements"]] verts, pair, dc, mec = _solve_q1(stations) fig = Figure(880, 520, title="问题一:直径圆可以覆盖吗?——符合题设的反例", caption="三点等边布站、示向度精度 ±1°,定位区域仍是"芒果形"四边形") fig.source("examples/q1/triangle.json") half = 1200.0 gx, gy, gw, gh = 60, 60, 760, 400 sx, sy = _world(fig, gx, gy, gw, gh, half, grid=400.0) for s_x, s_y, bearing, hd in stations: _wedge(fig, sx, sy, (s_x, s_y), bearing, hd, 1500.0, color=PALETTE["series"][0], opacity=0.10) fig.circle(sx(s_x), sy(s_y), 4.5, fill=PALETTE["primary"], stroke=None) fig.text(sx(s_x) + 8, sy(s_y) - 6, f"({s_x:.0f}, {s_y:.0f})", size=9.5, color=PALETTE["primary"], font="times") fig.polygon([[sx(px), sy(py)] for px, py in verts], fill=PALETTE["accent_soft"], stroke=PALETTE["accent"], sw=1.6) fig.circle(sx(dc[0][0]), sy(dc[0][1]), dc[1] * gw / (2 * half), fill=None, stroke=PALETTE["series"][4], sw=1.4, dash="6 4") fig.circle(sx(mec[0][0]), sy(mec[0][1]), mec[1] * gw / (2 * half), fill=None, stroke=PALETTE["success"], sw=1.6) for px, py in pair: fig.circle(sx(px), sy(py), 4.0, fill=PALETTE["accent"], stroke=None) fig.arrow(sx(dc[0][0]), sy(dc[0][1]), sx(verts[1][0]), sy(verts[1][1]), stroke=PALETTE["accent"], sw=1.0) fig.text(sx(verts[1][0]) + 6, sy(verts[1][1]) + 14, "最大超出顶点", size=9.5, color=PALETTE["accent"]) fig.legend_chips(74, 486, [("定位区域 P", PALETTE["accent"]), ("直径圆(不覆盖)", PALETTE["series"][4]), ("最小包围圆 MEC", PALETTE["success"])], size=10) footer_note(fig, f"D(P) = {math.dist(*pair):.4f} m,MEC 半径 {mec[1]:.4f} m;" "反例构造与证明见 docs/问题一解答.md §六。") return fig # =========================================================================== # 图 7:问题二保证接收盘 # =========================================================================== def _q2_params() -> dict[str, float]: sys.path.insert(0, str(ROOT / "src")) from mcm26b.config import ProblemConfig # noqa: PLC0415 cfg = ProblemConfig() delta = math.radians(cfg.field.bearing_error_half_deg) r_max = cfg.field.r_eff_max_m a = r_max / (2 * math.cos(delta)) # 截断角域最小包围圆半径 safe_r = R_EFF - a # 保证接收盘半径 d0 = (R_EFF + r_max) / 2 # 最坏后验半径的直观位置 return dict(delta_deg=cfg.field.bearing_error_half_deg, a=a, safe_r=safe_r, center_d=a, d0=d0 - a, reach=r_max) def fig_q2_region() -> Figure: p = _q2_params() fig = Figure(1120, 540, title="问题二:保证接收的候选盘与第二点选择", caption="候选集 K₁ 由三个闭集相交给出;解析安全盘中心在 s₁ + a·u") fig.source("src/mcm26b/config.py") half = 1700.0 gx, gy, gw, gh = 60, 70, 620, 420 sx, sy = _world(fig, gx, gy, gw, gh, half, grid=500.0) # Ω 边界 fig.circle(sx(0), sy(0), OMEGA_R * gw / (2 * half), fill=None, stroke=PALETTE["primary"], sw=1.4) fig.text(sx(0), sy(OMEGA_R) - 14, "Ω:R = 1800 m", size=10, color=PALETTE["primary"], anchor="middle") # 首次检测点与角域 s1 = (0.0, 0.0) bearing = 30.0 _wedge(fig, sx, sy, s1, bearing, 1.0, 1500.0, color=PALETTE["accent"], opacity=0.25, sw=1.4) fig.circle(sx(*s1), sy(*s1), 5.0, fill=PALETTE["accent"], stroke=None) fig.text(sx(*s1) + 8, sy(*s1) - 10, "s₁(首次检测点)", size=10.5, color=PALETTE["accent"]) a = p["a"] cx = a * math.cos(math.radians(bearing)) cy = a * math.sin(math.radians(bearing)) fig.circle(sx(cx), sy(cy), a * gw / (2 * half), fill=None, stroke=PALETTE["series"][4], sw=1.2, dash="6 4") fig.circle(sx(cx), sy(cy), R_EFF * gw / (2 * half), fill=PALETTE["success_soft"], stroke=PALETTE["success"], sw=1.6, opacity=0.45) fig.circle(sx(cx), sy(cy), 4.0, fill=PALETTE["success"], stroke=None) fig.arrow(sx(*s1), sy(*s1), sx(cx), sy(cy), stroke=PALETTE["series"][4], sw=1.3) fig.text((sx(*s1) + sx(cx)) / 2, (sy(*s1) + sy(cy)) / 2 - 10, f"a = R_max/(2cos δ) = {a:.2f} m", size=10, color=PALETTE["series"][4]) fig.legend_chips(74, 508, [("首次角域 W(s₁)", PALETTE["accent"]), ("截断角域的外接圆 B(c,a)", PALETTE["series"][4]), ("安全候选盘 B(c, 1000−a)", PALETTE["success"])], size=10) # 右栏:解析结论 rx = 720 panel_title(fig, rx, 44, "解析结论(δ = ±1°)") rows = [ ("首次角域截断半径 R_max", "1500 m"), ("a = R_max / (2cos δ)", f"{a:.4f} m"), ("安全盘半径 1000 − a", f"{p['safe_r']:.4f} m"), ("安全盘圆心 c = s₁ + a·u", f"({cx:.2f}, {cy:.2f})"), ] top, rh = 76, 32 for i, (k, v) in enumerate(rows): y = top + i * rh if i % 2 == 0: fig.rect(rx, y, 360, rh, fill="#f7f9fb", stroke=None) fig.text(rx + 10, y + rh / 2, k, size=10.5, align="middle") fig.text(rx + 350, y + rh / 2, v, size=11, font="times_bold", color=PALETTE["accent"], anchor="end", align="middle") box(fig, rx, top + 4 * rh + 14, 360, 118, "为什么它是"安全"的", "截断角域(扇形)的最小包围圆恰为 B(c, a),\n" "故扇形 ⊂ B(c, a)。取 B(c, 1000−a) 内任一点,\n" "与扇形内任一点的距离 ≤ a + (1000−a) = 1000 m,\n" "即第二点必然落在源的 1000 m 内 ⇒ 必然收到信号。", fill=PALETTE["primary_soft"], stroke=PALETTE["primary"], title_size=11.5, body_size=9.5) box(fig, rx, top + 4 * rh + 142, 360, 96, "选点目标", "在安全盘内最小化"最坏保守后验包围半径":\n" "主目标 = 后验区域直径,次目标 = 移动 + 检测成本;\n" "交会角只作同分筛选与可解释性说明。", fill=PALETTE["success_soft"], stroke=PALETTE["success"], title_size=11.5, body_size=9.5) footer_note(fig, f"δ = ±1° 时安全盘半径约 {p['safe_r']:.2f} m(严格非空);" "完整推导见 docs/algo-2.md §3。") return fig # =========================================================================== # 图 8:问题三覆盖计划 # =========================================================================== def fig_q3_coverage() -> Figure: fig = Figure(1120, 600, title="问题三:七点解析覆盖与"发现即证明"", caption="七点计划用解析上界换掉网格:任意源都在某站点 1000 m 内") fig.source("docs/algo-3.md") fig.source("src/mcm26b/planner/seven.py") half = 2200.0 gx, gy, gw, gh = 60, 70, 620, 440 sx, sy = _world(fig, gx, gy, gw, gh, half, grid=600.0) fig.circle(sx(0), sy(0), OMEGA_R * gw / (2 * half), fill="#f6f9fc", stroke=PALETTE["primary"], sw=1.4) ring = [(RING_RADIUS_M * math.cos(k * math.pi / 3), RING_RADIUS_M * math.sin(k * math.pi / 3)) for k in range(6)] fig.circle(sx(0), sy(0), RING_RADIUS_M * gw / (2 * half), fill=None, stroke=PALETTE["faint"], sw=1.0, dash="4 4") for px, py in ring: fig.circle(sx(px), sy(py), 5.0, fill=PALETTE["accent"], stroke=None) fig.circle(sx(0), sy(0), 5.0, fill=PALETTE["accent"], stroke=None) fig.text(sx(0), sy(0) - 12, "S₀ 原点", size=10, color=PALETTE["accent"], anchor="middle") fig.text(sx(0), sy(RING_RADIUS_M) - 16, f"环半径 a = {RING_RADIUS_M:.1f} m", size=10, color=PALETTE["accent"], anchor="middle") fig.circle(sx(0), sy(0), R_EFF * gw / (2 * half), fill=PALETTE["success_soft"], stroke=PALETTE["success"], sw=1.2, opacity=0.35) fig.text(sx(0) + 6, sy(R_EFF) - 16, "1000 m 接收半径", size=10, color=PALETTE["success"]) d_bound = max(RING_RADIUS_M / math.sqrt(3), math.sqrt( SEVEN_R ** 2 + RING_RADIUS_M ** 2 - math.sqrt(3) * SEVEN_R * RING_RADIUS_M)) fig.legend_chips(74, 528, [("站点(原点 + 六等分环)", PALETTE["accent"]), ("1000 m 接收盘", PALETTE["success"])], size=10) fig.text(74, 556, f"解析上界 D(a) = max{{a/√3, √(R²+a²−√3Ra)}} = {d_bound:.4f} m < 1000 m " f"⇒ 15.46 m 几何余量", size=10.5, color=PALETTE["success"]) # 右栏:对比 rx = 720 panel_title(fig, rx, 44, "(a) 三套发现计划的代价对比") heads = ("计划", "点数", "保证", "每轮检测") widths = (120, 56, 116, 88) top, rh = 76, 30 x = rx fig.rect(rx, top, sum(widths), rh, fill=PALETTE["primary_soft"], stroke=None) for wd, head in zip(widths, heads): fig.text(x + wd / 2, top + rh / 2, head, size=10.5, font="hei_bold", anchor="middle", align="middle") x += wd rows = [ ("七点解析(默认)", "7", "a=1159.7 m ⇒ 984.54 m", "7 × 频道数"), ("49 点网格(基线)", "49", "700√2 = 989.95 m", "49 × 频道数"), ("最小命中集(题4)", "31", "990.05 m(定向)", "31 × 频道数"), ] for i, row in enumerate(rows): y = top + rh * (i + 1) x = rx if i == 0: fig.rect(rx, y, sum(widths), rh, fill=PALETTE["success_soft"], stroke=None) elif i % 2 == 0: fig.rect(rx, y, sum(widths), rh, fill="#f7f9fb", stroke=None) for wd, cell in zip(widths, row): fig.text(x + wd / 2, y + rh / 2, cell, size=10, anchor="middle", align="middle") x += wd panel_title(fig, rx, 208, "(b) 为什么可以"发现即证明"") box(fig, rx, 236, 380, 120, "覆盖账本 + 负观测", "每个频道维护"是否已被某站点在 1000 m 内测过":\n" "· 收到 direction ⇒ 该源已发现,转入定位/清除;\n" "· 收到 no_signal ⇒ 只能排除以该站点为心、\n" " 半径 1000 m 的圆盘(r_eff ≥ 1000 m);\n" "· 当某频道在全部站点都测过且全为 no_signal,\n" " 由覆盖证明知该频道必无源 ⇒ 可安全销账。", fill=PALETTE["primary_soft"], stroke=PALETTE["primary"], title_size=11.5, body_size=9.5) box(fig, rx, 372, 380, 138, "闭环与停止", "发现 → 同频道第二点交会 → ρ ≤ 20 m 判定 → clear;\n" "未发现则换点重测,必要时转后备盲清(问题四)。\n" "所有频道销账 ⇒ 完成证明成立,立即 /exit——\n" "虚拟时间就是成绩,不做无意义的复扫\n" "(--persistent 可显式开启"扫到限时"保险模式)。", fill=PALETTE["success_soft"], stroke=PALETTE["success"], title_size=11.5, body_size=9.5) footer_note(fig, "七个站点的巡回长度 6a ≈ 6.96 km;a 的可行下界 " "a_min ≈ 1122.96 m(零余量),默认留 15 m 以上余量。") return fig # =========================================================================== # 图 9:问题四最小命中集与联合信念 # =========================================================================== def _load_plan(name: str) -> dict[str, Any]: path = ROOT / "src" / "mcm26b" / "guarantee" / "plans" / f"{name}.json" return json.loads(path.read_text(encoding="utf-8")) def fig_q4_cover() -> Figure: plan = _load_plan("q4_mincover_v2") pts = [(float(x), float(y)) for x, y in plan["points"]] cert = plan["certificate"] fig = Figure(1120, 600, title="问题四:定向源的 31 点最小命中集与联合信念", caption="定向源只有 ±90° 前向半球能被发现:覆盖语义从"圆盘"改成"半盘 + 朝向"") fig.source("src/mcm26b/guarantee/plans/q4_mincover_v2.json", plan=plan["name"], certificate=cert["uncovered"]) fig.source("src/mcm26b/planner/directional.py") half = 2300.0 gx, gy, gw, gh = 60, 70, 620, 440 sx, sy = _world(fig, gx, gy, gw, gh, half, grid=600.0) fig.circle(sx(0), sy(0), OMEGA_R * gw / (2 * half), fill="#f6f9fc", stroke=PALETTE["primary"], sw=1.4) fig.text(sx(0), sy(OMEGA_R) - 14, "Ω:R = 1800 m", size=10, color=PALETTE["primary"], anchor="middle") for i, (px, py) in enumerate(pts): fig.circle(sx(px), sy(py), 4.0, fill=PALETTE["accent"], stroke=None) if i < 6: fig.text(sx(px) + 6, sy(py) - 6, f"P{i + 1}", size=8.5, color=PALETTE["muted"], font="times") fig.text(74, 500, f"31 个覆盖点({plan['name']}):离线贪心最小命中集," f"设计半径 {Q4_DESIGN_R:.0f} m + 前向角裕量 {Q4_HEADING_MARGIN_DEG:.0f}°", size=10.5, color=PALETTE["ink"]) fig.text(74, 522, f"运行期证书:{cert['targets']:,} 个 (位置, 朝向) 采样格全部被命中," f"缺口 {cert['uncovered']},最坏命中距离 {cert['worst_hit_distance_m']:.1f} m", size=10.5, color=PALETTE["success"]) fig.text(74, 544, f"NN 巡回 {cert['nn_tour_m'] / 1000:.1f} km " f"≈ {cert['nn_tour_virtual_s']:.0f} s 虚拟时间;" "对照旧 q4_mincover_v1:粗采样过拟合,15 m/72 档下有 566 个缺口", size=10.5, color=PALETTE["muted"]) rx = 720 panel_title(fig, rx, 44, "(a) 覆盖语义:forward_half_disk") _half_disk(fig, rx + 10, 76, 190, 150, PALETTE["accent"]) box(fig, rx + 210, 76, 170, 150, "与问题三的差别", "问题三 full_disk:\n任何位置负观测都\n能排除 1000 m 圆盘。\n\n" "问题四:负观测只能\n排除"背对它"的朝向,\n位置信息几乎不更新。", fill=PALETTE["warn_soft"], stroke=PALETTE["warn"], title_size=11, body_size=9.5) panel_title(fig, rx, 250, "(b) 位置 × 朝向联合信念") box(fig, rx, 278, 380, 128, "JointBelief:网格 × 朝向档", "每个覆盖点观测后只更新"哪些朝向还活着":\n" "· direction 命中 ⇒ 保留该朝向档,位置信息由后续交会补;\n" "· no_signal ⇒ 删去"朝向正对本站点"的档(含格内角裕量);\n" "· 可见性必须与引擎一致:距离 ≤ r_eff 且夹角 ≤ 90°;\n" "· 后备盲清:联合信念坍缩到有限格时,按可疑格就近清。", fill=PALETTE["primary_soft"], stroke=PALETTE["primary"], title_size=11.5, body_size=9.5) box(fig, rx, 420, 380, 118, "本轮修复的正确性缺陷", "旧版 no_signal 朝向裁剪规则反向、且漏计格内角裕量,\n" "会删掉真源所在朝向档。现已修复,并由\n" "tests/test_joint_belief.py 的随机观测序列保留性测试固化。", fill=PALETTE["accent_soft"], stroke=PALETTE["accent"], title_size=11.5, body_size=9.5) footer_note(fig, "后备盲清 125.7 → 4.3 次/局,Q4 最坏虚拟时间 10891 → 9630 s;" "负面结论与开关见 docs/第三四问图论与动态规划优化方案.md §8。") return fig def _half_disk(fig: Figure, x: float, y: float, w: float, h: float, color: str) -> None: """画一个 forward_half_disk:圆盘 + 前向半平面高亮 + 朝向箭头。""" cx, cy, r = x + w / 2, y + h / 2, min(w, h) / 2 - 8 fig.circle(cx, cy, r, fill="#f7f9fb", stroke=PALETTE["muted"], sw=1.0) pts = [[cx, cy]] for i in range(25): a = math.radians(-90 + 180 * i / 24) pts.append([cx + r * math.cos(a), cy + r * math.sin(a)]) fig.polygon(pts, fill=color, stroke=None, opacity=0.35) fig.arrow(cx, cy, cx + r * 0.86, cy, stroke=color, sw=1.4, head=7) fig.line(cx, cy - r, cx, cy + r, stroke=color, sw=1.0, dash="4 4") fig.text(cx, cy + r + 14, "朝向未知 ⇒ ±90°", size=9.5, color=color, anchor="middle") fig.text(cx, cy - r - 8, "覆盖半径 1000 m", size=9.5, color=PALETTE["muted"], anchor="middle") # =========================================================================== # 图 10/11:WebUI 回放预览复现 # =========================================================================== def replay_figure(run: LogRun, *, width: float = 1200, height: float = 760, title: str | None = None) -> Figure: """复现 referee WebUI"回放"Tab 的画布语义:世界 Ω、真值源、运动轨迹、 检测/清除事件与虚拟时刻进度条。""" tl = decompose(run) fig = Figure(width, height, title=title or f"WebUI 回放复现:{run.case_code}", caption="与 referee 静态回放器同一语义:移动 5 m/s 直线展开、" "检测附实测示向度射线、清除先光学定位再清除") fig.source(run.source, case_code=run.case_code, module=run.module, truth=len(run.truth), actions=len(run.actions)) map_x, map_y, map_w = 40.0, 64.0, 700.0 map_h = height - map_y - 132 half = 2100.0 sx, sy = _world(fig, map_x, map_y, map_w, map_h, half, grid=1000.0) fig.circle(sx(0), sy(0), OMEGA_R * map_w / (2 * half), fill="#f7fafc", stroke=PALETTE["primary"], sw=1.6) fig.text(sx(0), sy(OMEGA_R) - 16, "Ω(半径 1800 m)", size=10.5, color=PALETTE["primary"], anchor="middle") # 真值源 solved = set(run.solution_channels()) for src in run.truth: ch = int(src["channel"]) directional = bool(src.get("directional")) color = PALETTE["accent"] if ch in solved else PALETTE["warn"] px, py = sx(float(src["x"])), sy(float(src["y"])) fig.circle(px, py, 6.0, fill=color, stroke=PALETTE["paper"], sw=1.2) fig.text(px + 9, py - 4, f"ch{ch}", size=9.5, font="times_bold", color=color) if directional: heading = math.radians(float(src.get("heading_deg") or 0.0)) fig.arrow(px, py, px + 26 * math.cos(heading), py - 26 * math.sin(heading), stroke=PALETTE["accent"], sw=1.2, head=6) # 运动轨迹(按动作序列连接 position) path: list[list[float]] = [[sx(0), sy(0)]] pos = (0.0, 0.0) for act in run.actions: req = act["request"] if act["path"] in ("/measure", "/clear") and "position" in req: tgt = (float(req["position"]["x"]), float(req["position"]["y"])) if tgt != pos: path.append([sx(*tgt)]) pos = tgt if len(path) > 1: fig.polyline(path, fill=None, stroke=PALETTE["primary"], sw=1.4, opacity=0.85) fig.circle(sx(0), sy(0), 5.0, fill=PALETTE["primary"], stroke=PALETTE["paper"], sw=1.2) fig.text(sx(0) + 8, sy(0) + 12, "起点 (0,0)", size=9.5, color=PALETTE["primary"]) # 检测射线(direction)与清除点 for act, row in zip(run.actions, tl.rows): if act["path"] == "/measure" and str(act["response"].get("measure_result")) == "direction": bearing = float(act["response"].get("svd_deg") or 0.0) if row["x"] is None: continue px, py = sx(float(row["x"])), sy(float(row["y"])) ex = px + 150 * math.cos(math.radians(bearing)) ey = py - 150 * math.sin(math.radians(bearing)) fig.line(px, py, ex, ey, stroke=PALETTE["success"], sw=0.9, opacity=0.5) fig.circle(px, py, 2.6, fill=PALETTE["success"], stroke=None) elif act["path"] == "/clear": if row["x"] is None: continue px, py = sx(float(row["x"])), sy(float(row["y"])) ok = act["response"].get("clear_result") == "cleared" fig.circle(px, py, 8.0, fill=None, stroke=PALETTE["success"] if ok else PALETTE["muted"], sw=1.6) if ok: fig.line(px - 5, py - 5, px + 5, py + 5, stroke=PALETTE["success"], sw=1.8) fig.line(px - 5, py + 5, px + 5, py - 5, stroke=PALETTE["success"], sw=1.8) # 右栏:状态与事件 rx = map_x + map_w + 28 rw = width - rx - 32 panel_title(fig, rx, 44, "回放状态") chips = [ (f"案例 {run.case_code}", PALETTE["primary"]), (f"{run.module}", PALETTE["muted"]), (f"参赛队号 {run.robot_id}", PALETTE["muted"]), (f"真值源 {len(run.truth)}(定向 {sum(1 for s in run.truth if s.get('directional'))})", PALETTE["accent"]), (f"全清 {len(solved)}/{len(run.truth)}", PALETTE["success"]), (f"动作 {len(run.actions)} · 检测 {run.measures} · 清除 {run.clears}", PALETTE["primary"]), ] y = 74 for label, color in chips: fig.rect(rx, y, rw, 24, fill=PALETTE["paper"], stroke=color, sw=1.1, radius=12) fig.text(rx + 12, y + 12, label, size=10, color=color, align="middle") y += 30 panel_title(fig, rx, y + 12, "事件流节选(前 12 条动作)") y += 40 fig.rect(rx, y, rw, 22, fill=PALETTE["primary_soft"], stroke=None) for x, head in ((rx + 8, "#"), (rx + 40, "指令"), (rx + 150, "参数"), (rx + 268, "虚拟 t/s")): fig.text(x, y + 11, head, size=9.5, font="hei_bold", align="middle") y += 22 for i, (act, row) in enumerate(zip(run.actions, tl.rows)): if i >= 12: break fill = "#f7f9fb" if i % 2 == 0 else None if fill: fig.rect(rx, y, rw, 20, fill=fill, stroke=None) detail = "" if act["path"] == "/measure": detail = f"ch{row['channel']} ({row['x']:.0f},{row['y']:.0f})" elif act["path"] == "/clear": detail = f"ch{row['channel']} {row['detail']}" elif act["path"] == "/enter": detail = "启动" elif act["path"] == "/exit": detail = "退出" fig.text(rx + 8, y + 10, str(i + 1), size=9, font="times", align="middle") fig.text(rx + 40, y + 10, act["path"], size=9, font="times_bold", align="middle") fig.text(rx + 150, y + 10, detail, size=9, font="times", align="middle") fig.text(rx + 268, y + 10, f"{row['virtual_s']:.0f}", size=9, font="times", align="middle") y += 20 fig.text(rx, y + 16, f"… 共 {len(run.actions)} 条动作;" f"终点虚拟时刻 {tl.total_virtual_s:,.1f} s", size=9.5, color=PALETTE["muted"]) # 底部:进度条(对齐回放器的可拖动时间轴) bar_y = height - 92 fig.rect(map_x, bar_y, map_w, 12, fill=PALETTE["faint"], stroke=None, radius=6) fig.rect(map_x, bar_y, map_w, 12, fill=PALETTE["primary"], stroke=None, radius=6) for act, row in zip(run.actions, tl.rows): frac = row["virtual_s"] / max(tl.total_virtual_s, 1e-9) px = map_x + map_w * frac if act["path"] == "/clear": fig.line(px, bar_y - 5, px, bar_y + 17, stroke=PALETTE["success"], sw=1.6) elif act["path"] == "/measure" and str(act["response"].get("measure_result")) == "direction": fig.line(px, bar_y - 3, px, bar_y + 15, stroke=PALETTE["accent"], sw=1.2) fig.text(map_x, bar_y + 30, "0 s", size=9.5, color=PALETTE["muted"], font="times") fig.text(map_x + map_w / 2, bar_y + 30, "时间轴刻度:竖线 = 检测到信号(红)/ 清除(绿)", size=9.5, color=PALETTE["muted"], anchor="middle") fig.text(map_x + map_w, bar_y + 30, f"{tl.total_virtual_s:,.0f} s", size=9.5, color=PALETTE["muted"], anchor="end", font="times") fig.legend_chips(map_x, bar_y + 54, [("已清除频道", PALETTE["success"]), ("未清除频道(真值)", PALETTE["warn"]), ("定向源朝向", PALETTE["accent"]), ("示向度射线", PALETTE["success"])], size=9.5) return fig # =========================================================================== # 入口 # =========================================================================== def build(only: Sequence[str] | None, scale: float, rasterizer: str) -> list[dict[str, Any]]: want = set(only) if only else None runs = discover_runs() q3 = pick_run(runs, module="q3", truth=10) q4 = pick_run(runs, module="q4", truth=12) jobs: list[tuple[str, Any]] = [ ("flow", lambda: fig_flow()), ("steps", lambda: fig_steps()), ("timing-rule", lambda: fig_timing_rule()), ("geo-q1", lambda: fig_q1_meetup()), ("geo-q1-counterexample", lambda: fig_q1_counterexample()), ("geo-q2", lambda: fig_q2_region()), ("geo-q3", lambda: fig_q3_coverage()), ("geo-q4", lambda: fig_q4_cover()), ] if q3: jobs.append(("timing-run", lambda q3=q3: fig_timing_run(q3))) jobs.append(("replay-q3", lambda q3=q3: replay_figure(q3))) if q4: jobs.append(("replay-q4", lambda q4=q4: replay_figure( q4, title=f"WebUI 回放复现(定向):{q4.case_code}"))) out: list[dict[str, Any]] = [] for stem, make in jobs: if want and stem not in want: continue fig = make() meta = fig.write(OUT_DIR, stem, scale=scale, rasterizer=rasterizer) out.append(meta) print(f"[fig] {stem:<24} {meta['size'][0]:.0f}×{meta['size'][1]:.0f} " f"→ {meta.get('png', meta['svg'])}") return out def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--only", default="", help="逗号分隔的图名子集(见 --list)") parser.add_argument("--scale", type=float, default=2.0, help="PNG 相对 SVG 的像素倍率(论文插图建议 2.0~3.0)") parser.add_argument("--rasterizer", default=DEFAULT_RASTERIZER) parser.add_argument("--list", action="store_true", help="列出全部图名后退出") args = parser.parse_args() names = ["flow", "steps", "timing-rule", "timing-run", "geo-q1", "geo-q1-counterexample", "geo-q2", "geo-q3", "geo-q4", "replay-q3", "replay-q4"] if args.list: print("\n".join(names)) return 0 only = [s.strip() for s in args.only.split(",") if s.strip()] entries = build(only or None, args.scale, args.rasterizer) command = ("uv run python scripts/build_docs_figures.py" + (f" --only {args.only}" if args.only else "")) path = write_manifest(OUT_DIR, entries, command) print(f"[fig] manifest → {path.relative_to(ROOT)}({len(entries)} 张图)") return 0 if __name__ == "__main__": raise SystemExit(main())