Files
mcm-26-b/scripts/make_figures.py
T
2026-09-13 08:52:23 +08:00

1183 lines
56 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.
"""docs/figures 的论文插图生成器(matplotlib 版)。
uv run python scripts/make_figures.py # 全部 11 张
uv run python scripts/make_figures.py --list # 图名
uv run python scripts/make_figures.py --only flow-overall-architecture
uv run python scripts/make_figures.py --scale 1.5 # 尺寸倍率
约定(与 docs/figures/README.md 对应):
- **只输出 PNG**(不生成 SVG),分辨率按论文插图设置;
- **图内不放标题、图注与解释性文字**,只保留坐标轴、图例与数据标注;
图的含义由文件名与 README 清单承担,解释写在论文正文;
- 字体取自 ``docs/paper/fonts/``(宋体 / 黑体 / Times New Roman),与 main.tex 一致;
- 数据来源与 sha256 写入 ``docs/figures/manifest.json``。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import sys
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
ROOT = Path(__file__).resolve().parents[1]
FONT_DIR = ROOT / "docs" / "paper" / "fonts"
OUT_DIR = ROOT / "docs" / "figures"
SESSIONS_DIR = ROOT / "referee-data" / "sessions"
ARCHIVE_DIR = ROOT / "examples" / "replay"
CACHE_DIR = ROOT / ".mpl-cache"
# matplotlib 的缓存目录:仓库内可写,避免污染用户目录
CACHE_DIR.mkdir(exist_ok=True)
os.environ.setdefault("MPLCONFIGDIR", str(CACHE_DIR))
os.environ.setdefault("XDG_CACHE_HOME", str(CACHE_DIR))
import matplotlib # noqa: E402
matplotlib.use("Agg")
import numpy as np # noqa: E402
from matplotlib import font_manager as fm # noqa: E402
from matplotlib import pyplot as plt # noqa: E402
from matplotlib.lines import Line2D # noqa: E402
from matplotlib.patches import Circle, FancyArrowPatch, FancyBboxPatch, Polygon # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 题面常量(与 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
Q4_DESIGN_R = 965.0
Q4_HEADING_MARGIN_DEG = 5.0
# 论文配色:低饱和,单色打印可辨
C = {
"ink": "#1c2430",
"muted": "#5c6673",
"grid": "#e4e9ee",
"primary": "#1f4e79",
"primary_fill": "#dce6f1",
"accent": "#b03a2e",
"accent_fill": "#f6dcd8",
"success": "#1e7a5f",
"success_fill": "#d8ece5",
"warn": "#b8860b",
"warn_fill": "#f6ecd2",
"violet": "#5b4b8a",
}
FONTS = {
"song": "SongtiSC-Regular.ttf",
"song_bold": "SongtiSC-Bold.ttf",
"hei": "HeitiSC-Light.ttf",
"hei_bold": "HeitiSC-Medium.ttf",
"times": "Times New Roman.ttf",
"times_bold": "Times New Roman Bold.ttf",
}
# ---------------------------------------------------------------------------
# 样式
# ---------------------------------------------------------------------------
def setup_style() -> dict[str, Any]:
"""注册自带字体并设全局样式,返回 FontProperties 字典。"""
for name in FONTS.values():
path = FONT_DIR / name
if path.is_file():
fm.fontManager.addfont(str(path))
props = {key: fm.FontProperties(fname=str(FONT_DIR / name))
for key, name in FONTS.items() if (FONT_DIR / name).is_file()}
plt.rcParams.update({
"figure.dpi": 120,
"savefig.dpi": 200,
"font.family": [props["song"].get_name(), "DejaVu Sans"],
"font.size": 8.5,
"axes.titlesize": 9,
"axes.labelsize": 8.5,
"xtick.labelsize": 8,
"ytick.labelsize": 8,
"legend.fontsize": 8,
"axes.edgecolor": C["muted"],
"axes.labelcolor": C["ink"],
"text.color": C["ink"],
"xtick.color": C["muted"],
"ytick.color": C["muted"],
"axes.grid": False,
"axes.unicode_minus": False,
"lines.linewidth": 1.2,
})
return props
def new_axes(width_mm: float, height_mm: float, *, ncols: int = 1,
nrows: int = 1, scale: float = 1.0, **kw: Any) -> tuple[Any, Any]:
"""按毫米尺寸建图(论文按 160 mm 宽排版时用 1.0 倍)。"""
fig, axes = plt.subplots(nrows, ncols,
figsize=(width_mm / 25.4 * scale, height_mm / 25.4 * scale),
layout="constrained", **kw)
return fig, axes
def axes_for_panels(panel_mm: float, aspects: Sequence[float], *, scale: float = 1.0,
width_ratios: Sequence[float] | None = None,
extra_mm: float = 30.0, **kw: Any) -> tuple[Any, Any]:
"""按"每个面板高 panel_mm、宽 = 高 × 宽高比"手工排版。
等比例坐标轴(``set_aspect('equal')``)在自动布局(constrained/tight)下会被
挤成一小块,因此这里直接按数据比例分配位置:每个子图的方框宽高比等于
数据宽高比,等比例坐标轴正好铺满方框。
"""
del kw
ratios = list(width_ratios) if width_ratios else [1.0] * len(aspects)
widths = [panel_mm * aspect * ratio for aspect, ratio in zip(aspects, ratios)]
left_mm, right_mm, top_mm = 16.0, 6.0, 6.0
gap_mm, bottom_mm = 16.0, 15.0
total_w = left_mm + sum(widths) + gap_mm * (len(widths) - 1) + right_mm
total_h = top_mm + panel_mm + bottom_mm
fig = plt.figure(figsize=(total_w / 25.4 * scale, total_h / 25.4 * scale))
def frac_x(mm: float) -> float:
return mm / total_w
def frac_y(mm: float) -> float:
return mm / total_h
axes = []
cursor = left_mm
for width in widths:
box = [frac_x(cursor), frac_y(bottom_mm), frac_x(width), frac_y(panel_mm)]
axes.append(fig.add_axes(box))
cursor += width + gap_mm
return fig, (axes if len(axes) > 1 else axes[0])
def polar_axes(ax: Any, half: float, *, grid: float = 500.0,
center: Sequence[float] = (0.0, 0.0)) -> None:
"""世界坐标视图:等比例、留白、细网格;``center`` 为视图中心。"""
cx, cy = float(center[0]), float(center[1])
step = grid
ticks = []
while step <= half * 1.2:
ticks += [-step, step]
step *= 2
ticks = sorted({round(cx + t) for t in ticks if abs(t) <= half * 1.05} |
{round(cy + t) for t in ticks if abs(t) <= half * 1.05})
# 顺序要紧:set_xticks 会在数据范围内重新 autoscale,必须最后再钉住范围
ax.set_xticks(ticks)
ax.set_yticks(ticks)
ax.set_xlim(cx - half, cx + half)
ax.set_ylim(cy - half, cy + half)
# 面板方框由 axes_for_panels 按数据宽高比分配,刻度范围也是 1:1,
# 不需要再让 matplotlib 调 aspect(它会反过来改写已设定的范围)
ax.set_aspect("auto")
ax.grid(True, color=C["grid"], lw=0.6)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
ax.set_axisbelow(True)
def save(fig: Any, stem: str, sources: Sequence[dict[str, Any]], scale: float) -> dict[str, Any]:
"""落盘 PNG(仅 PNG:论文插图不再维护矢量版),返回 manifest 条目。"""
OUT_DIR.mkdir(parents=True, exist_ok=True)
png = OUT_DIR / f"{stem}.png"
fig.savefig(png, format="png")
plt.close(fig)
return {"stem": stem, "png": str(png.relative_to(ROOT)), "scale": scale,
"sources": list(sources)}
def source(path: str | Path, **extra: Any) -> dict[str, Any]:
"""记录数据来源:仓库内相对路径、sha256 前 12 位、行数。"""
p = Path(path)
p = p if p.is_absolute() else ROOT / p
digest, lines = "-", 0
if p.is_file():
data = p.read_bytes()
digest = hashlib.sha256(data).hexdigest()[:12]
lines = data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
rel = str(p.relative_to(ROOT)) if p.is_relative_to(ROOT) else str(p)
return {"path": rel, "sha256_12": digest, "lines": lines, **extra}
# ---------------------------------------------------------------------------
# 行为日志:解析、计时复算、归档回退
# ---------------------------------------------------------------------------
def parse_jsonl(path: Path) -> dict[str, Any]:
"""兼容评测机 ``event=action`` 与客户端 ``event=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[str(ev.get("request_id", ""))] = ev
elif kind == "step" and str(ev.get("request_id", "")) not in fallback:
fallback[str(ev.get("request_id", ""))] = ev
if not actions:
for ev in fallback.values():
if not ev.get("path"):
continue
seq += 1
stamp = ev.get("logged_at_unix_s")
actions.append(dict(seq=seq, path=ev["path"], request=ev.get("request") or {},
response=ev.get("response") or {},
wall_ts_ms=stamp * 1000 if stamp else None))
if not actions:
raise ValueError(f"{path} 没有可回放的动作事件")
return dict(label=path.stem, case_code=meta.get("case_code") or path.parent.name,
module=meta.get("module") or "?", robot_id=str(meta.get("robot_id") or "?"),
truth=list(meta.get("truth") or []), actions=actions, source=path)
def discover_runs() -> list[dict[str, Any]]:
runs: list[dict[str, Any]] = []
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[dict[str, Any]], module: str, truth: int) -> dict[str, Any]:
cands = [r for r in runs if r["module"].startswith(module) and len(r["truth"]) >= truth
and sum(1 for a in r["actions"] if a["path"] == "/measure") > 0]
if not cands:
cands = [r for r in runs if r["module"].startswith(module)]
return sorted(cands, key=lambda r: (abs(len(r["truth"]) - truth), len(r["actions"])))[0]
def decompose(run: dict[str, Any]) -> dict[str, Any]:
"""按附件规则逐动作复算移动 / 换频 / 动作耗时。"""
pos = (0.0, 0.0)
channel: int | None = None
rows: list[dict[str, Any]] = []
for act in run["actions"]:
req, res = act["request"], act["response"]
move_m = switch = action_s = 0.0
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":
action_s = CLEAR_OK_S if str(res.get("clear_result")) == "success" else CLEAR_NO_S
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),
hit=str(res.get("measure_result")) == "direction",
cleared=str(res.get("clear_result")) == "success",
channel=req.get("channel"),
x=req.get("position", {}).get("x"),
y=req.get("position", {}).get("y")))
wall = [r for r in run["actions"] if r.get("wall_ts_ms")]
real_s = (wall[-1]["wall_ts_ms"] - wall[0]["wall_ts_ms"]) / 1000.0 if len(wall) > 1 else 0.0
return dict(rows=rows, virtual_s=float(run["actions"][-1]["response"].get("virtual_time_s") or 0),
real_s=real_s,
move_s=sum(r["move_s"] for r in rows),
switch_s=sum(r["switch_s"] for r in rows),
action_s=sum(r["action_s"] for r in rows),
move_m=sum(r["move_m"] for r in rows))
def solved_channels(run: dict[str, Any]) -> list[int]:
return sorted({int(a["request"]["channel"]) for a in run["actions"]
if a["path"] == "/clear" and "channel" in a["request"]
and str(a["response"].get("clear_result")) == "success"})
# ---------------------------------------------------------------------------
# 几何工具
# ---------------------------------------------------------------------------
def clip_halfplane(poly: np.ndarray, a: float, b: float, c: float) -> np.ndarray:
"""Sutherland–Hodgman:半平面 a·x + b·y <= c 裁剪凸多边形。"""
if len(poly) == 0:
return poly
out: list[list[float]] = []
n = len(poly)
for i in range(n):
cur, nxt = poly[i], poly[(i + 1) % n]
dc = a * cur[0] + b * cur[1] - c
dn = a * nxt[0] + b * nxt[1] - c
if dc <= 1e-9:
out.append(list(cur))
if dn > 1e-9:
t = dc / (dc - dn)
out.append([cur[0] + t * (nxt[0] - cur[0]), cur[1] + t * (nxt[1] - cur[1])])
elif dn <= 1e-9:
t = dc / (dc - dn)
out.append([cur[0] + t * (nxt[0] - cur[0]), cur[1] + t * (nxt[1] - cur[1])])
return np.array(out) if out else np.empty((0, 2))
def wedge_polygon(station: Sequence[float], bearing: float, half_deg: float,
reach: float) -> np.ndarray:
a0, a1 = math.radians(bearing - half_deg), math.radians(bearing + half_deg)
ang = np.linspace(a0, a1, 16)
pts = [[station[0], station[1]]] + \
[[station[0] + reach * math.cos(t), station[1] + reach * math.sin(t)] for t in ang]
return np.array(pts)
def clip_convex(region: np.ndarray, a: float, b: float, c: float) -> np.ndarray:
"""半平面 a·x + b·y <= c 裁剪(对 numpy 数组的薄封装)。"""
return clip_halfplane(np.asarray(region, dtype=float), a, b, c)
def clip_wedge(region: np.ndarray, station: Sequence[float], bearing: float,
half_deg: float) -> np.ndarray:
"""用角域 W(s, θ ± δ) 裁剪区域。
角域是两条边界射线的半平面交。示向度可能跨越 0/360 度,法向的正负不能靠叉积
猜:两个半平面各试一次,用"结果中心与站点的连线是否落在示向度 ±δ 内"来判定
哪一侧是角域内部。
"""
region = np.asarray(region, dtype=float)
if len(region) == 0:
return region
for edge in (bearing - half_deg, bearing + half_deg):
ang = math.radians(edge)
a, b = -math.sin(ang), math.cos(ang)
c = a * station[0] + b * station[1]
candidates = [cand for cand in (clip_convex(region, a, b, c),
clip_convex(region, -a, -b, -c))
if len(cand) >= 3]
if not candidates:
return np.empty((0, 2))
if len(candidates) == 1:
region = candidates[0]
continue
region = min(candidates, key=lambda cand: _direction_error(cand, station,
bearing, half_deg))
return region
def _direction_error(poly: np.ndarray, station: Sequence[float], bearing: float,
half_deg: float) -> float:
"""多边形中心方向与示向度的偏差;用于挑正确的半平面一侧。"""
centre = poly.mean(axis=0)
dx, dy = centre[0] - station[0], centre[1] - station[1]
if abs(dx) < 1e-12 and abs(dy) < 1e-12:
return 0.0
return abs(angle_diff(math.degrees(math.atan2(dy, dx)), bearing))
def polygon_area(poly: np.ndarray) -> float:
if len(poly) < 3:
return 0.0
x, y = poly[:, 0], poly[:, 1]
return 0.5 * abs(float(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))))
def angle_diff(a: float, b: float) -> float:
return (a - b + 180.0) % 360.0 - 180.0
def wedge_clip(station: Sequence[float], bearing: float, half_deg: float,
box: Sequence[float]) -> np.ndarray:
"""角域与视窗矩形的交集,用于局部放大图。"""
x0, y0, x1, y1 = box
poly = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], dtype=float)
return clip_wedge(poly, station, bearing, half_deg)
def q1_solve(stations: Sequence[Sequence[float]]) -> dict[str, Any]:
"""调用生产求解器,避免图中数字与代码不一致。"""
sys.path.insert(0, str(ROOT / "src"))
from mcm26b import Measurement, solve # noqa: PLC0415
res = solve([Measurement(x, y, b, h) for x, y, b, h in stations])
dc, mec = res["diameter_circle"], res["minimum_enclosing_circle"]
return dict(vertices=np.array(res["vertices"]), pair=res["diameter_pair"],
dc=dc, mec=mec)
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 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 seven_bound(a: float = RING_RADIUS_M, radius: float = SEVEN_R) -> float:
return max(a / math.sqrt(3), math.sqrt(radius ** 2 + a ** 2 - math.sqrt(3) * radius * a))
# ---------------------------------------------------------------------------
# 排版小工具
# ---------------------------------------------------------------------------
def draw_boxes(ax: Any, boxes: Sequence[dict[str, Any]], font: Any) -> None:
"""画一组统一风格的圆角框(流程示意图用)。"""
for b in boxes:
ax.add_patch(FancyBboxPatch((b["x"], b["y"]), b["w"], b["h"],
boxstyle="round,pad=0.02,rounding_size=0.6",
linewidth=1.0, edgecolor=b.get("edge", C["primary"]),
facecolor=b.get("face", C["primary_fill"]),
zorder=2))
ax.text(b["x"] + b["w"] / 2, b["y"] + b["h"] / 2, b["label"],
ha="center", va="center", fontproperties=b.get("font", font["hei_bold"]),
fontsize=b.get("size", 8.2), color=b.get("color", C["ink"]),
linespacing=1.5, zorder=3)
def connect(ax: Any, p0: Sequence[float], p1: Sequence[float], *,
color: str | None = None, style: str = "-", lw: float = 1.0,
rad: float = 0.0) -> None:
ax.add_patch(FancyArrowPatch(p0, p1, arrowstyle="-|>", mutation_scale=9,
linewidth=lw, color=color or C["muted"],
linestyle=style, zorder=4,
connectionstyle=f"arc3,rad={rad}"))
def chips(ax: Any, items: Sequence[tuple[str, str]], *, x: float = 0.02, y: float = 0.02,
dy: float = 0.06, font: Any = None) -> None:
"""左下角竖排色块图例(坐标用 axes 分数)。"""
for i, (label, color) in enumerate(items):
yy = y + i * dy
ax.add_patch(FancyBboxPatch((x, yy - 0.012), 0.018, 0.024,
boxstyle="round,pad=0.002,rounding_size=0.004",
transform=ax.transAxes, facecolor=color,
edgecolor="none", zorder=5, clip_on=False))
ax.text(x + 0.026, yy, label, transform=ax.transAxes, va="center", ha="left",
fontproperties=font, fontsize=7.6, color=C["ink"], zorder=5)
# ===========================================================================
# 1. 总体思路与算法主线
# ===========================================================================
def fig_flow_overall(font: dict[str, Any]) -> Any:
fig, ax = new_axes(170, 96)
ax.set_xlim(0, 120)
ax.set_ylim(0, 56)
ax.axis("off")
def band(tag: str, y: float, items: Sequence[tuple[str, float, float]],
face: str, color: str) -> None:
ax.text(3.0, y + 3.0, tag, ha="center", va="center",
fontproperties=font["hei_bold"], fontsize=8.6, color=color)
for label, x, width in items:
draw_boxes(ax, [dict(x=x, y=y, w=width, h=6.0, label=label, face=face,
edge=color, font=font["song"], size=7.8)], font)
ax.plot([8.0, 116.0], [y - 1.6, y - 1.6], color=C["grid"], lw=0.8, zorder=1)
band("L0", 46.0, [
("区域 Ω:半径 1800 m", 9.0, 26.5),
("移动 5 m/s · 检测 5 s", 36.0, 26.5),
("换频 1 s · 清除 5/3 s", 63.0, 26.5),
("源 10~16 个 · 频道 1~20", 90.0, 25.5)], C["primary_fill"], C["primary"])
band("L1", 33.0, [
("证据层:候选集与粒子幸存集", 9.0, 33.0),
("决策层:选点与任务表", 44.0, 33.0),
("执行层:串行客户端", 79.0, 33.0)], C["primary_fill"], C["primary"])
for x in (42.6, 77.6):
connect(ax, (x, 36.0), (x + 1.4, 36.0), color=C["ink"])
band("L2", 20.0, [
("问题 1 定位解算", 9.0, 25.5),
("问题 2 第二点选择", 35.5, 25.5),
("问题 3 全向搜索", 62.0, 25.5),
("问题 4 混合定向", 88.5, 27.0)], C["accent_fill"], C["accent"])
band("L3", 7.0, [
("发现闭环", 9.0, 33.0),
("定位闭环", 44.0, 33.0),
("清除闭环", 79.0, 33.0)], C["success_fill"], C["success"])
return fig
# ===========================================================================
# 2. 执行步骤:会话状态机
# ===========================================================================
def fig_steps_state_machine(font: dict[str, Any]) -> Any:
fig, ax = new_axes(160, 34)
ax.set_xlim(0, 100)
ax.set_ylim(0, 22)
ax.axis("off")
states = [("IDLE", 3, C["grid"], C["muted"]),
("倒计时 5 s", 26, C["warn_fill"], C["warn"]),
("OPEN 接口开放", 49, C["success_fill"], C["success"]),
("CLOSED 断连", 72, C["accent_fill"], C["accent"])]
for label, x, face, edge in states:
draw_boxes(ax, [dict(x=x, y=9, w=22, h=6.5, label=label, face=face, edge=edge,
font=font["hei_bold"], size=8.4)], font)
for i in range(3):
connect(ax, (25 + i * 23 + 0.3, 12.2), (26 + i * 23 - 0.5, 12.2), color=C["ink"])
ax.text(50, 6.2, "现实预算 1200 s(程序)与测试窗口 1500 s(服务端)独立计时",
ha="center", fontproperties=font["song"], fontsize=7.6, color=C["muted"])
ax.text(83, 4.0, "剩余不足 60 s 时预留 /exit", ha="center",
fontproperties=font["song"], fontsize=7.6, color=C["accent"])
return fig
# ===========================================================================
# 3. 执行时间:附件示例逐步推演
# ===========================================================================
EXAMPLE_STEPS = [
("/enter", 0.0, 0.0, 0.0),
("/measure ch1", 100.0, 0.0, 5.0),
("/measure ch2", 0.0, 1.0, 5.0),
("/clear ch3", 80.0, 0.0, 3.0),
("/measure ch2", 0.0, 0.0, 5.0),
("/exit", 0.0, 0.0, 0.0),
]
def fig_timing_attachment(font: dict[str, Any]) -> Any:
fig, ax = new_axes(160, 46)
y = np.arange(len(EXAMPLE_STEPS))[::-1]
move = np.array([s[1] for s in EXAMPLE_STEPS])
switch = np.array([s[2] for s in EXAMPLE_STEPS])
action = np.array([s[3] for s in EXAMPLE_STEPS])
ax.barh(y, move, color=C["primary"], height=0.6, label="移动 L/5")
ax.barh(y, switch, left=move, color=C["warn"], height=0.6, label="换频 1 s")
ax.barh(y, action, left=move + switch, color=C["success"], height=0.6,
label="检测/清除动作")
cumulative = np.cumsum(move + switch + action)
for i, (yy, total, acc) in enumerate(zip(y, move + switch + action, cumulative)):
if total > 0:
ax.text(move[i] + switch[i] + action[i] + 2, yy, f"{total:.0f} s",
va="center", fontproperties=font["times"], fontsize=7.6, color=C["ink"])
ax.text(-4, yy, f"{acc:.0f}", va="center", ha="right",
fontproperties=font["times_bold"], fontsize=7.8, color=C["accent"])
ax.set_yticks(y)
ax.set_yticklabels([s[0] for s in EXAMPLE_STEPS], fontproperties=font["times"],
fontsize=7.8)
ax.set_xlabel("虚拟耗时 / s", fontproperties=font["song"])
ax.set_xlim(-14, 132)
ax.text(-4, len(EXAMPLE_STEPS) - 0.35, "累计", ha="right", va="center",
fontproperties=font["song"], fontsize=7.6, color=C["accent"])
ax.legend(prop=font["song"], frameon=False, loc="lower right", ncols=3)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
ax.grid(True, axis="x", color=C["grid"], lw=0.6)
ax.set_axisbelow(True)
return fig
# ===========================================================================
# 4. 执行时间:实测日志分解
# ===========================================================================
def fig_timing_measured(run: dict[str, Any], font: dict[str, Any]) -> Any:
tl = decompose(run)
rows = tl["rows"]
seq = np.array([r["seq"] for r in rows])
move = np.cumsum([r["move_s"] for r in rows])
switch = np.cumsum([r["switch_s"] for r in rows])
action = np.cumsum([r["action_s"] for r in rows])
total = move + switch + action
parts = [tl["move_s"], tl["action_s"], tl["switch_s"]]
labels = ["移动", "检测/清除", "换频"]
colors = [C["primary"], C["success"], C["warn"]]
fig, (ax, ax2) = new_axes(170, 54, ncols=2, width_ratios=[1.45, 1.0])
ax.stackplot(seq, move, switch, action, colors=colors,
labels=["移动 L/5", "检测/清除动作", "换频 1 s"], lw=0)
ax.set_xlim(seq[0], seq[-1])
ax.set_ylim(0, total[-1] * 1.08)
ax.set_xlabel("动作序号", fontproperties=font["song"])
ax.set_ylabel("累计虚拟时间 / s", fontproperties=font["song"])
ax.legend(prop=font["song"], frameon=False, loc="upper left")
for side in ("top", "right"):
ax.spines[side].set_visible(False)
ax.grid(True, axis="y", color=C["grid"], lw=0.6)
ax.set_axisbelow(True)
# 右:构成条形 + 汇总(同一坐标下三段,末段极窄故编号标注)
ax2.set_xlim(0, 1)
ax2.set_ylim(0, 1)
ax2.axis("off")
bar_y, bar_h = 0.62, 0.14
left = 0.0
for i, (value, color) in enumerate(zip(parts, colors)):
frac = value / total[-1]
ax2.add_patch(FancyBboxPatch((left, bar_y), frac, bar_h,
boxstyle="round,pad=0,rounding_size=0.005",
facecolor=color, edgecolor="none"))
if frac > 0.06:
ax2.text(left + frac / 2, bar_y + bar_h / 2, f"{value:,.0f} s", ha="center",
va="center", fontproperties=font["song"], fontsize=7.4,
color="white")
left += frac
ax2.text(0.0, 0.84, f"虚拟 {total[-1]:,.1f} s", fontproperties=font["song"],
fontsize=7.8, color=C["ink"])
ax2.text(0.5, 0.84, f"现实 {tl['real_s']:.2f} s", fontproperties=font["song"],
fontsize=7.8, color=C["ink"])
ax2.text(1.0, 0.84, f"加速 {total[-1] / max(tl['real_s'], 1e-9):,.0f} 倍", ha="right",
fontproperties=font["song"], fontsize=7.8, color=C["accent"])
for i, (value, label, color) in enumerate(zip(parts, labels, colors)):
ax2.add_patch(FancyBboxPatch((0.0 + i * 0.36, 0.40), 0.03, 0.05,
boxstyle="round,pad=0.002,rounding_size=0.01",
facecolor=color, edgecolor="none"))
ax2.text(0.05 + i * 0.36, 0.425,
f"{i + 1} {label} {value:,.0f} s", fontproperties=font["song"],
fontsize=7.4, va="center")
ax2.text(0.0, 0.24, f"检测 {sum(1 for r in rows if r['path'] == '/measure')} 次 · "
f"清除 {sum(1 for r in rows if r['path'] == '/clear')} 次 · "
f"里程 {tl['move_m'] / 1000:.1f} km",
fontproperties=font["song"], fontsize=7.4, color=C["muted"])
return fig
# ===========================================================================
# 5. 问题一:交会定位区域
# ===========================================================================
def _q1_data() -> dict[str, Any]:
case = q1_practice_case()
if case:
return dict(subtitle=f"官方演练 {case['case_code']}",
stations=[(m["x"], m["y"], m["bearing_deg"], m["half_angle_deg"])
for m in case["measurements"]],
verts=np.array(case["result"]["vertices"]),
dc=case["result"]["diameter_circle"],
mec=case["result"]["minimum_enclosing_circle"],
src=case["_path"])
doc = json.loads((ROOT / "examples" / "q1" / "triangle.json").read_text(encoding="utf-8"))
sol = q1_solve([(m["x"], m["y"], m["bearing_deg"], m.get("half_angle_deg", 1.0))
for m in doc["measurements"]])
return dict(subtitle="反例算例 triangle.json",
stations=[(m["x"], m["y"], m["bearing_deg"], m.get("half_angle_deg", 1.0))
for m in doc["measurements"]],
verts=sol["vertices"], dc=sol["dc"], mec=sol["mec"],
src=ROOT / "examples" / "q1" / "triangle.json")
def fig_q1_meetup(font: dict[str, Any]) -> Any:
data = _q1_data()
stations = data["stations"]
xs = [s[0] for s in stations]
ys = [s[1] for s in stations]
cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
half = max(max(xs) - min(xs), max(ys) - min(ys)) / 2 * 1.35 + 60
reach = 2.4 * half
mec_c = data["mec"]["center"]
mec_r = data["mec"]["radius_m"]
dc_c, dc_r = data["dc"]["center"], data["dc"]["radius_m"]
# 放大窗口取定位区域外接框的 1.8 倍,使多边形、直径圆、MEC 同框且尺寸可比
verts = data["verts"]
zoom_c = (float(verts[:, 0].mean()), float(verts[:, 1].mean()))
span = float(np.abs(verts - np.array(zoom_c)).max())
zoom = max(1.8 * span, 0.8 * mec_r + span, 12.0)
fig, axes = axes_for_panels(62.0, [1.0, 1.0], width_ratios=[1.0, 1.0])
ax, axz = axes
polar_axes(ax, half, grid=half / 2)
for sx, sy, bearing, hd in stations:
ax.add_patch(Polygon(wedge_polygon((sx, sy), bearing, hd, reach), closed=True,
facecolor=C["primary"], alpha=0.16, edgecolor="none"))
ax.plot([sx], [sy], marker="o", ms=4, color=C["primary"])
ax.annotate(f"({sx:.0f}, {sy:.0f})", (sx, sy), textcoords="offset points",
xytext=(6, 4), fontproperties=font["times"], fontsize=7.2,
color=C["primary"])
ax.add_patch(Polygon(data["verts"], closed=True, facecolor=C["accent"],
edgecolor=C["accent"], lw=1.0))
ax.annotate("定位区域 P →", (float(data["verts"][:, 0].mean()),
float(data["verts"][:, 1].mean())),
textcoords="offset points", xytext=(14, -18),
fontproperties=font["song"], fontsize=7.6, color=C["accent"],
arrowprops=dict(arrowstyle="-", color=C["accent"], lw=0.9))
ax.set_xlabel("x / m", fontproperties=font["song"])
ax.set_ylabel("y / m", fontproperties=font["song"])
polar_axes(axz, zoom, grid=zoom / 2, center=zoom_c)
axz.set_xticks([])
axz.set_yticks([])
axz.set_ylabel("")
# 放大面板:角域按面板的坐标范围裁剪,范围与 xlim/ylim 一致
xlim, ylim = axz.get_xlim(), axz.get_ylim()
box = (float(xlim[0]), float(ylim[0]), float(xlim[1]), float(ylim[1]))
for sx, sy, bearing, hd in stations:
poly = wedge_clip((sx, sy), bearing, hd, box)
if len(poly):
axz.add_patch(Polygon(poly, closed=True, facecolor=C["primary"],
alpha=0.28, edgecolor="none"))
region = np.array([[box[0], box[1]], [box[2], box[1]],
[box[2], box[3]], [box[0], box[3]]], dtype=float)
for sx, sy, bearing, hd in stations:
region = clip_wedge(region, (sx, sy), bearing, hd)
if len(region) >= 3:
axz.add_patch(Polygon(region, closed=True, facecolor=C["accent_fill"],
edgecolor=C["accent"], lw=1.6))
axz.add_patch(Polygon(data["verts"], closed=True, facecolor=C["accent_fill"],
edgecolor=C["accent"], lw=1.0))
axz.add_patch(Circle(dc_c, dc_r, facecolor="none", edgecolor=C["violet"],
lw=1.2, ls=(0, (5, 3))))
axz.add_patch(Circle(mec_c, mec_r, facecolor="none", edgecolor=C["success"], lw=1.4))
axz.plot([dc_c[0]], [dc_c[1]], marker="o", ms=3.4, color=C["violet"])
axz.plot([mec_c[0]], [mec_c[1]], marker="o", ms=3.4, color=C["success"])
outside = max(data["verts"], key=lambda v: math.dist(v, dc_c))
axz.annotate("", xy=outside, xytext=dc_c,
arrowprops=dict(arrowstyle="-|>", color=C["accent"], lw=1.0))
axz.annotate("E > 0", (outside[0], outside[1]), textcoords="offset points",
xytext=(10, 6), fontproperties=font["times"], fontsize=7.4,
color=C["accent"])
axz.set_xlabel(f"x / m(放大 ±{zoom:.0f} m)", fontproperties=font["song"])
chips(ax, [("前向角域 W(s, θ ± 1°)", C["primary"]), ("定位区域 P", C["accent"])])
chips(axz, [("直径圆(不覆盖)", C["violet"]), ("最小包围圆 MEC", C["success"])])
return fig
# ===========================================================================
# 6. 问题一:直径圆反例
# ===========================================================================
def fig_q1_counterexample(font: dict[str, Any]) -> Any:
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"]]
sol = q1_solve(stations)
verts = sol["vertices"]
dc, mec = sol["dc"], sol["mec"]
mec_r = mec["radius_m"]
mec_c, dc_c = mec["center"], dc["center"]
# 反例的定位区域是边长 40 m 的等边三角形:取 1.8 倍直径做放大窗口
zoom = max(1.8 * float(np.abs(verts - np.array(mec_c)).max()) * 2.0, 40.0)
fig, axes = axes_for_panels(60.0, [1.0, 1.0])
ax, axz = axes
half = 1200.0
polar_axes(ax, half, grid=400.0)
for sx, sy, bearing, hd in stations:
ax.add_patch(Polygon(wedge_polygon((sx, sy), bearing, hd, 3 * half), closed=True,
facecolor=C["primary"], alpha=0.14, edgecolor="none"))
ax.plot([sx], [sy], marker="o", ms=4, color=C["primary"])
ax.annotate(f"({sx:.0f}, {sy:.0f})", (sx, sy), textcoords="offset points",
xytext=(6, 4), fontproperties=font["times"], fontsize=7.2,
color=C["primary"])
ax.add_patch(Polygon(verts, closed=True, facecolor=C["accent"], edgecolor=C["accent"]))
ax.set_xlabel("x / m", fontproperties=font["song"])
ax.set_ylabel("y / m", fontproperties=font["song"])
polar_axes(axz, zoom, grid=zoom / 2, center=mec_c)
axz.set_xticks([])
axz.set_yticks([])
axz.set_ylabel("")
box = (mec_c[0] - zoom, mec_c[1] - zoom, mec_c[0] + zoom, mec_c[1] + zoom)
for sx, sy, bearing, hd in stations:
poly = wedge_clip((sx, sy), bearing, hd, box)
if len(poly):
axz.add_patch(Polygon(poly, closed=True, facecolor=C["primary"],
alpha=0.24, edgecolor="none"))
region = np.array([[box[0], box[1]], [box[2], box[1]],
[box[2], box[3]], [box[0], box[3]]], dtype=float)
for sx, sy, bearing, hd in stations:
region = clip_wedge(region, (sx, sy), bearing, hd)
if len(region) >= 3:
axz.add_patch(Polygon(region, closed=True, facecolor=C["accent_fill"],
edgecolor=C["accent"], lw=1.6))
axz.add_patch(Circle(dc_c, dc["radius_m"], facecolor="none", edgecolor=C["violet"],
lw=1.4, ls=(0, (5, 3))))
axz.add_patch(Circle(mec_c, mec_r, facecolor="none", edgecolor=C["success"], lw=1.4))
outside = max(verts, key=lambda v: math.dist(v, dc_c))
axz.annotate("最大超出顶点", xy=outside, xytext=(outside[0] + 6, outside[1] - 10),
fontproperties=font["song"], fontsize=7.6, color=C["accent"],
arrowprops=dict(arrowstyle="-|>", color=C["accent"], lw=1.0))
axz.set_xlabel(f"x / m(放大 ±{zoom:.0f} m)", fontproperties=font["song"])
chips(axz, [("直径圆(不覆盖)", C["violet"]), ("最小包围圆 MEC", C["success"])])
return fig
# ===========================================================================
# 7. 问题二:保证接收盘
# ===========================================================================
def fig_q2_safe_disk(font: dict[str, Any]) -> Any:
delta = math.radians(1.0)
a = 1500.0 / (2 * math.cos(delta))
safe = R_EFF - a
bearing = 30.0
c = (a * math.cos(math.radians(bearing)), a * math.sin(math.radians(bearing)))
fig, axes = axes_for_panels(64.0, [1.0, 1.0], width_ratios=[1.0, 1.0])
ax, axz = axes
half = 1900.0
polar_axes(ax, half, grid=500.0)
ax.add_patch(Circle((0, 0), OMEGA_R, facecolor="none", edgecolor=C["primary"], lw=1.2))
ax.add_patch(Polygon(wedge_polygon((0, 0), bearing, 1.0, 1500.0), closed=True,
facecolor=C["accent"], alpha=0.5, edgecolor=C["accent"], lw=1.0))
ax.plot([0], [0], marker="o", ms=4, color=C["accent"])
ax.annotate("s1", (0, 0), textcoords="offset points", xytext=(6, -10),
fontproperties=font["times"], fontsize=7.6, color=C["accent"])
ax.add_patch(Circle(c, a, facecolor="none", edgecolor=C["violet"], lw=1.0,
ls=(0, (5, 3))))
ax.add_patch(Circle(c, safe, facecolor=C["success"], alpha=0.35,
edgecolor=C["success"], lw=1.2))
ax.annotate("", xy=c, xytext=(0, 0),
arrowprops=dict(arrowstyle="-|>", color=C["violet"], lw=1.0))
ax.annotate(f"a = {a:.1f} m", ((c[0] + 0) / 2, (c[1] + 0) / 2),
textcoords="offset points", xytext=(-6, 26), ha="right",
fontproperties=font["song"], fontsize=7.6, color=C["violet"],
arrowprops=dict(arrowstyle="-", color=C["violet"], lw=0.8))
ax.annotate(f"安全盘半径 {safe:.1f} m", (c[0], c[1] - safe * 0.7),
textcoords="offset points", xytext=(14, -20), ha="left",
fontproperties=font["song"], fontsize=7.6, color=C["success"],
arrowprops=dict(arrowstyle="-", color=C["success"], lw=0.8))
ax.text(0, OMEGA_R * 0.98, "Ω:R = 1800 m", fontproperties=font["song"],
fontsize=7.6, color=C["primary"], ha="center", va="top")
ax.set_xlabel("x / m", fontproperties=font["song"])
ax.set_ylabel("y / m", fontproperties=font["song"])
# 右:安全性依据(两半径相加 = 1000 m)
axz.set_aspect("auto")
axz.axis("off")
axz.set_xlim(-1.15, 1.25)
axz.set_ylim(-0.35, 1.05)
r_safe, r_a = safe / 1000.0, a / 1000.0
axz.add_patch(Circle((0, 0.45), r_a, facecolor=C["violet"], alpha=0.16,
edgecolor=C["violet"], lw=1.0, ls=(0, (4, 3))))
axz.add_patch(Circle((0, 0.45), r_safe, facecolor=C["success"], alpha=0.35,
edgecolor=C["success"], lw=1.2))
axz.plot([0], [0.45], marker="o", ms=3.4, color=C["ink"])
ang = math.radians(20)
axz.annotate("", xy=(r_a * math.cos(ang), 0.45 + r_a * math.sin(ang)),
xytext=(0, 0.45), arrowprops=dict(arrowstyle="-", color=C["violet"], lw=1.0))
axz.annotate("", xy=(-r_safe * math.cos(ang), 0.45 - r_safe * math.sin(ang)),
xytext=(0, 0.45), arrowprops=dict(arrowstyle="-", color=C["success"], lw=1.0))
axz.text(0.30, 0.72, f"a = {a:.1f} m", fontproperties=font["song"], fontsize=7.6,
color=C["violet"])
axz.text(-0.62, 0.18, f"1000 − a = {safe:.1f} m", fontproperties=font["song"],
fontsize=7.6, color=C["success"])
chips(ax, [("前向角域 W(s1)", C["accent"]), ("截断角域外接圆 B(c, a)", C["violet"]),
("安全候选盘 B(c, 1000 − a)", C["success"])])
return fig
# ===========================================================================
# 8. 问题三:七点覆盖
# ===========================================================================
def fig_q3_seven_coverage(font: dict[str, Any]) -> Any:
a = RING_RADIUS_M
stations = [(0.0, 0.0)] + [(a * math.cos(k * math.pi / 3), a * math.sin(k * math.pi / 3))
for k in range(6)]
bound = seven_bound(a)
fig, axes = axes_for_panels(66.0, [1.0, 0.95], width_ratios=[1.0, 0.95])
ax, axr = axes
half = 2050.0
polar_axes(ax, half, grid=600.0)
ax.add_patch(Circle((0, 0), OMEGA_R, facecolor="#f7fafc", edgecolor=C["primary"], lw=1.4))
for px, py in stations:
ax.add_patch(Circle((px, py), R_EFF, facecolor=C["success"], alpha=0.28,
edgecolor=C["success"], lw=0.8))
ax.add_patch(Circle((0, 0), OMEGA_R, facecolor="none", edgecolor=C["primary"], lw=1.4))
for px, py in stations:
ax.plot([px], [py], marker="o", ms=4.2, color=C["accent"])
ax.plot([0, a], [0, 0], color=C["accent"], lw=0.9)
ax.text(a / 2, 60, f"a = {a:.1f} m", ha="center", fontproperties=font["times"],
fontsize=7.6, color=C["accent"])
ax.plot([0], [-SEVEN_R], marker="o", ms=4.6, mfc="none", mec=C["accent"], mew=1.2)
ax.annotate(f"最坏点 {bound:.2f} m", (0, -SEVEN_R), textcoords="offset points",
xytext=(-4, 14), ha="center", fontproperties=font["song"], fontsize=7.6,
color=C["accent"],
arrowprops=dict(arrowstyle="-", color=C["accent"], lw=0.8))
ax.text(0, OMEGA_R, "Ω", ha="center", va="bottom", fontproperties=font["song"],
fontsize=8, color=C["primary"])
ax.set_xlabel("x / m", fontproperties=font["song"])
ax.set_ylabel("y / m", fontproperties=font["song"])
chips(ax, [("覆盖站点(原点 + 六等分环)", C["accent"]), ("1000 m 接收盘", C["success"])])
axr.axis("off")
axr.set_xlim(0, 1)
axr.set_ylim(0, 1)
rows = [("七点解析(默认)", "7", f"{bound:.2f} m", C["success_fill"], C["success"]),
("49 点网格基线", "49", "989.95 m", "#f7f9fb", C["muted"]),
("31 点最小命中集(题 4)", "31", "990.05 m", "#f7f9fb", C["muted"])]
header = ["方案", "点数", "覆盖半径上界"]
xs = [0.02, 0.62, 0.78]
for x, head in zip(xs, header):
axr.text(x, 0.9, head, fontproperties=font["hei_bold"], fontsize=7.8,
color=C["primary"])
for i, (name, count, dist, face, edge) in enumerate(rows):
y = 0.76 - i * 0.13
axr.add_patch(FancyBboxPatch((0.0, y - 0.045), 1.0, 0.11,
boxstyle="round,pad=0.004,rounding_size=0.02",
facecolor=face, edgecolor=edge, lw=0.8))
axr.text(xs[0], y, name, fontproperties=font["song"], fontsize=7.6)
axr.text(xs[1], y, count, fontproperties=font["times"], fontsize=7.6)
axr.text(xs[2], y, dist, fontproperties=font["times"], fontsize=7.6)
return fig
# ===========================================================================
# 9. 问题四:31 点最小命中集
# ===========================================================================
def fig_q4_mincover(font: dict[str, Any]) -> Any:
plan = load_plan("q4_mincover_v2")
pts = np.array(plan["points"], dtype=float)
cert = plan["certificate"]
fig, axes = axes_for_panels(64.0, [1.0, 1.0], width_ratios=[1.0, 1.0])
ax, axr = axes
half = 2150.0
polar_axes(ax, half, grid=600.0)
ax.add_patch(Circle((0, 0), OMEGA_R, facecolor="#f7fafc", edgecolor=C["primary"], lw=1.4))
ax.plot(pts[:, 0], pts[:, 1], ls="none", marker="o", ms=4.2, color=C["accent"],
label=f"{len(pts)} 个覆盖点")
ax.legend(prop=font["song"], frameon=False, loc="lower left")
ax.text(0, OMEGA_R, "Ω", ha="center", va="bottom", fontproperties=font["song"],
fontsize=8, color=C["primary"])
ax.set_xlabel("x / m", fontproperties=font["song"])
ax.set_ylabel("y / m", fontproperties=font["song"])
ax.set_xlabel("x / m", fontproperties=font["song"])
# 右:forward_half_disk 语义与证书
axr.axis("off")
axr.set_xlim(0, 1)
axr.set_ylim(0, 1)
axr.add_patch(Circle((0.36, 0.66), 0.24, facecolor="#f7f9fb", edgecolor=C["muted"],
lw=0.9))
half_disk = [[0.36, 0.66]] + [[0.36 + 0.24 * math.cos(math.radians(-90 + 180 * i / 24)),
0.66 + 0.24 * math.sin(math.radians(-90 + 180 * i / 24))]
for i in range(25)]
axr.add_patch(Polygon(half_disk, closed=True, facecolor=C["accent"], alpha=0.32,
edgecolor="none"))
axr.annotate("", xy=(0.57, 0.66), xytext=(0.36, 0.66),
arrowprops=dict(arrowstyle="-|>", color=C["accent"], lw=1.2))
axr.plot([0.36, 0.36], [0.42, 0.90], color=C["accent"], lw=0.9, ls=(0, (4, 3)))
axr.annotate("", xy=(0.60, 0.95), xytext=(0.12, 0.95),
arrowprops=dict(arrowstyle="<->", color=C["muted"], lw=0.8))
axr.text(0.36, 0.90, "覆盖半径 1000 m", ha="center", fontproperties=font["song"],
fontsize=7.4, color=C["muted"])
axr.text(0.62, 0.60, "朝向未知\n±90° 前向半盘", ha="left", va="top",
fontproperties=font["song"], fontsize=7.4, color=C["accent"], linespacing=1.6)
return fig
# ===========================================================================
# 10/11. WebUI 回放复现
# ===========================================================================
def fig_replay(run: dict[str, Any], font: dict[str, Any], *, directional: bool) -> Any:
tl = decompose(run)
rows = tl["rows"]
truth = run["truth"]
solved = set(solved_channels(run))
fig = plt.figure(figsize=(170 / 25.4, 108 / 25.4))
ax = fig.add_axes([0.04, 0.30, 0.60, 0.66])
polar_axes(ax, 2100.0, grid=1000.0)
ax.add_patch(Circle((0, 0), OMEGA_R, facecolor="#f7fafc", edgecolor=C["primary"], lw=1.4))
for src in truth:
ch = int(src["channel"])
color = C["accent"] if ch in solved else C["warn"]
ax.plot([src["x"]], [src["y"]], marker="o", ms=5, color=color, zorder=5)
ax.annotate(f"ch{ch}", (src["x"], src["y"]), textcoords="offset points",
xytext=(5, 2), fontproperties=font["times"], fontsize=6.8, color=color)
if directional and src.get("directional"):
hd = math.radians(float(src.get("heading_deg") or 0.0))
ax.annotate("", xy=(src["x"] + 200 * math.cos(hd), src["y"] + 200 * math.sin(hd)),
xytext=(src["x"], src["y"]),
arrowprops=dict(arrowstyle="-|>", color=C["accent"], lw=1.0))
path = [(0.0, 0.0)]
for row in rows:
if row["x"] is None:
continue
if (row["x"], row["y"]) != path[-1]:
path.append((row["x"], row["y"]))
if len(path) > 1:
px, py = zip(*path)
ax.plot(px, py, color=C["primary"], lw=1.1, alpha=0.85, zorder=3)
for act, row in zip(run["actions"], rows):
if row["x"] is None:
continue
if act["path"] == "/measure" and row["hit"]:
bearing = math.radians(float(act["response"].get("svd_deg") or 0.0))
ax.annotate("", xy=(row["x"] + 220 * math.cos(bearing),
row["y"] + 220 * math.sin(bearing)),
xytext=(row["x"], row["y"]),
arrowprops=dict(arrowstyle="-|>", color=C["success"], lw=0.7,
alpha=0.55))
ax.plot([row["x"]], [row["y"]], marker="o", ms=2.6, color=C["success"])
elif act["path"] == "/clear":
ax.plot([row["x"]], [row["y"]], marker="o", ms=7, mfc="none",
mec=C["success"] if row["cleared"] else C["muted"], mew=1.2)
if row["cleared"]:
ax.plot([row["x"]], [row["y"]], marker="x", ms=5, color=C["success"],
mew=1.6)
ax.set_xlabel("x / m", fontproperties=font["song"])
ax.set_ylabel("y / m", fontproperties=font["song"])
ax.legend(handles=[
Line2D([], [], marker="o", ls="none", color=C["success"], label="已清除频道"),
Line2D([], [], marker="o", ls="none", color=C["warn"], label="未清除频道"),
Line2D([], [], color=C["accent"], lw=1.2, label="定向源朝向"),
Line2D([], [], color=C["success"], lw=0.8, label="检测示向度"),
], prop=font["song"], frameon=False, loc="upper center",
bbox_to_anchor=(0.5, -0.13), ncols=4)
# 右侧:事件流水(前 10 条)
axt = fig.add_axes([0.70, 0.30, 0.285, 0.62])
axt.axis("off")
axt.set_xlim(0, 1)
axt.set_ylim(0, 1)
axt.text(0.0, 1.0, run["case_code"], fontproperties=font["times_bold"], fontsize=7.8,
color=C["primary"], va="top")
axt.text(0.0, 0.93, f"{run['module']} · 源 {len(truth)} · "
f"全清 {len(solved)}/{len(truth)}", fontproperties=font["song"],
fontsize=7.4, color=C["muted"], va="top")
axt.text(0.0, 0.86, "动作 参数 虚拟 t/s", fontproperties=font["hei_bold"],
fontsize=7.4, color=C["primary"], va="top")
for i, (act, row) in enumerate(list(zip(run["actions"], rows))[:10]):
if act["path"] == "/measure":
detail = f"ch{row['channel']} ({row['x']:.0f},{row['y']:.0f})"
elif act["path"] == "/clear":
detail = f"ch{row['channel']} {'成功' if row['cleared'] else '未发现'}"
elif act["path"] == "/enter":
detail = "启动"
else:
detail = "退出"
axt.text(0.0, 0.78 - i * 0.075, act["path"], fontproperties=font["times"],
fontsize=7.2, va="top")
axt.text(0.45, 0.78 - i * 0.075, detail, fontproperties=font["song"],
fontsize=7.2, va="top")
axt.text(1.0, 0.78 - i * 0.075, f"{row['virtual_s']:.0f}",
fontproperties=font["times"], fontsize=7.2, va="top", ha="right")
# 底部:虚拟时间轴(检测命中 / 清除刻度)
axb = fig.add_axes([0.04, 0.13, 0.945, 0.09])
axb.set_xlim(0, max(tl["virtual_s"], 1e-9))
axb.set_ylim(0, 1)
axb.axis("off")
axb.add_patch(FancyBboxPatch((0, 0.35), tl["virtual_s"], 0.3,
boxstyle="round,pad=0,rounding_size=0",
facecolor=C["primary_fill"], edgecolor="none"))
for act, row in zip(run["actions"], rows):
if act["path"] == "/measure" and row["hit"]:
axb.plot([row["virtual_s"], row["virtual_s"]], [0.2, 0.8], color=C["accent"],
lw=0.9)
elif act["path"] == "/clear" and row["cleared"]:
axb.plot([row["virtual_s"], row["virtual_s"]], [0.05, 0.95], color=C["success"],
lw=1.4)
axb.text(0, 1.15, "0 s", fontproperties=font["times"], fontsize=7.2, color=C["muted"])
axb.text(tl["virtual_s"], 1.15, f"{tl['virtual_s']:,.0f} s", ha="right",
fontproperties=font["times"], fontsize=7.2, color=C["muted"])
axb.text(tl["virtual_s"] / 2, 1.15, "竖线:检测到信号(红)/ 成功清除(绿)",
ha="center", fontproperties=font["song"], fontsize=7.2, color=C["muted"])
return fig
# ===========================================================================
# 入口
# ===========================================================================
def build_figures(only: Sequence[str] | None, scale: float) -> list[dict[str, Any]]:
font = setup_style()
want = set(only) if only else None
runs = discover_runs()
q3 = pick_run(runs, "q3", 10) if runs else None
q4 = pick_run(runs, "q4", 12) if runs else None
if not runs:
print("[fig] 未找到 JSONL,跳过与日志相关的 3 张图")
jobs: list[tuple[str, Callable[[], Any], list[dict[str, Any]]]] = [
("flow-overall-architecture", lambda: fig_flow_overall(font),
[source("src/mcm26b/config.py"), source("docs/B题建模分析与实施计划.md")]),
("steps-state-machine", lambda: fig_steps_state_machine(font),
[source("docs/协议规则表与状态机.md"), source("src/mcm26b/protocol/client.py")]),
("timing-attachment-example-105-111-194-199", lambda: fig_timing_attachment(font),
[source("docs/协议规则表与状态机.md")]),
("geo-q1-convex-intersection-zoom", lambda: fig_q1_meetup(font), []),
("geo-q1-diameter-circle-counterexample", lambda: fig_q1_counterexample(font),
[source("examples/q1/triangle.json")]),
("geo-q2-guaranteed-reception-disk", lambda: fig_q2_safe_disk(font),
[source("src/mcm26b/config.py"), source("docs/algo-2.md")]),
("geo-q3-seven-point-coverage", lambda: fig_q3_seven_coverage(font),
[source("src/mcm26b/planner/seven.py"), source("docs/algo-3.md")]),
("geo-q4-minimum-cover-31-points", lambda: fig_q4_mincover(font),
[source("src/mcm26b/guarantee/plans/q4_mincover_v2.json"), source("docs/algo-4.md")]),
]
if q3:
jobs.append(("timing-measured-q3-10-sources", lambda: fig_timing_measured(q3, font),
[source(q3["source"], case_code=q3["case_code"], module=q3["module"])]))
jobs.append(("replay-webui-q3-omnidirectional",
lambda: fig_replay(q3, font, directional=False),
[source(q3["source"], case_code=q3["case_code"])]))
if q4:
jobs.append(("replay-webui-q4-directional",
lambda: fig_replay(q4, font, directional=True),
[source(q4["source"], case_code=q4["case_code"])]))
entries: list[dict[str, Any]] = []
for stem, maker, sources in jobs:
if want and stem not in want:
continue
fig = maker()
if stem == "geo-q1-convex-intersection-zoom":
sources = [source(_q1_data()["src"])]
entry = save(fig, stem, sources, scale)
entries.append(entry)
print(f"[fig] {stem:<44} → {entry['png']}")
return entries
def main() -> int:
names = ["flow-overall-architecture", "steps-state-machine",
"timing-attachment-example-105-111-194-199", "timing-measured-q3-10-sources",
"geo-q1-convex-intersection-zoom", "geo-q1-diameter-circle-counterexample",
"geo-q2-guaranteed-reception-disk", "geo-q3-seven-point-coverage",
"geo-q4-minimum-cover-31-points", "replay-webui-q3-omnidirectional",
"replay-webui-q4-directional"]
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--only", default="", help="逗号分隔的图名子集")
parser.add_argument("--scale", type=float, default=1.0, help="尺寸倍率")
parser.add_argument("--list", action="store_true")
args = parser.parse_args()
if args.list:
print("\n".join(names))
return 0
only = [s.strip() for s in args.only.split(",") if s.strip()]
unknown = [s for s in only if s not in names]
if unknown:
parser.error(f"未知图名 {unknown}")
entries = build_figures(only or None, args.scale)
manifest = {"generator": "scripts/make_figures.py",
"command": "uv run python scripts/make_figures.py"
+ (f" --only {args.only}" if args.only else ""),
"palette": C, "fonts": {"dir": "docs/paper/fonts", "map": FONTS},
"figures": entries}
path = OUT_DIR / "manifest.json"
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(f"[fig] manifest → {path.relative_to(ROOT)}({len(entries)} 张)")
return 0
if __name__ == "__main__":
raise SystemExit(main())