Files
mcm-26-b/scripts/build_docs_figures.py
T
2026-09-13 08:29:30 +08:00

1310 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从行为日志 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 <case>.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())