diff --git a/scripts/build_docs_figures.py b/scripts/build_docs_figures.py deleted file mode 100644 index 004d83c..0000000 --- a/scripts/build_docs_figures.py +++ /dev/null @@ -1,1309 +0,0 @@ -"""从行为日志 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()) diff --git a/scripts/figkit.py b/scripts/figkit.py deleted file mode 100644 index 40eed26..0000000 --- a/scripts/figkit.py +++ /dev/null @@ -1,372 +0,0 @@ -"""论文级图表工具:统一配色、场景图 DSL、SVG/PNG 双出与来源清单。 - -设计约束(对应 docs/figures/README.md 的"可复现"承诺): - -- **零新增运行时依赖**:几何/连线由本模块直接写 SVG,PNG 由 ``rsvg-convert`` - 光栅化(与项目既有可视化链路一致);中文与西文文字层用 Pillow 以论文自带 - 的 ``docs/paper/fonts/`` 字体单独合成,保证与 ``main.tex`` 同字体、同字号; -- **一份场景图两处消费**:SVG 用于 WebUI/文档内联预览(走系统字体栈), - PNG 用于论文插图(走 bundled 字体);两者坐标完全一致; -- **来源可追溯**:每张图强制携带 ``sources``(文件路径 + sha256 前 12 位 + - 行数)与生成命令,写入 ``manifest.json``,避免"图与数据对不上"。 - -文字排版约定:**主风格只提供 SVG 精度的文字**,Pillow 侧复用同一套 -``(x, y, anchor, font, size, dy)`` 参数重画一遍;``dy`` 表示"文字视觉中心相对 -基线的偏移比例",由 font-size 缩放,用来让框内文字纵向居中。 -""" - -from __future__ import annotations - -import hashlib -import json -import math -import shutil -import subprocess -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Iterable, Sequence - -ROOT = Path(__file__).resolve().parents[1] -FONT_DIR = ROOT / "docs" / "paper" / "fonts" -DEFAULT_RASTERIZER = "rsvg-convert" - -# --------------------------------------------------------------------------- -# 论文配色:低饱和、单色可打印,正文/强调/真值/成功/失败五类语义色 + 顺序色阶 -# --------------------------------------------------------------------------- -PALETTE: dict[str, str] = { - "ink": "#1c2430", # 主文字/坐标轴 - "muted": "#6b7684", # 次要文字与网格 - "faint": "#dfe4ea", # 分隔线 - "paper": "#ffffff", - "primary": "#1f4e79", # 主线:协议/流程 - "primary_soft": "#dce6f1", - "accent": "#b03a2e", # 强调:真值源/关键结论 - "accent_soft": "#f6dcd8", - "success": "#1e7a5f", # 成功:清除/全清 - "success_soft": "#d8ece5", - "warn": "#b8860b", # 警示:后备/未达标 - "warn_soft": "#f6ecd2", - "series": ("#1f4e79", "#b03a2e", "#1e7a5f", "#b8860b", "#5b4b8a", "#3d7ea6"), -} - -FONTS: dict[str, str] = { - "song": "SongtiSC-Regular.ttf", # 中文正文:宋体 - "song_bold": "SongtiSC-Bold.ttf", - "hei": "HeitiSC-Light.ttf", # 中文标题:黑体 - "hei_bold": "HeitiSC-Medium.ttf", - "times": "Times New Roman.ttf", # 西文与数字 - "times_bold": "Times New Roman Bold.ttf", -} - -_SVG_FAMILY = { - "song": "'Songti SC','SimSun',serif", - "song_bold": "'Songti SC','SimSun',serif", - "hei": "'Heiti SC','Microsoft YaHei',sans-serif", - "hei_bold": "'Heiti SC','Microsoft YaHei',sans-serif", - "times": "'Times New Roman',Times,serif", - "times_bold": "'Times New Roman',Times,serif", -} - - -# --------------------------------------------------------------------------- -# 场景图 DSL -# --------------------------------------------------------------------------- -@dataclass -class Figure: - """一张图的场景描述:宽高以 CSS 像素(≈1/96 inch)计。""" - - width: float - height: float - title: str = "" - caption: str = "" - background: str = PALETTE["paper"] - elements: list[dict[str, Any]] = field(default_factory=list) - sources: list[dict[str, Any]] = field(default_factory=list) - notes: list[str] = field(default_factory=list) - - # ---------------------------------------------------------------- 图元 - def rect(self, x: float, y: float, w: float, h: float, **kw: Any) -> "Figure": - self.elements.append(dict(kind="rect", x=x, y=y, w=w, h=h, **kw)) - return self - - def circle(self, cx: float, cy: float, r: float, **kw: Any) -> "Figure": - self.elements.append(dict(kind="circle", cx=cx, cy=cy, r=r, **kw)) - return self - - def line(self, x1: float, y1: float, x2: float, y2: float, **kw: Any) -> "Figure": - self.elements.append(dict(kind="line", x1=x1, y1=y1, x2=x2, y2=y2, **kw)) - return self - - def polyline(self, points: Sequence[Sequence[float]], **kw: Any) -> "Figure": - self.elements.append(dict(kind="polyline", points=[list(p) for p in points], **kw)) - return self - - def polygon(self, points: Sequence[Sequence[float]], **kw: Any) -> "Figure": - self.elements.append(dict(kind="polygon", points=[list(p) for p in points], **kw)) - return self - - def path(self, d: str, **kw: Any) -> "Figure": - self.elements.append(dict(kind="path", d=d, **kw)) - return self - - def arrow(self, x1: float, y1: float, x2: float, y2: float, **kw: Any) -> "Figure": - """直线箭头:``head`` 为箭头边长(像素),``dash`` 支持虚线。""" - self.elements.append(dict(kind="arrow", x1=x1, y1=y1, x2=x2, y2=y2, **kw)) - return self - - def text(self, x: float, y: float, s: str, *, size: float = 12.0, - font: str = "song", color: str | None = None, anchor: str = "start", - align: str = "top", bold: bool = False, rotate: float | None = None, - dy: float | None = None, opacity: float = 1.0) -> "Figure": - """文字。``(x, y)`` 为参考点;``align`` ∈ {top, middle, bottom} 决定 - 参考点落在文字块的哪条水平线上(``anchor`` 决定左右)。 - """ - self.elements.append(dict( - kind="text", x=x, y=y, s=s, size=size, font=font, - color=color or PALETTE["ink"], anchor=anchor, align=align, - bold=bold, rotate=rotate, dy=dy, opacity=opacity)) - return self - - def legend_chips(self, x: float, y: float, items: Iterable[tuple[str, str]], - *, size: float = 11.0, gap: float = 16.0, box: float = 10.0, - font: str = "song") -> float: - """一行"色块 + 文字"图例,返回结束时的 x 坐标。""" - cur = x - for label, color in items: - self.rect(cur, y - box * 0.75, box, box, fill=color, stroke=None, radius=2.0) - self.text(cur + box + 5.0, y + box * 0.35, label, size=size, font=font, - align="middle") - cur += box + 5.0 + _estimate_width(label, size, font) + gap - return cur - - def source(self, path: str | Path, **extra: Any) -> "Figure": - """登记本图的数据来源(相对仓库根),含 sha256 前 12 位与行数。""" - p = Path(path) - p = p if p.is_absolute() else ROOT / p - digest, lines = "-", 0 - if p.is_file(): - data = p.read_bytes() - digest = hashlib.sha256(data).hexdigest()[:12] - lines = data.count(b"\n") + (0 if data.endswith(b"\n") else 1) - self.sources.append(dict(path=str(p.relative_to(ROOT)) if p.is_relative_to(ROOT) else str(p), - sha256_12=digest, lines=lines, **extra)) - return self - - # ---------------------------------------------------------------- 输出 - def to_svg(self) -> str: - body: list[str] = [_svg_element(el) for el in self.elements] - head = (f'') - meta = f"{_esc(self.title)}\n" - if self.caption: - meta += f"{_esc(self.caption)}\n" - return "\n".join([head, meta, f'', *body, "", ""]) - - def write(self, out_dir: str | Path, stem: str, *, scale: float = 2.0, - rasterizer: str = DEFAULT_RASTERIZER, png: bool = True) -> dict[str, Any]: - """写出 ``.svg``(+ ``.png``),返回产出清单。""" - out = Path(out_dir) - out.mkdir(parents=True, exist_ok=True) - svg_path = out / f"{stem}.svg" - svg_path.write_text(self.to_svg(), encoding="utf-8") - produced: dict[str, Any] = {"stem": stem, "title": self.title, - "caption": self.caption, - "svg": str(svg_path.relative_to(ROOT)), - "size": [self.width, self.height], - "sources": self.sources, "notes": self.notes} - if png: - png_path = out / f"{stem}.png" - _rasterize(svg_path, png_path, scale=scale, rasterizer=rasterizer) - _compose_text(self, png_path, scale=scale) - produced["png"] = str(png_path.relative_to(ROOT)) - produced["png_scale"] = scale - return produced - - -# --------------------------------------------------------------------------- -# SVG 生成 -# --------------------------------------------------------------------------- -def _n(value: float) -> str: - """紧凑数字:去掉多余小数位,避免 SVG 体积膨胀。""" - if abs(value - round(value)) < 1e-9: - return str(int(round(value))) - return f"{value:.3f}".rstrip("0").rstrip(".") - - -def _esc(text: str) -> str: - return (str(text).replace("&", "&").replace("<", "<").replace(">", ">")) - - -def _paint(kw: dict[str, Any]) -> str: - parts: list[str] = [] - fill = kw.get("fill", "none") - if fill is None: - fill = "none" - parts.append(f'fill="{fill}"') - stroke = kw.get("stroke") - if stroke: - parts.append(f'stroke="{stroke}" stroke-width="{_n(kw.get("sw", 1.0))}"') - if kw.get("dash"): - dash = kw["dash"] if isinstance(kw["dash"], str) else \ - " ".join(_n(v) for v in kw["dash"]) - parts.append(f'stroke-dasharray="{dash}"') - parts.append(f'stroke-linejoin="{kw.get("linejoin", "miter")}"') - if kw.get("opacity") is not None: - parts.append(f'opacity="{_n(kw["opacity"])}"') - if kw.get("radius"): - parts.append(f'rx="{_n(kw["radius"])}" ry="{_n(kw["radius"])}"') - return " ".join(parts) - - -def _text_svg(el: dict[str, Any]) -> str: - size = el["size"] - dy = _baseline_offset(el) - anchor = {"start": "start", "middle": "middle", "end": "end"}[el["anchor"]] - family = _SVG_FAMILY[el["font"]] - weight = ' font-weight="bold"' if el["bold"] or el["font"].endswith("_bold") else "" - style = ' font-style="italic"' if el.get("italic") else "" - extra = f' opacity="{_n(el["opacity"])}"' if el.get("opacity", 1.0) < 1 else "" - rotate = f' transform="rotate({_n(el["rotate"])} {_n(el["x"])} {_n(el["y"])})"' \ - if el.get("rotate") else "" - return (f'{_esc(el["s"])}') - - -def _baseline_offset(el: dict[str, Any]) -> float: - """参考点 → SVG 基线(alphabetic)的 y 偏移。""" - if el.get("dy") is not None: - return float(el["dy"]) - if el["align"] == "middle": - return el["size"] * 0.35 - if el["align"] == "bottom": - return 0.0 - return el["size"] * 0.82 - - -def _svg_element(el: dict[str, Any]) -> str: - kind = el["kind"] - if kind == "text": - return _text_svg(el) - if kind == "rect": - return (f'') - if kind == "circle": - return f'' - if kind == "line": - return (f'') - if kind in ("polyline", "polygon"): - pts = " ".join(f"{_n(px)},{_n(py)}" for px, py in el["points"]) - return f'<{kind} points="{pts}" {_paint(el)}/>' - if kind == "path": - return f'' - if kind == "arrow": - return _arrow_svg(el) - raise ValueError(f"未知图元:{kind}") - - -def _arrow_svg(el: dict[str, Any]) -> str: - x1, y1, x2, y2 = el["x1"], el["y1"], el["x2"], el["y2"] - color = el.get("stroke") or PALETTE["ink"] - sw = el.get("sw", 1.4) - head = el.get("head", 8.0) - ang = math.atan2(y2 - y1, x2 - x1) - bx, by = x2 - head * math.cos(ang), y2 - head * math.sin(ang) - left = (bx - head * 0.5 * math.sin(ang), by + head * 0.5 * math.cos(ang)) - right = (bx + head * 0.5 * math.sin(ang), by - head * 0.5 * math.cos(ang)) - dash = f' stroke-dasharray="{el["dash"]}"' if el.get("dash") else "" - return "\n".join([ - f'', - f'', - ]) - - -# --------------------------------------------------------------------------- -# PNG:rsvg-convert 出几何层 + Pillow 出文字层(论文同款字体) -# --------------------------------------------------------------------------- -def _rasterize(svg_path: Path, png_path: Path, *, scale: float, - rasterizer: str) -> None: - exe = shutil.which(rasterizer) - if not exe: - raise RuntimeError(f"未找到 {rasterizer}:PNG 输出需要它(brew install librsvg)") - cmd = [exe, "-f", "png", "-o", str(png_path), "--zoom", str(scale), str(svg_path)] - proc = subprocess.run(cmd, capture_output=True, text=True, check=False) - if proc.returncode != 0: - raise RuntimeError(f"{rasterizer} 失败:{proc.stderr.strip()}") - - -def _compose_text(fig: Figure, png_path: Path, *, scale: float) -> None: - """把文字层用 Pillow 重画在光栅图上:与基础几何同色同坐标,字体来自 - ``docs/paper/fonts/``(缺失时回退系统字体)。""" - from PIL import Image, ImageDraw, ImageFont # 可选依赖:仅生成图时导入 - - texts = [el for el in fig.elements if el["kind"] == "text"] - if not texts: - return - img = Image.open(png_path).convert("RGBA") - layer = Image.new("RGBA", img.size, (0, 0, 0, 0)) - draw = ImageDraw.Draw(layer) - cache: dict[tuple[str, float], Any] = {} - for el in texts: - key = (el["font"], round(el["size"] * scale, 2)) - if key not in cache: - cache[key] = _load_font(el["font"], el["size"] * scale, ImageFont) - font = cache[key] - x, y = el["x"] * scale, (el["y"] + _baseline_offset(el)) * scale - anchor = {"start": "ls", "middle": "ms", "end": "rs"}[el["anchor"]] - draw.text((x, y), el["s"], font=font, fill=_rgba(el["color"], el.get("opacity", 1.0)), - anchor=anchor) - Image.alpha_composite(img, layer).convert("RGB").save(png_path, optimize=True) - - -def _load_font(name: str, size: float, image_font: Any) -> Any: - path = FONT_DIR / FONTS[name] - if path.is_file(): - return image_font.truetype(str(path), max(6, int(round(size)))) - fallback = { - "song": "/System/Library/Fonts/Supplemental/Songti.ttc", - "song_bold": "/System/Library/Fonts/Supplemental/Songti.ttc", - "hei": "/System/Library/Fonts/Supplemental/Songti.ttc", - "hei_bold": "/System/Library/Fonts/Supplemental/Songti.ttc", - "times": "/System/Library/Fonts/Supplemental/Times New Roman.ttf", - "times_bold": "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf", - }[name] - return image_font.truetype(fallback, max(6, int(round(size)))) - - -def _rgba(color: str, opacity: float) -> tuple[int, int, int, int]: - value = color.lstrip("#") - return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16), - max(0, min(255, int(round(opacity * 255))))) - - -def _estimate_width(text: str, size: float, font: str) -> float: - """粗略宽度估计(仅用于图例排版留白):CJK 按 1.0 em,西文按 0.5 em。""" - wide = sum(1 for ch in text if ord(ch) > 0x2E80) - narrow = len(text) - wide - factor = 1.0 if font.startswith(("song", "hei")) else 0.98 - return (wide * size + narrow * size * 0.52) * factor - - -def write_manifest(out_dir: str | Path, entries: Sequence[dict[str, Any]], - command: str) -> Path: - """落盘 manifest.json:图清单 + 数据来源 + 复现命令。""" - out = Path(out_dir) - out.mkdir(parents=True, exist_ok=True) - doc = { - "generator": "scripts/build_docs_figures.py", - "command": command, - "palette": {k: v for k, v in PALETTE.items() if k != "series"}, - "fonts": {"dir": "docs/paper/fonts", "map": FONTS}, - "figures": list(entries), - } - path = out / "manifest.json" - path.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - return path