123
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
``referee-data/`` 与 ``output/`` 都在 .gitignore 里:本机清掉评测机数据后,
|
||||
``docs/figures/*.png`` 就无法复现。本脚本把"作图用到的那几局"抽出来,
|
||||
命名成 ``<case_code>.<module>.jsonl`` 存进 ``examples/replay/``(入库、几万行以内),
|
||||
``scripts/make_figures.py`` 在没有评测机数据时自动改读这里。
|
||||
``scripts/build_figure_data.py`` 在没有评测机数据时自动改读这里。
|
||||
|
||||
uv run python scripts/archive_replay_logs.py # 归档作图默认选中的两局
|
||||
uv run python scripts/archive_replay_logs.py BBM9-HZG2-77N5-NKKC # 指定案例
|
||||
@@ -26,7 +26,7 @@ ARCHIVE_DIR = ROOT / 'examples' / 'replay'
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from make_figures import discover_runs, pick_run # noqa: E402
|
||||
from build_figure_data import discover_runs, pick_run # noqa: E402
|
||||
|
||||
|
||||
def archive(run: dict, out_dir: Path) -> Path:
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
"""为 MATLAB 出图准备数据,并更新 docs/figures/manifest.json。
|
||||
|
||||
uv run python scripts/build_figure_data.py --prepare # 只写 .mat 数据
|
||||
uv run python scripts/build_figure_data.py # 写数据 + 调 MATLAB 出图
|
||||
uv run python scripts/build_figure_data.py --only geo-q3-seven-point-coverage
|
||||
uv run python scripts/build_figure_data.py --list
|
||||
|
||||
分工:
|
||||
|
||||
- **本脚本(Python)**:解析行为日志与覆盖计划、调用生产求解器算几何、把绘图数据写成
|
||||
``docs/figures/.data/*.mat``、维护 manifest(来源路径 + sha256 + 行数);
|
||||
- **MATLAB**:``scripts/matlab/*.m`` 负责排版与出图,只读 .mat,不做数据解析。
|
||||
手动出图:MATLAB 中 ``cd scripts/matlab`` 后执行 ``run_all_figures``。
|
||||
|
||||
图内不放标题、图注与解释性文字,只保留坐标轴、图例与数据标注;
|
||||
每张图的含义由文件名与 ``docs/figures/README.md`` 承担。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
import numpy as np
|
||||
from scipy.io import savemat
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_DIR = ROOT / "docs" / "figures"
|
||||
DATA_DIR = OUT_DIR / ".data"
|
||||
MATLAB_DIR = ROOT / "scripts" / "matlab"
|
||||
SESSIONS_DIR = ROOT / "referee-data" / "sessions"
|
||||
ARCHIVE_DIR = ROOT / "examples" / "replay"
|
||||
MATLAB_BIN = Path("/Applications/MATLAB_R2025a.app/bin/matlab")
|
||||
|
||||
# 题面常量(与 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
|
||||
|
||||
# 回放图指定的 q4 演练局(缺失时回退到自动挑选)
|
||||
Q4_REPLAY_CASE = "GHSN-M38E-74GD-YLUD"
|
||||
SEVEN_R = 1800.0
|
||||
|
||||
# 图名 → (MATLAB 函数, .mat 数据名)
|
||||
FIGURES: tuple[tuple[str, str, str], ...] = (
|
||||
("flow-overall-architecture", "fig_flow_overall", "flow_overall"),
|
||||
("steps-state-machine", "fig_steps_state_machine", "steps_state_machine"),
|
||||
("timing-attachment-example-105-111-194-199", "fig_timing_attachment",
|
||||
"timing_attachment"),
|
||||
("timing-measured-q3-10-sources", "fig_timing_measured", "timing_measured"),
|
||||
("geo-q1-convex-intersection-zoom", "fig_geo_q1_meetup", "geo_q1_meetup"),
|
||||
("geo-q1-diameter-circle-counterexample", "fig_geo_q1_counterexample",
|
||||
"geo_q1_counterexample"),
|
||||
("geo-q2-guaranteed-reception-disk", "fig_geo_q2_safe_disk", "geo_q2"),
|
||||
("geo-q3-seven-point-coverage", "fig_geo_q3_coverage", "geo_q3"),
|
||||
("geo-q4-minimum-cover-31-points", "fig_geo_q4_mincover", "geo_q4"),
|
||||
("replay-webui-q3-omnidirectional", "fig_replay", "replay_q3"),
|
||||
("replay-webui-q4-directional", "fig_replay", "replay_q4"),
|
||||
)
|
||||
|
||||
SOURCES: dict[str, list[str]] = {
|
||||
"flow-overall-architecture": ["src/mcm26b/config.py", "docs/B题建模分析与实施计划.md"],
|
||||
"steps-state-machine": ["docs/协议规则表与状态机.md", "src/mcm26b/protocol/client.py"],
|
||||
"timing-attachment-example-105-111-194-199": ["docs/协议规则表与状态机.md"],
|
||||
"geo-q1-diameter-circle-counterexample": ["examples/q1/triangle.json"],
|
||||
"geo-q2-guaranteed-reception-disk": ["src/mcm26b/config.py", "docs/algo-2.md"],
|
||||
"geo-q3-seven-point-coverage": ["src/mcm26b/planner/seven.py", "docs/algo-3.md"],
|
||||
"geo-q4-minimum-cover-31-points": [
|
||||
"src/mcm26b/guarantee/plans/q4_mincover_v2.json", "docs/algo-4.md"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据来源
|
||||
# ---------------------------------------------------------------------------
|
||||
def source(path: str | Path, **extra: Any) -> dict[str, Any]:
|
||||
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(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_case(runs: Sequence[dict[str, Any]], case_code: str) -> dict[str, Any] | None:
|
||||
"""按案例编码取局;不在本机留档里就返回 None,由调用方回退。"""
|
||||
return next((r for r in runs if r["case_code"] == case_code), None)
|
||||
|
||||
|
||||
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 any(a["path"] == "/measure" for a in r["actions"])]
|
||||
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(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")))
|
||||
last = run["actions"][-1]["response"]
|
||||
return dict(rows=rows, virtual_s=float(last.get("virtual_time_s") or 0.0),
|
||||
move_m=sum(r["move_m"] for r in rows),
|
||||
measures=sum(1 for r in rows if r["path"] == "/measure"),
|
||||
clears=sum(1 for r in rows if r["path"] == "/clear"))
|
||||
|
||||
|
||||
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 angle_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((math.degrees(math.atan2(dy, dx)) - bearing + 180.0) % 360.0 - 180.0)
|
||||
|
||||
|
||||
def clip_wedge(region: np.ndarray, station: Sequence[float], bearing: float,
|
||||
half_deg: float) -> np.ndarray:
|
||||
"""用角域 W(s, θ ± δ) 裁剪区域:两个半平面各试一次,取方向一致的那一侧。"""
|
||||
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]
|
||||
cands = [cand for cand in (clip_halfplane(region, a, b, c),
|
||||
clip_halfplane(region, -a, -b, -c)) if len(cand) >= 3]
|
||||
if not cands:
|
||||
return np.empty((0, 2))
|
||||
region = cands[0] if len(cands) == 1 else min(
|
||||
cands, key=lambda cand: angle_error(cand, station, bearing))
|
||||
return region
|
||||
|
||||
|
||||
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, 24)
|
||||
return np.vstack([[station[0], station[1]],
|
||||
np.column_stack([station[0] + reach * np.cos(ang),
|
||||
station[1] + reach * np.sin(ang)])])
|
||||
|
||||
|
||||
def overview_window(stations: np.ndarray, verts: np.ndarray,
|
||||
dc: dict[str, Any], mec: dict[str, Any]) -> tuple[float, np.ndarray]:
|
||||
"""总览窗口:包含全部站点、定位区域与两个圆,中心取包围盒中心。"""
|
||||
pts = [np.asarray(stations, dtype=float), np.asarray(verts, dtype=float)]
|
||||
for circ in (dc, mec):
|
||||
c = np.asarray(circ["center"], dtype=float)
|
||||
r = float(circ["radius_m"])
|
||||
pts.append(np.array([c + r, c - r]))
|
||||
all_pts = np.vstack(pts)
|
||||
lo, hi = all_pts.min(axis=0), all_pts.max(axis=0)
|
||||
center = (lo + hi) / 2
|
||||
half = float(np.max(hi - lo) / 2 * 1.12 + 40.0)
|
||||
return half, center
|
||||
|
||||
|
||||
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 = res["diameter_circle"]
|
||||
mec = res["minimum_enclosing_circle"]
|
||||
return dict(vertices=np.array(res["vertices"]),
|
||||
dc=dict(center=np.array(dc["center"]), radius_m=float(dc["radius_m"])),
|
||||
mec=dict(center=np.array(mec["center"]), radius_m=float(mec["radius_m"])))
|
||||
|
||||
|
||||
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 data_flow_overall() -> dict[str, Any]:
|
||||
"""四个层次带:层标签、每层的框内短标签与语义色名。"""
|
||||
bands = [
|
||||
("L0", [("区域 Ω:半径 1800 m", 8.0, 26.0),
|
||||
("移动 5 m/s · 检测 5 s", 36.5, 26.0),
|
||||
("换频 1 s · 清除 5/3 s", 65.0, 26.0),
|
||||
("源 10~16 个 · 频道 1~20", 93.5, 24.0)]),
|
||||
("L1", [("证据层:候选集与粒子幸存集", 8.0, 34.0),
|
||||
("决策层:选点与任务表", 45.0, 34.0),
|
||||
("执行层:串行客户端", 82.0, 34.0)]),
|
||||
("L2", [("问题 1 定位解算", 8.0, 26.0),
|
||||
("问题 2 第二点选择", 36.5, 26.0),
|
||||
("问题 3 全向搜索", 65.0, 26.0),
|
||||
("问题 4 混合定向", 93.5, 24.0)]),
|
||||
("L3", [("发现闭环", 8.0, 34.0),
|
||||
("定位闭环", 45.0, 34.0),
|
||||
("清除闭环", 82.0, 34.0)]),
|
||||
]
|
||||
colors = {"L0": "primary", "L1": "primary", "L2": "accent", "L3": "success"}
|
||||
return dict(tags=[b[0] for b in bands],
|
||||
labels=[[i[0] for i in b[1]] for b in bands],
|
||||
colors=[colors[b[0]] for b in bands])
|
||||
|
||||
|
||||
def data_steps_state_machine() -> dict[str, Any]:
|
||||
return dict(labels=["IDLE", "倒计时 5 s", "OPEN 接口开放", "CLOSED 断连"],
|
||||
edges=["muted", "warn", "success", "accent"])
|
||||
|
||||
|
||||
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 data_timing_attachment() -> dict[str, Any]:
|
||||
return dict(labels=[s[0] for s in EXAMPLE_STEPS],
|
||||
move=np.array([s[1] for s in EXAMPLE_STEPS]),
|
||||
switch_s=np.array([s[2] for s in EXAMPLE_STEPS]),
|
||||
action=np.array([s[3] for s in EXAMPLE_STEPS]),
|
||||
xlabel="虚拟耗时 / s",
|
||||
legend=["移动 L/5", "换频 1 s", "检测/清除动作"])
|
||||
|
||||
|
||||
def data_timing_measured(run: dict[str, Any]) -> dict[str, Any]:
|
||||
tl = decompose(run)
|
||||
rows = tl["rows"]
|
||||
seq = np.arange(1.0, len(rows) + 1.0)
|
||||
steps = np.array([[r["move_s"], r["action_s"], r["switch_s"]] for r in rows],
|
||||
dtype=float)
|
||||
cum = np.cumsum(np.cumsum(steps, axis=1), axis=0) # 移动 / +动作 / +换频
|
||||
return dict(seq=seq, cum=cum,
|
||||
xlabel="动作序号", ylabel="累计虚拟时间 / s",
|
||||
legend=["移动", "检测/清除动作", "换频"])
|
||||
|
||||
|
||||
def data_geo_q1_meetup() -> tuple[dict[str, Any], Path]:
|
||||
case = q1_practice_case()
|
||||
if case:
|
||||
stations = [(m["x"], m["y"], m["bearing_deg"], m["half_angle_deg"])
|
||||
for m in case["measurements"]]
|
||||
res = case["result"]
|
||||
verts = np.array(res["vertices"])
|
||||
dc = dict(center=np.array(res["diameter_circle"]["center"]),
|
||||
radius_m=float(res["diameter_circle"]["radius_m"]))
|
||||
mec = dict(center=np.array(res["minimum_enclosing_circle"]["center"]),
|
||||
radius_m=float(res["minimum_enclosing_circle"]["radius_m"]))
|
||||
src = case["_path"]
|
||||
else:
|
||||
src = ROOT / "examples" / "q1" / "triangle.json"
|
||||
doc = json.loads(src.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, dc, mec = sol["vertices"], sol["dc"], sol["mec"]
|
||||
|
||||
zoom_c = verts.mean(axis=0)
|
||||
span = float(np.abs(verts - zoom_c).max())
|
||||
zoom = max(1.8 * span, 0.8 * mec["radius_m"] + span, 12.0)
|
||||
xs = [s[0] for s in stations]
|
||||
ys = [s[1] for s in stations]
|
||||
half, view_c = overview_window(np.array([[s[0], s[1]] for s in stations]),
|
||||
verts, dc, mec)
|
||||
reach = 2.4 * half
|
||||
wedges = [wedge_polygon((sx, sy), b, hd, reach) for sx, sy, b, hd in stations]
|
||||
region = np.array([[zoom_c[0] - zoom, zoom_c[1] - zoom],
|
||||
[zoom_c[0] + zoom, zoom_c[1] - zoom],
|
||||
[zoom_c[0] + zoom, zoom_c[1] + zoom],
|
||||
[zoom_c[0] - zoom, zoom_c[1] + zoom]], dtype=float)
|
||||
for sx, sy, b, hd in stations:
|
||||
region = clip_wedge(region, (sx, sy), b, hd)
|
||||
data = dict(
|
||||
stations=np.array([[s[0], s[1]] for s in stations]),
|
||||
station_labels=[f"s{i + 1}" for i in range(len(stations))],
|
||||
wedges=np.stack(wedges), zoom_wedges=np.stack(wedges),
|
||||
zoom_region=region,
|
||||
dc_center=dc["center"], dc_radius=dc["radius_m"],
|
||||
mec_center=mec["center"], mec_radius=mec["radius_m"],
|
||||
view_center=view_c, half_overview=float(half),
|
||||
zoom_center=zoom_c, zoom_half=float(zoom),
|
||||
legend1=["前向角域 W(s, θ ± 1°)", "定位区域 P"],
|
||||
legend2=["直径圆", "最小包围圆 MEC"])
|
||||
return data, Path(src)
|
||||
|
||||
|
||||
def data_geo_q1_counterexample() -> dict[str, Any]:
|
||||
src = ROOT / "examples" / "q1" / "triangle.json"
|
||||
doc = json.loads(src.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, dc, mec = sol["vertices"], sol["dc"], sol["mec"]
|
||||
zoom_c = verts.mean(axis=0)
|
||||
zoom = max(1.9 * float(np.abs(verts - zoom_c).max()) + mec["radius_m"], 45.0)
|
||||
half, view_c = overview_window(np.array([[s[0], s[1]] for s in stations]),
|
||||
verts, dc, mec)
|
||||
wedges = [wedge_polygon((sx, sy), b, hd, 3600.0) for sx, sy, b, hd in stations]
|
||||
region = np.array([[zoom_c[0] - zoom, zoom_c[1] - zoom],
|
||||
[zoom_c[0] + zoom, zoom_c[1] - zoom],
|
||||
[zoom_c[0] + zoom, zoom_c[1] + zoom],
|
||||
[zoom_c[0] - zoom, zoom_c[1] + zoom]], dtype=float)
|
||||
for sx, sy, b, hd in stations:
|
||||
region = clip_wedge(region, (sx, sy), b, hd)
|
||||
return dict(
|
||||
stations=np.array([[s[0], s[1]] for s in stations]),
|
||||
station_labels=[f"s{i + 1}" for i in range(len(stations))],
|
||||
wedges=np.stack(wedges), zoom_wedges=np.stack(wedges),
|
||||
zoom_region=region,
|
||||
dc_center=dc["center"], dc_radius=dc["radius_m"],
|
||||
mec_center=mec["center"], mec_radius=mec["radius_m"],
|
||||
view_center=view_c, half_overview=float(half),
|
||||
zoom_center=zoom_c, zoom_half=float(zoom),
|
||||
legend1=["前向角域 W(s, θ ± 1°)", "定位区域 P"],
|
||||
legend2=["直径圆", "最小包围圆 MEC"])
|
||||
|
||||
|
||||
def data_geo_q2() -> dict[str, Any]:
|
||||
a = 1500.0 / (2 * math.cos(math.radians(1.0)))
|
||||
safe = R_EFF - a
|
||||
c = np.array([a * math.cos(math.radians(30.0)), a * math.sin(math.radians(30.0))])
|
||||
return dict(omega_r=OMEGA_R, wedge=wedge_polygon((0.0, 0.0), 30.0, 1.0, 1500.0),
|
||||
a=float(a), safe=float(safe), c=c, half=1900.0,
|
||||
legend=["前向角域 W(s1)", "截断角域外接圆 B(c, a)",
|
||||
"安全候选盘 B(c, 1000 − a)"])
|
||||
|
||||
|
||||
def data_geo_q3() -> dict[str, Any]:
|
||||
a = RING_RADIUS_M
|
||||
stations = np.array([[0.0, 0.0]] + [[a * math.cos(k * math.pi / 3),
|
||||
a * math.sin(k * math.pi / 3)] for k in range(6)])
|
||||
return dict(omega_r=OMEGA_R, r_eff=R_EFF,
|
||||
stations=stations,
|
||||
worst=np.array([0.0, -SEVEN_R]),
|
||||
half=float(RING_RADIUS_M + R_EFF + 90.0),
|
||||
legend=["区域 Ω", "1000 m 接收盘", "覆盖站点", "最差点"])
|
||||
|
||||
|
||||
def data_geo_q4() -> dict[str, Any]:
|
||||
plan = load_plan("q4_mincover_v2")
|
||||
pts = np.array(plan["points"], dtype=float)
|
||||
theta = np.linspace(-math.pi / 2, math.pi / 2, 60)
|
||||
half_disk = np.vstack([[0.0, 0.5],
|
||||
np.column_stack([0.85 * np.cos(theta),
|
||||
0.5 + 0.85 * np.sin(theta)])])
|
||||
half = float(max(OMEGA_R, np.abs(pts).max()) * 1.06)
|
||||
return dict(omega_r=OMEGA_R, points=pts, half=half, half_disk=half_disk,
|
||||
radius_label="覆盖半径 1000 m",
|
||||
heading_label="前向半盘 ±90°(朝向未知)",
|
||||
legend=["区域 Ω", "31 点最小命中集"])
|
||||
|
||||
|
||||
def data_replay(run: dict[str, Any], *, directional: bool) -> dict[str, Any]:
|
||||
tl = decompose(run)
|
||||
rows = tl["rows"]
|
||||
truth = run["truth"]
|
||||
solved = set(solved_channels(run))
|
||||
truth_pts, truth_solved, truth_labels = [], [], []
|
||||
for src in truth:
|
||||
ch = int(src["channel"])
|
||||
truth_pts.append([src["x"], src["y"]])
|
||||
truth_solved.append(1.0 if ch in solved else 0.0)
|
||||
truth_labels.append(f"ch{ch}")
|
||||
# 定向源:朝向箭头(长度取该源有效接收半径)+ ±90° 前向半盘
|
||||
headings, half_disks = [], []
|
||||
for src in truth:
|
||||
if not (directional and src.get("directional")):
|
||||
continue
|
||||
r = float(src.get("r_eff_m") or R_EFF)
|
||||
ang = math.radians(float(src.get("heading_deg") or 0.0))
|
||||
headings.append([src["x"], src["y"], r * math.cos(ang), r * math.sin(ang)])
|
||||
arc = ang + np.linspace(-math.pi / 2, math.pi / 2, 49)
|
||||
half_disks.append(np.vstack([[src["x"], src["y"]],
|
||||
np.column_stack([src["x"] + r * np.cos(arc),
|
||||
src["y"] + r * np.sin(arc)])]))
|
||||
half_disk_arr = (np.stack(half_disks) if half_disks
|
||||
else np.zeros((0, 50, 2), dtype=float))
|
||||
path = [(0.0, 0.0)]
|
||||
rays, clears, marks, events = [], [], [], []
|
||||
for act, row in zip(run["actions"], rows):
|
||||
if act["path"] == "/measure" and row["hit"]:
|
||||
bearing = math.radians(float(act["response"].get("svd_deg") or 0.0))
|
||||
rays.append([row["x"], row["y"], 220 * math.cos(bearing),
|
||||
220 * math.sin(bearing)])
|
||||
if act["path"] in ("/measure", "/clear") and row["x"] is not None:
|
||||
if (row["x"], row["y"]) != path[-1]:
|
||||
path.append((row["x"], row["y"]))
|
||||
if act["path"] == "/clear":
|
||||
clears.append([row["x"], row["y"], 1.0 if row["cleared"] else 0.0])
|
||||
marks.append([row["virtual_s"], 1.0 if row["hit"] else 0.0,
|
||||
1.0 if act["path"] == "/clear" and row["cleared"] else 0.0])
|
||||
if len(events) < 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 '未发现'}"
|
||||
else:
|
||||
detail = "启动会话" if act["path"] == "/enter" else "退出"
|
||||
events.append([act["path"], detail, f"{row['virtual_s']:.0f}"])
|
||||
legend = ["移动轨迹", "检测示向度", "已清除频道", "未清除频道"]
|
||||
if directional:
|
||||
legend = ["移动轨迹", "检测示向度", "定向源前向半盘 (±90°)",
|
||||
"已清除频道", "未清除频道"]
|
||||
# 世界视图窗口:容纳 Ω、轨迹、射线与定向半盘,避免半盘被画到框外
|
||||
geom = [np.asarray(truth_pts, dtype=float), np.asarray(path, dtype=float)]
|
||||
if rays:
|
||||
rays_arr = np.asarray(rays, dtype=float)
|
||||
geom.append(rays_arr[:, :2] + rays_arr[:, 2:])
|
||||
if half_disk_arr.size:
|
||||
geom.append(half_disk_arr.reshape(-1, 2))
|
||||
half = float(max(OMEGA_R, np.abs(np.vstack(geom)).max()) * 1.06)
|
||||
return dict(
|
||||
omega_r=OMEGA_R, half=half,
|
||||
out_png=("replay-webui-q4-directional.png" if directional
|
||||
else "replay-webui-q3-omnidirectional.png"),
|
||||
truth=np.array(truth_pts, dtype=float),
|
||||
truth_solved=np.array(truth_solved, dtype=float).reshape(-1, 1),
|
||||
truth_labels=truth_labels,
|
||||
headings=np.array(headings, dtype=float).reshape(-1, 4),
|
||||
half_disks=half_disk_arr,
|
||||
path=np.array(path, dtype=float),
|
||||
rays=np.array(rays, dtype=float).reshape(-1, 4),
|
||||
clears=np.array(clears, dtype=float).reshape(-1, 3),
|
||||
marks=np.array(marks, dtype=float).reshape(-1, 3),
|
||||
events=events, total_s=float(tl["virtual_s"]), legend=legend)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 产出
|
||||
# ---------------------------------------------------------------------------
|
||||
def _as_cell(values: Any) -> np.ndarray:
|
||||
"""字符串列表 → object 数组(MATLAB cell);纯数值嵌套列表 → 数值矩阵。"""
|
||||
if isinstance(values, str):
|
||||
return np.array([values], dtype=object)
|
||||
if isinstance(values, (list, tuple)):
|
||||
if values and all(isinstance(v, (list, tuple)) and
|
||||
all(isinstance(x, (int, float)) for x in v)
|
||||
for v in values):
|
||||
return np.asarray(values, dtype=float)
|
||||
return np.array([_as_cell(v) if isinstance(v, (list, tuple)) else v
|
||||
for v in values], dtype=object)
|
||||
return np.asarray(values)
|
||||
|
||||
|
||||
def write_mat(name: str, data: dict[str, Any]) -> None:
|
||||
"""写 .mat;字符串字段转 object 数组,MATLAB 侧按 cell 读取。"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def wrap(value: Any, key: str) -> Any: # noqa: ARG001
|
||||
if isinstance(value, (str, list, tuple)):
|
||||
return _as_cell(value)
|
||||
return value
|
||||
|
||||
payload = {key: wrap(value, key) for key, value in data.items()}
|
||||
savemat(DATA_DIR / f"{name}.mat", payload, do_compression=True,
|
||||
long_field_names=True)
|
||||
|
||||
|
||||
def prepare(only: Sequence[str] | None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""写全部 .mat 数据,返回每张图的数据来源。"""
|
||||
want = set(only) if only else None
|
||||
runs = discover_runs()
|
||||
q3 = pick_run(runs, "q3", 10) if runs else None
|
||||
q4 = (pick_case(runs, Q4_REPLAY_CASE) if runs else None) or \
|
||||
(pick_run(runs, "q4", 12) if runs else None)
|
||||
sources: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def need(stem: str) -> bool:
|
||||
return want is None or stem in want
|
||||
|
||||
build: list[tuple[str, str, Callable[[], dict[str, Any]]]] = [
|
||||
("flow-overall-architecture", "flow_overall", data_flow_overall),
|
||||
("steps-state-machine", "steps_state_machine", data_steps_state_machine),
|
||||
("timing-attachment-example-105-111-194-199", "timing_attachment",
|
||||
data_timing_attachment),
|
||||
("geo-q1-diameter-circle-counterexample", "geo_q1_counterexample",
|
||||
data_geo_q1_counterexample),
|
||||
("geo-q2-guaranteed-reception-disk", "geo_q2", data_geo_q2),
|
||||
("geo-q3-seven-point-coverage", "geo_q3", data_geo_q3),
|
||||
("geo-q4-minimum-cover-31-points", "geo_q4", data_geo_q4),
|
||||
]
|
||||
for stem, name, maker in build:
|
||||
if not need(stem):
|
||||
continue
|
||||
write_mat(name, maker())
|
||||
sources[stem] = [source(p) for p in SOURCES.get(stem, [])]
|
||||
|
||||
if need("geo-q1-convex-intersection-zoom"):
|
||||
data, src = data_geo_q1_meetup()
|
||||
write_mat("geo_q1_meetup", data)
|
||||
sources["geo-q1-convex-intersection-zoom"] = [source(src)]
|
||||
if q3:
|
||||
if need("timing-measured-q3-10-sources"):
|
||||
write_mat("timing_measured", data_timing_measured(q3))
|
||||
sources["timing-measured-q3-10-sources"] = [
|
||||
source(q3["source"], case_code=q3["case_code"], module=q3["module"])]
|
||||
if need("replay-webui-q3-omnidirectional"):
|
||||
write_mat("replay_q3", data_replay(q3, directional=False))
|
||||
sources["replay-webui-q3-omnidirectional"] = [
|
||||
source(q3["source"], case_code=q3["case_code"])]
|
||||
if q4 and need("replay-webui-q4-directional"):
|
||||
write_mat("replay_q4", data_replay(q4, directional=True))
|
||||
sources["replay-webui-q4-directional"] = [
|
||||
source(q4["source"], case_code=q4["case_code"])]
|
||||
return sources
|
||||
|
||||
|
||||
def run_matlab(only: Sequence[str] | None, matlab_bin: Path) -> None:
|
||||
"""逐张调用 MATLAB 出图。
|
||||
|
||||
本机实测:同一 MATLAB 批处理会话里渲染第二张图时图形子系统会崩溃,
|
||||
因此每张图单独起一个会话(每次约数秒),并显式 exit。
|
||||
"""
|
||||
if not matlab_bin.is_file():
|
||||
raise SystemExit(f"未找到 MATLAB:{matlab_bin}")
|
||||
want = set(only) if only else None
|
||||
log = MATLAB_DIR / "matlab_run.log"
|
||||
lines: list[str] = []
|
||||
for stem, fn, name in FIGURES:
|
||||
if want and stem not in want:
|
||||
continue
|
||||
call = f"{fn}('{name}')" if fn == "fig_replay" else f"{fn}()"
|
||||
script = (f"cd('{MATLAB_DIR}'); addpath('{MATLAB_DIR}'); "
|
||||
f"{call}; exit")
|
||||
proc = subprocess.run([str(matlab_bin), "-batch", script],
|
||||
capture_output=True, text=True, check=False)
|
||||
ok = (OUT_DIR / f"{stem}.png").is_file()
|
||||
lines.append(f"{'OK ' if ok else 'FAIL'} {stem}")
|
||||
if not ok:
|
||||
tail = [ln for ln in (proc.stdout + proc.stderr).splitlines()
|
||||
if ln.strip() and "Keyboard" not in ln and "TISFile" not in ln]
|
||||
lines.extend(f" {ln}" for ln in tail[-6:])
|
||||
log.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def finish(only: Sequence[str] | None, sources: dict[str, list[dict[str, Any]]]) -> None:
|
||||
"""删掉历史遗留的 SVG,写 manifest。"""
|
||||
for stale in OUT_DIR.glob("*.svg"):
|
||||
stale.unlink()
|
||||
want = set(only) if only else None
|
||||
entries = []
|
||||
for stem, fn, _name in FIGURES:
|
||||
if want and stem not in want:
|
||||
continue
|
||||
png = OUT_DIR / f"{stem}.png"
|
||||
entry = {"stem": stem, "png": str(png.relative_to(ROOT)),
|
||||
"matlab": f"scripts/matlab/{fn}.m",
|
||||
"sources": sources.get(stem, [])}
|
||||
if not png.is_file():
|
||||
entry["missing"] = True
|
||||
entries.append(entry)
|
||||
manifest = {
|
||||
"generator": "scripts/build_figure_data.py(数据)+ scripts/matlab/*.m(MATLAB 出图)",
|
||||
"command": "uv run python scripts/build_figure_data.py"
|
||||
+ (f" --only {','.join(only)}" if only else ""),
|
||||
"manual": "MATLAB 中 cd scripts/matlab 后执行 run_all_figures",
|
||||
"figures": entries,
|
||||
}
|
||||
(OUT_DIR / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--only", default="", help="逗号分隔的图名子集(见 --list)")
|
||||
parser.add_argument("--prepare", action="store_true", help="只写 .mat 数据,不调 MATLAB")
|
||||
parser.add_argument("--list", action="store_true", help="列出全部图名")
|
||||
parser.add_argument("--matlab", default=str(MATLAB_BIN), help="matlab 可执行文件路径")
|
||||
args = parser.parse_args()
|
||||
names = [stem for stem, _fn, _name in FIGURES]
|
||||
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 set(names)]
|
||||
if unknown:
|
||||
parser.error(f"未知图名 {unknown}")
|
||||
|
||||
sources = prepare(only or None)
|
||||
if args.prepare:
|
||||
print(f"[fig] 已写入 {len(list(DATA_DIR.glob('*.mat')))} 个 .mat → "
|
||||
f"{DATA_DIR.relative_to(ROOT)}")
|
||||
return 0
|
||||
targets = only or names
|
||||
for stem in targets: # 先清掉旧文件:只认本次生成的
|
||||
(OUT_DIR / f"{stem}.png").unlink(missing_ok=True)
|
||||
run_matlab(only or None, Path(args.matlab))
|
||||
finish(only or None, sources)
|
||||
missing = []
|
||||
for stem in targets:
|
||||
if (OUT_DIR / f"{stem}.png").is_file():
|
||||
print(f"[fig] OK {stem}")
|
||||
else:
|
||||
missing.append(stem)
|
||||
print(f"[fig] 缺失 {stem}")
|
||||
print(f"[fig] manifest → {(OUT_DIR / 'manifest.json').relative_to(ROOT)}")
|
||||
if missing:
|
||||
print(f"[fig] {len(missing)} 张未生成:{', '.join(missing)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
function fig_flow_overall()
|
||||
%FIG_FLOW_OVERALL 四层主线:题面抽象(L0)、在线三层(L1)、四问主线(L2)、三条闭环(L3)。
|
||||
% 仅保留分层框图与层标签,图内不放任何说明性文字。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'flow_overall');
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
ax = axes(fig, 'Position', [0.02 0.06 0.96 0.90], ...
|
||||
'XLim', [0 100], 'YLim', [0 100], 'Visible', 'off');
|
||||
hold(ax, 'on');
|
||||
|
||||
n = numel(D.tags);
|
||||
x_tag = 7; % 层标签中心
|
||||
x_lo = 15; x_hi = 99; % 框体水平范围
|
||||
box_gap = 2.2; % 框间距(放箭头)
|
||||
pad = 1.4; % 框内左右留白
|
||||
band_h = 14; % 层高
|
||||
band_gap = 6.5;
|
||||
y_top = 96;
|
||||
|
||||
% 统一字号:逐行按量得的文本宽度试算,保证一排框加间距放得下
|
||||
fs = ax.FontSize;
|
||||
while fs > 8
|
||||
need_max = 0;
|
||||
for b = 1:n
|
||||
labels = D.labels{b};
|
||||
m = numel(labels);
|
||||
need = sum(cellfun(@(s) H.text_width(ax, s, fs), labels)) ...
|
||||
+ 2 * pad * m + box_gap * (m - 1);
|
||||
need_max = max(need_max, need);
|
||||
end
|
||||
if need_max <= x_hi - x_lo
|
||||
break;
|
||||
end
|
||||
fs = fs - 0.5;
|
||||
end
|
||||
|
||||
for b = 1:n
|
||||
labels = D.labels{b};
|
||||
m = numel(labels);
|
||||
tw = cellfun(@(s) H.text_width(ax, s, fs), labels);
|
||||
avail = (x_hi - x_lo) - box_gap * (m - 1);
|
||||
w = (tw + 2 * pad) * avail / sum(tw + 2 * pad);
|
||||
y = y_top - band_h - (b - 1) * (band_h + band_gap);
|
||||
color = pick_color(S, D.colors{b});
|
||||
text(ax, x_tag, y + band_h / 2, D.tags{b}, 'FontSize', fs + 1, ...
|
||||
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle');
|
||||
x = x_lo;
|
||||
for i = 1:m
|
||||
rectangle(ax, 'Position', [x, y, w(i), band_h], 'FaceColor', 'w', ...
|
||||
'EdgeColor', color, 'LineWidth', 1.1);
|
||||
text(ax, x + w(i) / 2, y + band_h / 2, labels{i}, 'FontSize', fs, ...
|
||||
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ...
|
||||
'Interpreter', 'none');
|
||||
if i < m
|
||||
H.arrow(ax, [x + w(i) + 0.25, y + band_h / 2], ...
|
||||
[x + w(i) + box_gap - 0.25, y + band_h / 2], color);
|
||||
end
|
||||
x = x + w(i) + box_gap;
|
||||
end
|
||||
end
|
||||
hold(ax, 'off');
|
||||
fig_save(fig, S, 'flow-overall-architecture.png');
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
function fig_geo_q1_counterexample()
|
||||
%FIG_GEO_Q1_COUNTEREXAMPLE 问题一:等边三角形布站时直径圆不覆盖定位区域的反例。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'geo_q1_counterexample');
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
|
||||
|
||||
% 左:角域交会总览
|
||||
ax1 = nexttile(t);
|
||||
hold(ax1, 'on');
|
||||
hw = gobjects(0);
|
||||
for i = 1:size(D.wedges, 1)
|
||||
hw = H.wedge(ax1, squeeze(D.wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
|
||||
end
|
||||
hr = plot(ax1, D.zoom_center(1), D.zoom_center(2), 's', 'MarkerSize', 6, ...
|
||||
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
|
||||
plot(ax1, D.stations(:, 1), D.stations(:, 2), 'o', 'MarkerSize', 6, ...
|
||||
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
|
||||
H.label_points(ax1, D.stations, D.station_labels, pick_color(S, 'accent'));
|
||||
hold(ax1, 'off');
|
||||
axis(ax1, 'equal');
|
||||
xlim(ax1, D.view_center(1) + [-D.half_overview, D.half_overview]);
|
||||
ylim(ax1, D.view_center(2) + [-D.half_overview, D.half_overview]);
|
||||
xlabel(ax1, 'x / m');
|
||||
ylabel(ax1, 'y / m');
|
||||
legend(ax1, [hw(1), hr], D.legend1, 'Location', 'southoutside', ...
|
||||
'Orientation', 'horizontal');
|
||||
|
||||
% 右:定位区域放大
|
||||
ax2 = nexttile(t);
|
||||
hold(ax2, 'on');
|
||||
for i = 1:size(D.zoom_wedges, 1)
|
||||
H.wedge(ax2, squeeze(D.zoom_wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
|
||||
end
|
||||
H.poly(ax2, D.zoom_region, pick_color(S, 'accent'), 1, 'none', 0.5);
|
||||
hd = H.circle(ax2, D.dc_center, D.dc_radius, pick_color(S, 'violet'), 1.2, '--');
|
||||
hm = H.circle(ax2, D.mec_center, D.mec_radius, pick_color(S, 'success'), 1.4, '-');
|
||||
H.marker(ax2, D.dc_center, pick_color(S, 'violet'), 5, true);
|
||||
H.marker(ax2, D.mec_center, pick_color(S, 'success'), 5, true);
|
||||
hold(ax2, 'off');
|
||||
axis(ax2, 'equal');
|
||||
xlim(ax2, D.zoom_center(1) + [-D.zoom_half, D.zoom_half]);
|
||||
ylim(ax2, D.zoom_center(2) + [-D.zoom_half, D.zoom_half]);
|
||||
xlabel(ax2, 'x / m');
|
||||
ylabel(ax2, 'y / m');
|
||||
legend(ax2, [hd, hm], D.legend2, 'Location', 'southoutside', ...
|
||||
'Orientation', 'horizontal');
|
||||
|
||||
fig_save(fig, S, 'geo-q1-diameter-circle-counterexample.png');
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
function fig_geo_q1_meetup()
|
||||
%FIG_GEO_Q1_MEETUP 问题一:角域交会总览与定位区域放大(直径圆、最小包围圆)。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'geo_q1_meetup');
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
|
||||
|
||||
% 左:角域交会总览(交会区域在总览里只是一个点,放大后见右图)
|
||||
ax1 = nexttile(t);
|
||||
hold(ax1, 'on');
|
||||
hw = gobjects(0);
|
||||
for i = 1:size(D.wedges, 1)
|
||||
hw = H.wedge(ax1, squeeze(D.wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
|
||||
end
|
||||
hr = plot(ax1, D.zoom_center(1), D.zoom_center(2), 's', 'MarkerSize', 6, ...
|
||||
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
|
||||
plot(ax1, D.stations(:, 1), D.stations(:, 2), 'o', 'MarkerSize', 6, ...
|
||||
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
|
||||
H.label_points(ax1, D.stations, D.station_labels, pick_color(S, 'accent'));
|
||||
hold(ax1, 'off');
|
||||
axis(ax1, 'equal');
|
||||
xlim(ax1, D.view_center(1) + [-D.half_overview, D.half_overview]);
|
||||
ylim(ax1, D.view_center(2) + [-D.half_overview, D.half_overview]);
|
||||
xlabel(ax1, 'x / m');
|
||||
ylabel(ax1, 'y / m');
|
||||
legend(ax1, [hw(1), hr], D.legend1, 'Location', 'southoutside', ...
|
||||
'Orientation', 'horizontal');
|
||||
|
||||
% 右:定位区域放大
|
||||
ax2 = nexttile(t);
|
||||
hold(ax2, 'on');
|
||||
for i = 1:size(D.zoom_wedges, 1)
|
||||
H.wedge(ax2, squeeze(D.zoom_wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
|
||||
end
|
||||
H.poly(ax2, D.zoom_region, pick_color(S, 'accent'), 1, 'none', 0.5);
|
||||
hd = H.circle(ax2, D.dc_center, D.dc_radius, pick_color(S, 'violet'), 1.2, '--');
|
||||
hm = H.circle(ax2, D.mec_center, D.mec_radius, pick_color(S, 'success'), 1.4, '-');
|
||||
H.marker(ax2, D.dc_center, pick_color(S, 'violet'), 5, true);
|
||||
H.marker(ax2, D.mec_center, pick_color(S, 'success'), 5, true);
|
||||
hold(ax2, 'off');
|
||||
axis(ax2, 'equal');
|
||||
xlim(ax2, D.zoom_center(1) + [-D.zoom_half, D.zoom_half]);
|
||||
ylim(ax2, D.zoom_center(2) + [-D.zoom_half, D.zoom_half]);
|
||||
xlabel(ax2, 'x / m');
|
||||
ylabel(ax2, 'y / m');
|
||||
legend(ax2, [hd, hm], D.legend2, 'Location', 'southoutside', ...
|
||||
'Orientation', 'horizontal');
|
||||
|
||||
fig_save(fig, S, 'geo-q1-convex-intersection-zoom.png');
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
function fig_geo_q2_safe_disk()
|
||||
%FIG_GEO_Q2_SAFE_DISK 问题二:截断角域外接圆与保证接收的安全候选盘。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'geo_q2');
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
|
||||
|
||||
% 左:真实几何(Ω 内的角域与两个候选圆)
|
||||
ax1 = nexttile(t);
|
||||
hold(ax1, 'on');
|
||||
H.circle(ax1, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
|
||||
hw = H.wedge(ax1, D.wedge, pick_color(S, 'accent'), 0.25);
|
||||
hd = H.circle(ax1, D.c, D.a, pick_color(S, 'violet'), 1.2, '--');
|
||||
hs = H.disk(ax1, D.c, D.safe, pick_color(S, 'success'), 0.30, '-');
|
||||
H.marker(ax1, [0 0], pick_color(S, 'accent'), 6, true);
|
||||
H.marker(ax1, D.c, pick_color(S, 'violet'), 5, true);
|
||||
H.label_points(ax1, [0 0], {'s1'}, pick_color(S, 'accent'));
|
||||
H.label_points(ax1, D.c, {'c'}, pick_color(S, 'violet'));
|
||||
hold(ax1, 'off');
|
||||
axis(ax1, 'equal');
|
||||
xlim(ax1, [-D.half, D.half]);
|
||||
ylim(ax1, [-D.half, D.half]);
|
||||
xlabel(ax1, 'x / m');
|
||||
ylabel(ax1, 'y / m');
|
||||
legend(ax1, [hw, hd, hs], D.legend, 'Location', 'southoutside', ...
|
||||
'Orientation', 'horizontal');
|
||||
|
||||
% 右:半径分解 a + (1000 − a) = 1000 m
|
||||
ax2 = nexttile(t);
|
||||
hold(ax2, 'on');
|
||||
H.circle(ax2, [0 0], D.a, pick_color(S, 'violet'), 1.2, '--');
|
||||
H.disk(ax2, [0 0], D.safe, pick_color(S, 'success'), 0.30, '-');
|
||||
H.marker(ax2, [0 0], 'k', 5, true);
|
||||
ang = deg2rad(35);
|
||||
p_out = D.a * [cos(ang), sin(ang)];
|
||||
p_in = D.safe * [cos(-ang), sin(-ang)];
|
||||
H.arrow(ax2, [0 0], p_out, pick_color(S, 'violet'));
|
||||
H.arrow(ax2, [0 0], p_in, pick_color(S, 'success'));
|
||||
text(ax2, p_out(1) * 1.06, p_out(2) * 1.12, sprintf('a = %.1f m', D.a), ...
|
||||
'Color', pick_color(S, 'violet'), 'Interpreter', 'none');
|
||||
text(ax2, p_in(1) * 1.04, p_in(2) * 1.30, sprintf('1000 − a = %.1f m', D.safe), ...
|
||||
'Color', pick_color(S, 'success'), 'HorizontalAlignment', 'right', ...
|
||||
'Interpreter', 'none');
|
||||
text(ax2, -D.safe * 0.45, 0, 'c', 'Color', 'k', ...
|
||||
'HorizontalAlignment', 'right', 'VerticalAlignment', 'middle');
|
||||
hold(ax2, 'off');
|
||||
axis(ax2, 'equal');
|
||||
xlim(ax2, [-D.a * 1.15, D.a * 1.75]);
|
||||
ylim(ax2, [-D.a * 1.05, D.a * 1.05]);
|
||||
axis(ax2, 'off');
|
||||
|
||||
fig_save(fig, S, 'geo-q2-guaranteed-reception-disk.png');
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
function fig_geo_q3_coverage()
|
||||
%FIG_GEO_Q3_COVERAGE 问题三:七个 1000 m 接收盘覆盖区域 Ω。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'geo_q3');
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
ax = axes(fig);
|
||||
hold(ax, 'on');
|
||||
|
||||
hd = gobjects(0);
|
||||
for i = 1:size(D.stations, 1)
|
||||
hd = H.disk(ax, D.stations(i, :), D.r_eff, pick_color(S, 'success'), 0.18, '-');
|
||||
end
|
||||
hw = H.circle(ax, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
|
||||
hs = plot(ax, D.stations(:, 1), D.stations(:, 2), 'o', 'MarkerSize', 6, ...
|
||||
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
|
||||
hx = H.marker(ax, D.worst, 'k', 8, false);
|
||||
hold(ax, 'off');
|
||||
axis(ax, 'equal');
|
||||
xlim(ax, [-D.half, D.half]);
|
||||
ylim(ax, [-D.half, D.half]);
|
||||
xlabel(ax, 'x / m');
|
||||
ylabel(ax, 'y / m');
|
||||
legend(ax, [hw, hd(1), hs, hx], D.legend, 'Location', 'southoutside', ...
|
||||
'Orientation', 'horizontal');
|
||||
|
||||
fig_save(fig, S, 'geo-q3-seven-point-coverage.png');
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
function fig_geo_q4_mincover()
|
||||
%FIG_GEO_Q4_MINCOVER 问题四:31 点最小命中集与前向半盘覆盖语义。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'geo_q4');
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
|
||||
|
||||
% 左:Ω 内的 31 个覆盖点
|
||||
ax1 = nexttile(t);
|
||||
hold(ax1, 'on');
|
||||
hw = H.circle(ax1, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
|
||||
hp = plot(ax1, D.points(:, 1), D.points(:, 2), 'o', 'MarkerSize', 5, ...
|
||||
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
|
||||
hold(ax1, 'off');
|
||||
axis(ax1, 'equal');
|
||||
xlim(ax1, [-D.half, D.half]);
|
||||
ylim(ax1, [-D.half, D.half]);
|
||||
xlabel(ax1, 'x / m');
|
||||
ylabel(ax1, 'y / m');
|
||||
legend(ax1, [hw, hp], D.legend, 'Location', 'southoutside', ...
|
||||
'Orientation', 'horizontal');
|
||||
|
||||
% 右:前向半盘(朝向未知,±90°)
|
||||
ax2 = nexttile(t);
|
||||
hold(ax2, 'on');
|
||||
H.poly(ax2, D.half_disk, pick_color(S, 'accent'), 0.30, 'none', 0.5);
|
||||
H.circle(ax2, [0 0.5], 0.85, pick_color(S, 'muted'), 0.9, '-');
|
||||
plot(ax2, [0 0], [0.5 - 0.85, 0.5 + 0.85], '--', ...
|
||||
'Color', pick_color(S, 'accent'), 'LineWidth', 1.0);
|
||||
H.arrow(ax2, [0 0.5], [0.85 0.5], pick_color(S, 'accent'));
|
||||
text(ax2, 0.425, 0.30, D.radius_label, 'HorizontalAlignment', 'center', ...
|
||||
'Interpreter', 'none');
|
||||
text(ax2, 0.0, 1.55, D.heading_label, 'HorizontalAlignment', 'center', ...
|
||||
'Interpreter', 'none');
|
||||
hold(ax2, 'off');
|
||||
axis(ax2, 'equal');
|
||||
xlim(ax2, [-1.15 1.35]);
|
||||
ylim(ax2, [-0.55 1.75]);
|
||||
axis(ax2, 'off');
|
||||
|
||||
fig_save(fig, S, 'geo-q4-minimum-cover-31-points.png');
|
||||
end
|
||||
@@ -0,0 +1,109 @@
|
||||
function fig_replay(name)
|
||||
%FIG_REPLAY 回放画布(点运动图):世界视图 + 事件流水 + 虚拟时间轴。
|
||||
% name: 'replay_q3' 或 'replay_q4',对应 docs/figures/.data/<name>.mat。
|
||||
% 本图是唯一保留自定义画布版式的图:三个区互不重叠,图例置于世界视图下方。
|
||||
|
||||
if nargin < 1
|
||||
name = 'replay_q3';
|
||||
end
|
||||
S = fig_style();
|
||||
D = load_data(S, name);
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off', 'Position', [100 100 1040 660]);
|
||||
|
||||
c_path = [0.35 0.35 0.35];
|
||||
c_ray = pick_color(S, 'primary');
|
||||
c_solved = pick_color(S, 'success');
|
||||
c_unsolv = pick_color(S, 'accent');
|
||||
c_head = pick_color(S, 'violet');
|
||||
|
||||
% ---------------- 世界视图 ----------------
|
||||
ax = axes(fig, 'Position', [0.075 0.40 0.49 0.56]);
|
||||
hold(ax, 'on');
|
||||
H.circle(ax, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
|
||||
h_path = plot(ax, D.path(:, 1), D.path(:, 2), 'Color', c_path, 'LineWidth', 0.9);
|
||||
h_ray = plot(ax, nan, nan, 'Color', c_ray, 'LineWidth', 0.8);
|
||||
for i = 1:size(D.rays, 1)
|
||||
p0 = D.rays(i, 1:2);
|
||||
p1 = p0 + D.rays(i, 3:4);
|
||||
plot(ax, [p0(1) p1(1)], [p0(2) p1(2)], 'Color', c_ray, 'LineWidth', 0.8);
|
||||
end
|
||||
h_disk = gobjects(0);
|
||||
for i = 1:size(D.half_disks, 1)
|
||||
disk = squeeze(D.half_disks(i, :, :));
|
||||
h_disk(end + 1) = patch(ax, 'XData', disk(:, 1), 'YData', disk(:, 2), ...
|
||||
'FaceColor', c_head, 'FaceAlpha', 0.10, ...
|
||||
'EdgeColor', c_head, 'LineWidth', 0.8); %#ok<AGROW>
|
||||
end
|
||||
for i = 1:size(D.headings, 1)
|
||||
p0 = D.headings(i, 1:2);
|
||||
p1 = p0 + D.headings(i, 3:4);
|
||||
H.arrow(ax, p0, p1, c_head);
|
||||
end
|
||||
solved = D.truth_solved > 0.5;
|
||||
h_solved = plot(ax, D.truth(solved, 1), D.truth(solved, 2), 'o', ...
|
||||
'MarkerSize', 6, 'MarkerFaceColor', c_solved, 'MarkerEdgeColor', 'none');
|
||||
h_unsolv = plot(ax, D.truth(~solved, 1), D.truth(~solved, 2), 'o', ...
|
||||
'MarkerSize', 6, 'MarkerFaceColor', c_unsolv, 'MarkerEdgeColor', 'none');
|
||||
label_color = repmat(c_unsolv, size(D.truth, 1), 1);
|
||||
label_color(solved, :) = repmat(c_solved, nnz(solved), 1);
|
||||
H.label_points(ax, D.truth, D.truth_labels, label_color);
|
||||
plot(ax, D.clears(D.clears(:, 3) > 0.5, 1), D.clears(D.clears(:, 3) > 0.5, 2), ...
|
||||
'x', 'MarkerSize', 7, 'LineWidth', 1.4, 'Color', c_solved);
|
||||
plot(ax, D.clears(D.clears(:, 3) <= 0.5, 1), D.clears(D.clears(:, 3) <= 0.5, 2), ...
|
||||
'o', 'MarkerSize', 6, 'MarkerEdgeColor', 'k', 'MarkerFaceColor', 'none');
|
||||
hold(ax, 'off');
|
||||
axis(ax, 'equal');
|
||||
xlim(ax, [-D.half, D.half]);
|
||||
ylim(ax, [-D.half, D.half]);
|
||||
xlabel(ax, 'x / m');
|
||||
ylabel(ax, 'y / m');
|
||||
hleg = [h_path, h_ray];
|
||||
if ~isempty(h_disk)
|
||||
hleg(end + 1) = h_disk(1);
|
||||
end
|
||||
hleg = [hleg, h_solved, h_unsolv];
|
||||
legend(ax, hleg, D.legend, 'Location', 'southoutside', 'Orientation', 'horizontal');
|
||||
|
||||
% ---------------- 事件流水 ----------------
|
||||
ex = axes(fig, 'Position', [0.625 0.40 0.345 0.56], ...
|
||||
'XLim', [0 1], 'YLim', [0 1], 'Visible', 'off');
|
||||
hold(ex, 'on');
|
||||
cols = [0.00 0.34 1.00];
|
||||
align = {'left', 'left', 'right'};
|
||||
head = {'动作', '参数', 't / s'};
|
||||
for j = 1:3
|
||||
text(ex, cols(j), 0.97, head{j}, 'FontWeight', 'bold', ...
|
||||
'HorizontalAlignment', align{j});
|
||||
end
|
||||
for i = 1:size(D.events, 1)
|
||||
y = 0.90 - (i - 1) * 0.088;
|
||||
for j = 1:3
|
||||
text(ex, cols(j), y, D.events{i, j}, 'HorizontalAlignment', align{j}, ...
|
||||
'Interpreter', 'none');
|
||||
end
|
||||
end
|
||||
hold(ex, 'off');
|
||||
|
||||
% ---------------- 虚拟时间轴 ----------------
|
||||
tx = axes(fig, 'Position', [0.075 0.155 0.90 0.125]);
|
||||
hold(tx, 'on');
|
||||
rectangle(tx, 'Position', [0 0.34 D.total_s 0.32], ...
|
||||
'FaceColor', pick_color(S, 'primary'), 'FaceAlpha', 0.12, ...
|
||||
'EdgeColor', 'none');
|
||||
for i = 1:size(D.marks, 1)
|
||||
t = D.marks(i, 1);
|
||||
if D.marks(i, 3) > 0.5
|
||||
plot(tx, [t t], [0.05 0.95], 'Color', c_solved, 'LineWidth', 1.0);
|
||||
elseif D.marks(i, 2) > 0.5
|
||||
plot(tx, [t t], [0.22 0.78], 'Color', c_unsolv, 'LineWidth', 0.8);
|
||||
end
|
||||
end
|
||||
hold(tx, 'off');
|
||||
xlim(tx, [0 D.total_s]);
|
||||
ylim(tx, [0 1]);
|
||||
set(tx, 'YTick', []);
|
||||
xlabel(tx, '虚拟时间 t / s');
|
||||
|
||||
fig_save(fig, S, D.out_png{1});
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
function fig_save(fig, S, name)
|
||||
%FIG_SAVE 导出白底 PNG(300 dpi)并关闭图形窗口。
|
||||
% 导出前关掉坐标轴工具条,避免交互按钮被画进图片。
|
||||
axs = findall(fig, 'Type', 'axes');
|
||||
for k = 1:numel(axs)
|
||||
axs(k).Toolbar.Visible = 'off';
|
||||
end
|
||||
exportgraphics(fig, fullfile(S.out_dir, name), 'Resolution', S.dpi, ...
|
||||
'BackgroundColor', 'white');
|
||||
close(fig);
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
function fig_steps_state_machine()
|
||||
%FIG_STEPS_STATE_MACHINE 会话状态机:IDLE → 倒计时 → OPEN → CLOSED。
|
||||
% 只画状态框与迁移箭头,图内不放任何说明性文字。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'steps_state_machine');
|
||||
H = geom_helpers();
|
||||
fig = figure('Color', 'w', 'Visible', 'off', 'Position', [100 100 900 240]);
|
||||
ax = axes(fig, 'Position', [0.02 0.10 0.96 0.80], ...
|
||||
'XLim', [0 100], 'YLim', [0 100], 'Visible', 'off');
|
||||
hold(ax, 'on');
|
||||
|
||||
n = numel(D.labels);
|
||||
w = 21;
|
||||
gap = 4.0;
|
||||
x0 = (100 - (n * w + (n - 1) * gap)) / 2;
|
||||
y0 = 25;
|
||||
h = 50;
|
||||
for i = 1:n
|
||||
x = x0 + (i - 1) * (w + gap);
|
||||
color = pick_color(S, D.edges{i});
|
||||
rectangle(ax, 'Position', [x, y0, w, h], 'FaceColor', 'w', ...
|
||||
'EdgeColor', color, 'LineWidth', 1.2);
|
||||
text(ax, x + w / 2, y0 + h / 2, D.labels{i}, ...
|
||||
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ...
|
||||
'Interpreter', 'none');
|
||||
if i < n
|
||||
H.arrow(ax, [x + w + 0.5, y0 + h / 2], ...
|
||||
[x + w + gap - 0.5, y0 + h / 2], color);
|
||||
end
|
||||
end
|
||||
hold(ax, 'off');
|
||||
fig_save(fig, S, 'steps-state-machine.png');
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
function S = fig_style()
|
||||
%FIG_STYLE 出图环境:目录、导出参数与 MATLAB 默认配色。
|
||||
% 除 fig_replay(回放画布,点运动图)外,各图一律使用 MATLAB 默认图形样式:
|
||||
% 默认配色、默认字体、默认坐标轴与图例;本函数不设置任何自定义样式。
|
||||
|
||||
root = fileparts(fileparts(fileparts(mfilename('fullpath'))));
|
||||
S.data_dir = fullfile(root, 'docs', 'figures', '.data');
|
||||
S.out_dir = fullfile(root, 'docs', 'figures');
|
||||
S.dpi = 300; % 论文插图导出分辨率
|
||||
S.c = get(groot, 'defaultAxesColorOrder'); % 默认配色(7×3)
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
function fig_timing_attachment()
|
||||
%FIG_TIMING_ATTACHMENT 附件计时示例:逐动作的耗时构成(堆叠条形)与单步合计。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'timing_attachment');
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
ax = axes(fig);
|
||||
|
||||
n = numel(D.labels);
|
||||
Y = [D.move(:), D.switch_s(:), D.action(:)];
|
||||
y = (n:-1:1)'; % 动作按先后从上到下排列
|
||||
barh(ax, y, Y, 'stacked');
|
||||
total = sum(Y, 2);
|
||||
xlim(ax, [0, max(total) * 1.18]);
|
||||
hold(ax, 'on');
|
||||
for i = 1:n
|
||||
if total(i) > 0
|
||||
text(ax, total(i) + max(total) * 0.02, y(i), ...
|
||||
sprintf('%.0f s', total(i)), 'VerticalAlignment', 'middle', ...
|
||||
'Interpreter', 'none');
|
||||
end
|
||||
end
|
||||
hold(ax, 'off');
|
||||
set(ax, 'YTick', 1:n, 'YTickLabel', flipud(D.labels(:)), ...
|
||||
'TickLabelInterpreter', 'none');
|
||||
xlabel(ax, D.xlabel);
|
||||
legend(ax, D.legend);
|
||||
fig_save(fig, S, 'timing-attachment-example-105-111-194-199.png');
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
function fig_timing_measured()
|
||||
%FIG_TIMING_MEASURED 实测一局:累计虚拟时间按动作类型的堆叠构成。
|
||||
|
||||
S = fig_style();
|
||||
D = load_data(S, 'timing_measured');
|
||||
fig = figure('Color', 'w', 'Visible', 'off');
|
||||
ax = axes(fig);
|
||||
|
||||
seq = D.seq(:);
|
||||
bands = [D.cum(:, 1), diff(D.cum, 1, 2)]; % area 对矩阵按列堆叠,这里给每层厚度
|
||||
h = area(ax, seq, bands, 'FaceAlpha', 1);
|
||||
xlim(ax, [seq(1), seq(end)]);
|
||||
xlabel(ax, D.xlabel);
|
||||
ylabel(ax, D.ylabel);
|
||||
legend(ax, h, D.legend, 'Location', 'northwest');
|
||||
fig_save(fig, S, 'timing-measured-q3-10-sources.png');
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
function H = geom_helpers()
|
||||
%GEOM_HELPERS 几何示意图共用的绘制函数。
|
||||
% 只使用 MATLAB 默认图形对象与默认配色,不做自定义样式。
|
||||
|
||||
H.circle = @draw_circle;
|
||||
H.wedge = @draw_wedge;
|
||||
H.disk = @draw_disk;
|
||||
H.poly = @draw_poly;
|
||||
H.marker = @draw_marker;
|
||||
H.arrow = @draw_arrow;
|
||||
H.label_points = @label_points;
|
||||
H.text_width = @text_width;
|
||||
end
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% 基本图元
|
||||
% ---------------------------------------------------------------------------
|
||||
function h = draw_circle(ax, center, radius, color, lw, style)
|
||||
t = linspace(0, 2 * pi, 360);
|
||||
h = plot(ax, center(1) + radius * cos(t), center(2) + radius * sin(t), ...
|
||||
'Color', color, 'LineWidth', lw, 'LineStyle', style);
|
||||
end
|
||||
|
||||
function h = draw_wedge(ax, poly, color, alpha)
|
||||
h = patch(ax, 'XData', poly(:, 1), 'YData', poly(:, 2), 'FaceColor', color, ...
|
||||
'FaceAlpha', alpha, 'EdgeColor', 'none');
|
||||
end
|
||||
|
||||
function h = draw_disk(ax, center, radius, color, alpha, edge_style)
|
||||
t = linspace(0, 2 * pi, 360);
|
||||
h = patch(ax, 'XData', center(1) + radius * cos(t), ...
|
||||
'YData', center(2) + radius * sin(t), ...
|
||||
'FaceColor', color, 'FaceAlpha', alpha, 'EdgeColor', color, ...
|
||||
'LineWidth', 0.6, 'LineStyle', edge_style);
|
||||
end
|
||||
|
||||
function h = draw_poly(ax, poly, face, alpha, edge, lw)
|
||||
h = patch(ax, 'XData', poly(:, 1), 'YData', poly(:, 2), 'FaceColor', face, ...
|
||||
'FaceAlpha', alpha, 'EdgeColor', edge, 'LineWidth', lw);
|
||||
end
|
||||
|
||||
function h = draw_marker(ax, p, color, size, filled)
|
||||
if filled
|
||||
h = plot(ax, p(1), p(2), 'o', 'MarkerSize', size, ...
|
||||
'MarkerFaceColor', color, 'MarkerEdgeColor', 'none');
|
||||
else
|
||||
h = plot(ax, p(1), p(2), 'o', 'MarkerSize', size, ...
|
||||
'MarkerFaceColor', 'none', 'MarkerEdgeColor', color, ...
|
||||
'LineWidth', 0.9);
|
||||
end
|
||||
end
|
||||
|
||||
function h = draw_arrow(ax, p0, p1, color)
|
||||
%DRAW_ARROW 数据坐标箭头:直线 + 实心三角箭头。
|
||||
% 箭头在"像素空间"里构造后换算回数据坐标,因此与坐标轴比例无关,
|
||||
% 也不依赖绘制时的 axes 位置(图例把坐标区挤小后箭头依然对准数据点)。
|
||||
h = plot(ax, [p0(1) p1(1)], [p0(2) p1(2)], 'Color', color, 'LineWidth', 0.9);
|
||||
d = [p1(1) - p0(1), p1(2) - p0(2)];
|
||||
[sx, sy] = px_scale(ax); % 每像素对应的数据单位
|
||||
v = [d(1) / sx, d(2) / sy]; % 像素空间向量
|
||||
len = hypot(v(1), v(2));
|
||||
if len < 1e-9
|
||||
return;
|
||||
end
|
||||
u = v / len;
|
||||
n = [-u(2), u(1)];
|
||||
head = min(len * 0.6, 10); % 头长(像素)
|
||||
base = v - u * head;
|
||||
tri = [v; base + n * head * 0.42; base - n * head * 0.42] .* [sx, sy];
|
||||
patch(ax, 'XData', p0(1) + tri(:, 1), 'YData', p0(2) + tri(:, 2), ...
|
||||
'FaceColor', color, 'EdgeColor', 'none');
|
||||
end
|
||||
|
||||
function [sx, sy] = px_scale(ax)
|
||||
pos = ax.Position;
|
||||
fpos = ancestor(ax, 'figure').Position;
|
||||
sx = diff(ax.XLim) / (pos(3) * fpos(3));
|
||||
sy = diff(ax.YLim) / (pos(4) * fpos(4));
|
||||
end
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% 标签自动避让
|
||||
% ---------------------------------------------------------------------------
|
||||
function label_points(ax, pts, labels, color)
|
||||
%LABEL_POINTS 在散点旁放置标签:按候选方位打分选出与已放标签、标记点都不重叠的位置。
|
||||
% color 可以是 1×3(所有标签同色)或 n×3(逐点配色)。
|
||||
if isempty(pts)
|
||||
return;
|
||||
end
|
||||
if size(color, 1) == 1
|
||||
color = repmat(color, numel(labels), 1);
|
||||
end
|
||||
xl = ax.XLim;
|
||||
yl = ax.YLim;
|
||||
gap = pixel_gap(ax, 5); % 标签与标记点之间的固定像素间距
|
||||
mbox = marker_size(ax) * [-0.5 -0.5 1 1];
|
||||
placed = zeros(0, 4);
|
||||
for i = 1:numel(labels)
|
||||
if pts(i, 1) < xl(1) || pts(i, 1) > xl(2) || ...
|
||||
pts(i, 2) < yl(1) || pts(i, 2) > yl(2)
|
||||
continue; % 视野外的点不标注
|
||||
end
|
||||
sz = text_size(ax, labels{i});
|
||||
cand = candidates(pts(i, :), sz, gap);
|
||||
best_pen = inf;
|
||||
best = cand(1, :);
|
||||
for k = 1:size(cand, 1)
|
||||
box = [cand(k, 1) - sz(1) / 2 - gap(1), cand(k, 2) - sz(2) / 2 - gap(2), ...
|
||||
sz(1) + 2 * gap(1), sz(2) + 2 * gap(2)];
|
||||
pen = penalty(box, placed, pts, mbox, xl, yl);
|
||||
if pen < best_pen
|
||||
best_pen = pen;
|
||||
best = cand(k, :);
|
||||
end
|
||||
end
|
||||
text(ax, best(1), best(2), labels{i}, 'Color', color(i, :), ...
|
||||
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle');
|
||||
placed(end + 1, :) = [best(1) - sz(1) / 2 - gap(1), ...
|
||||
best(2) - sz(2) / 2 - gap(2), ...
|
||||
sz(1) + 2 * gap(1), sz(2) + 2 * gap(2)]; %#ok<AGROW>
|
||||
end
|
||||
end
|
||||
|
||||
function cand = candidates(p, sz, gap)
|
||||
%CANDIDATES 标记点周围的候选标签中心:先近后远,四正方向 + 四斜角。
|
||||
dx = gap(1) + sz(1) / 2;
|
||||
dy = gap(2) + sz(2) / 2;
|
||||
cand = [p(1) + dx, p(2); p(1) - dx, p(2)
|
||||
p(1), p(2) + dy; p(1), p(2) - dy
|
||||
p(1) + dx, p(2) + dy; p(1) - dx, p(2) + dy
|
||||
p(1) + dx, p(2) - dy; p(1) - dx, p(2) - dy
|
||||
p(1) + 2.2 * dx, p(2); p(1) - 2.2 * dx, p(2)
|
||||
p(1), p(2) + 2.2 * dy; p(1), p(2) - 2.2 * dy
|
||||
p(1) + 1.9 * dx, p(2) + 1.9 * dy; p(1) - 1.9 * dx, p(2) + 1.9 * dy
|
||||
p(1) + 1.9 * dx, p(2) - 1.9 * dy; p(1) - 1.9 * dx, p(2) - 1.9 * dy
|
||||
p(1) + 3.4 * dx, p(2); p(1) - 3.4 * dx, p(2)
|
||||
p(1), p(2) + 3.4 * dy; p(1), p(2) - 3.4 * dy];
|
||||
end
|
||||
|
||||
function g = pixel_gap(ax, npix)
|
||||
pos = ax.Position;
|
||||
fpos = ancestor(ax, 'figure').Position;
|
||||
g = [npix * diff(ax.XLim) / (pos(3) * fpos(3)), ...
|
||||
npix * diff(ax.YLim) / (pos(4) * fpos(4))];
|
||||
end
|
||||
|
||||
function pen = penalty(box, placed, pts, mbox, xl, yl)
|
||||
pen = 0;
|
||||
for j = 1:size(placed, 1) % 标签之间重叠代价最高
|
||||
pen = pen + 12 * overlap(box, placed(j, :));
|
||||
end
|
||||
for j = 1:size(pts, 1) % 其次避免压住标记点
|
||||
mb = [pts(j, 1) + mbox(1), pts(j, 2) + mbox(2), mbox(3), mbox(4)];
|
||||
pen = pen + 3 * overlap(box, mb);
|
||||
end
|
||||
dx = max(0, xl(1) - box(1)) + max(0, box(1) + box(3) - xl(2));
|
||||
dy = max(0, yl(1) - box(2)) + max(0, box(2) + box(4) - yl(2));
|
||||
pen = pen + 1e3 * (dx * box(4) + dy * box(3));
|
||||
end
|
||||
|
||||
function a = overlap(b1, b2)
|
||||
w = min(b1(1) + b1(3), b2(1) + b2(3)) - max(b1(1), b2(1));
|
||||
h = min(b1(2) + b1(4), b2(2) + b2(4)) - max(b1(2), b2(2));
|
||||
a = max(0, w) * max(0, h);
|
||||
end
|
||||
|
||||
function sz = text_size(ax, str)
|
||||
%TEXT_SIZE 文本在数据坐标下的包围盒尺寸(用临时 text 对象的 Extent 量取)。
|
||||
h = text(ax, 0, 0, str, 'Units', 'data', 'Visible', 'off', ...
|
||||
'Interpreter', 'none');
|
||||
e = get(h, 'Extent');
|
||||
delete(h);
|
||||
sz = [e(3) e(4)] * 1.12;
|
||||
end
|
||||
|
||||
function w = text_width(ax, str, fs)
|
||||
%TEXT_WIDTH 指定字号下文本在数据坐标下的宽度。
|
||||
h = text(ax, 0, 0, str, 'Units', 'data', 'Visible', 'off', ...
|
||||
'FontSize', fs, 'Interpreter', 'none');
|
||||
e = get(h, 'Extent');
|
||||
delete(h);
|
||||
w = e(3) * 1.04;
|
||||
end
|
||||
|
||||
function s = marker_size(ax)
|
||||
xl = ax.XLim;
|
||||
yl = ax.YLim;
|
||||
pos = ax.Position;
|
||||
fpos = ancestor(ax, 'figure').Position;
|
||||
w_px = pos(3) * fpos(3);
|
||||
h_px = pos(4) * fpos(4);
|
||||
s = 8 * max(diff(xl) / w_px, diff(yl) / h_px); % 约 8 px
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
function D = load_data(S, name)
|
||||
%LOAD_DATA 读取 scripts/build_figure_data.py 预先写好的绘图数据并规范化文本字段。
|
||||
path = fullfile(S.data_dir, [name '.mat']);
|
||||
if ~isfile(path)
|
||||
error('缺少绘图数据:%s(请先运行 scripts/build_figure_data.py --prepare)', path);
|
||||
end
|
||||
D = normalize_text(load(path));
|
||||
if isfield(D, 'switch_') && ~isfield(D, 'switch_s')
|
||||
D.switch_s = D.switch_;
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
function D = normalize_text(D)
|
||||
%NORMALIZE_TEXT 把 load 出来的字符矩阵还原成字符串列表。
|
||||
% scipy.io.savemat 会把 Python 的字符串列表写成三维 char 数组(或 char 矩阵),
|
||||
% MATLAB 侧读回来是 char,不能按 {i} 取值,这里统一转成 cellstr。
|
||||
|
||||
fn = fieldnames(D);
|
||||
for k = 1:numel(fn)
|
||||
v = D.(fn{k});
|
||||
if ischar(v) && ndims(v) > 2
|
||||
D.(fn{k}) = cellstr(v);
|
||||
elseif isstruct(v)
|
||||
D.(fn{k}) = normalize_text(v);
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
function c = pick_color(S, name)
|
||||
%PICK_COLOR 语义色名 → MATLAB 默认配色(不引入自定义调色板)。
|
||||
switch name
|
||||
case 'primary', c = S.c(1, :); % 几何区域、主体对象
|
||||
case 'accent', c = S.c(2, :); % 真值源、关键结论
|
||||
case 'warn', c = S.c(3, :); % 警示、未清除
|
||||
case 'violet', c = S.c(4, :); % 直径圆等辅助对象
|
||||
case 'success', c = S.c(5, :); % 覆盖、清除、通过
|
||||
case 'grid', c = [0.5 0.5 0.5];
|
||||
case 'muted', c = [0.45 0.45 0.45];
|
||||
otherwise, c = [0 0 0];
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
function run_all_figures()
|
||||
%RUN_ALL_FIGURES 生成 docs/figures 下的全部论文插图。
|
||||
% 数据由 scripts/build_figure_data.py 写入 docs/figures/.data/*.mat。
|
||||
% 本机图形子系统在同一批处理会话里连续渲染多张图会崩溃,故每张图单独起会话;
|
||||
% 这里只作为 MATLAB 内的手动入口,逐张调用并记日志。
|
||||
|
||||
here = fileparts(mfilename('fullpath'));
|
||||
addpath(here);
|
||||
log = fullfile(here, 'run_all_figures.log');
|
||||
items = { 'flow-overall-architecture', @fig_flow_overall
|
||||
'steps-state-machine', @fig_steps_state_machine
|
||||
'timing-attachment-example-105-111-194-199', @fig_timing_attachment
|
||||
'timing-measured-q3-10-sources', @fig_timing_measured
|
||||
'geo-q1-convex-intersection-zoom', @fig_geo_q1_meetup
|
||||
'geo-q1-diameter-circle-counterexample', @fig_geo_q1_counterexample
|
||||
'geo-q2-guaranteed-reception-disk', @fig_geo_q2_safe_disk
|
||||
'geo-q3-seven-point-coverage', @fig_geo_q3_coverage
|
||||
'geo-q4-minimum-cover-31-points', @fig_geo_q4_mincover
|
||||
'replay-webui-q3-omnidirectional', @() fig_replay('replay_q3')
|
||||
'replay-webui-q4-directional', @() fig_replay('replay_q4') };
|
||||
fid = fopen(log, 'w');
|
||||
failed = 0;
|
||||
for i = 1:2:numel(items)
|
||||
name = items{i};
|
||||
fn = items{i + 1};
|
||||
try
|
||||
fn();
|
||||
fprintf(fid, 'OK %s\n', name);
|
||||
catch err
|
||||
failed = failed + 1;
|
||||
fprintf(fid, 'FAIL %s: %s\n', name, err.message);
|
||||
end
|
||||
end
|
||||
fclose(fid);
|
||||
if failed > 0
|
||||
fprintf('有 %d 张图生成失败,详见 %s\n', failed, log);
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user