1182 lines
56 KiB
Python
1182 lines
56 KiB
Python
"""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, 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,
|
||
**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
|
||
last = run["actions"][-1]["response"]
|
||
return dict(rows=rows, virtual_s=float(last.get("virtual_time_s") or 0.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))
|
||
return region
|
||
|
||
|
||
def _direction_error(poly: np.ndarray, station: Sequence[float],
|
||
bearing: 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]
|
||
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])
|
||
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] / 2, c[1] / 2), textcoords="offset points",
|
||
xytext=(-10, 30), ha="center", 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]), textcoords="offset points",
|
||
xytext=(30, -34), 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("equal", adjustable="box")
|
||
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)
|
||
|
||
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())
|