1310 lines
65 KiB
Python
1310 lines
65 KiB
Python
"""从行为日志 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())
|