论文完成
This commit is contained in:
+428
-133
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
import csv, json, math
|
||||
import csv, json, math, re
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
@@ -17,11 +17,15 @@ LIGHT = "#9DB7CA"
|
||||
PALE = "#D9E4EC"
|
||||
GOLD = "#B58B3A"
|
||||
GREEN = "#4E7A55"
|
||||
plt.rcParams.update({"font.sans-serif": ["DejaVu Sans"], "axes.unicode_minus": False,
|
||||
plt.rcParams.update({"font.sans-serif": ["Microsoft YaHei", "SimHei", "DejaVu Sans"], "axes.unicode_minus": False,
|
||||
"figure.dpi": 120, "savefig.dpi": 300, "axes.edgecolor": GRAY,
|
||||
"axes.labelcolor": "#222222", "text.color": "#222222"})
|
||||
|
||||
def save(fig, name):
|
||||
try:
|
||||
fig.tight_layout()
|
||||
except Exception:
|
||||
pass
|
||||
fig.savefig(FIG / name, bbox_inches="tight", facecolor="white")
|
||||
plt.close(fig)
|
||||
|
||||
@@ -40,27 +44,6 @@ def load_formal():
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def fig_formal_total():
|
||||
rows = load_formal()
|
||||
labels = ["Q%d-%d" % (r["problem_no"], r["formal_index"]) for r in rows]
|
||||
total = [r["virtual_time_s"] for r in rows]
|
||||
runtime = [r["program_run_duration_s"] for r in rows]
|
||||
colors = [INK, LIGHT, PALE, GOLD, "#C98963", GREEN]
|
||||
fig, axes = plt.subplots(1, 2, figsize=(10.5, 3.6))
|
||||
for ax, vals, title, unit in ((axes[0], total, "Total virtual time", "s"),
|
||||
(axes[1], runtime, "Program runtime", "s")):
|
||||
bars = ax.barh(labels[::-1], vals[::-1], color=colors[::-1])
|
||||
ax.set_xlabel(title + " / " + unit)
|
||||
ax.grid(axis="x", color="#E5E5E5", linewidth=0.7)
|
||||
ax.set_axisbelow(True)
|
||||
for bar, val in zip(bars, vals[::-1]):
|
||||
ax.text(bar.get_width() + max(vals) * 0.015,
|
||||
bar.get_y() + bar.get_height() / 2,
|
||||
"%.3f" % val, va="center", fontsize=8)
|
||||
ax.margins(x=0.18)
|
||||
fig.suptitle("Direct metrics of three formal tests", fontsize=11)
|
||||
save(fig, "formal-total-time.png")
|
||||
|
||||
|
||||
def fig_formal_time_bars():
|
||||
rows = load_formal()
|
||||
@@ -70,19 +53,34 @@ def fig_formal_time_bars():
|
||||
switch = np.array([1.0 * r["channel_switch_count"] for r in rows])
|
||||
clear = np.array([5.0 * r["cleared_jammer_count"] + 3.0 * r["clear_failure_count"] for r in rows])
|
||||
fig, ax = plt.subplots(figsize=(9.0, 3.8))
|
||||
ax.barh(labels, move, color=INK, label="Move")
|
||||
ax.barh(labels, measure, left=move, color=LIGHT, label="Measure")
|
||||
ax.barh(labels, switch, left=move + measure, color=GRAY, label="Switch")
|
||||
ax.barh(labels, clear, left=move + measure + switch, color=GOLD, label="Clear")
|
||||
ax.set_xlabel("Cumulative virtual time / s")
|
||||
ax.barh(labels, move, color=INK, label="移动")
|
||||
ax.barh(labels, measure, left=move, color=LIGHT, label="检测")
|
||||
ax.barh(labels, switch, left=move + measure, color=GRAY, label="换频")
|
||||
ax.barh(labels, clear, left=move + measure + switch, color=GOLD, label="清除")
|
||||
ax.set_xlabel("累计虚拟时间 / s")
|
||||
ax.grid(axis="x", color="#E5E5E5", linewidth=0.7)
|
||||
ax.set_axisbelow(True)
|
||||
ax.legend(loc="lower right", frameon=False, ncol=4)
|
||||
save(fig, "formal-time-bars.png")
|
||||
|
||||
def box(ax, x, y, w, h, text, fc=PALE, ec=INK):
|
||||
def practice_macro(name: str) -> str:
|
||||
"""从 ``data/practice-macros.tex`` 读取正文主局案例编码。
|
||||
|
||||
正文与插图必须引用同一次采集的同一局,因此这里不写死案例编码,
|
||||
而是读 ``build_practice_data.py`` 生成的宏,避免两处口径分叉。
|
||||
"""
|
||||
path = ROOT / "docs" / "paper" / "data" / "practice-macros.tex"
|
||||
match = re.search(r"\\newcommand\{\\" + name + r"\}\{([^}]*)\}",
|
||||
path.read_text(encoding="utf-8"))
|
||||
if match is None:
|
||||
raise KeyError(f"宏 {name} 不存在;请先运行 scripts/build_practice_data.py")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def box(ax, x, y, w, h, text, fc=PALE, ec=INK, size=8):
|
||||
"""画一个流程框;``size`` 用 pt 指定字号,避免缩放后文字过小或过大。"""
|
||||
ax.add_patch(Rectangle((x, y), w, h, facecolor=fc, edgecolor=ec, linewidth=1.0))
|
||||
ax.text(x + w/2, y + h/2, text, ha="center", va="center", fontsize=8, wrap=True)
|
||||
ax.text(x + w/2, y + h/2, text, ha="center", va="center", fontsize=size, wrap=True)
|
||||
|
||||
|
||||
def arrow(ax, p, q, color=GRAY):
|
||||
@@ -91,45 +89,73 @@ def arrow(ax, p, q, color=GRAY):
|
||||
|
||||
|
||||
def fig_flow_overall():
|
||||
fig, ax = plt.subplots(figsize=(8.0, 4.8))
|
||||
"""四层主线图:紧凑画布,箭头端点一律落在框边,不在框内穿过。
|
||||
|
||||
原图四条 L2→L3 的箭头收在同一个点上,箭头头部挤成一团;
|
||||
这里改为按顺序分别落在 L3 顶边的不同位置,
|
||||
线不交叉、箭头也不重叠。
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(7.6, 4.9))
|
||||
ax.axis("off")
|
||||
box(ax, 0.5, 4.0, 7.0, 0.8, "L0 Problem abstraction: area, motion, timing, channels", fc="#EFEFEF")
|
||||
box(ax, 0.5, 3.0, 2.1, 0.7, "L1 Evidence\ncandidate sets")
|
||||
box(ax, 2.9, 3.0, 2.1, 0.7, "L1 Decision\ntask tables")
|
||||
box(ax, 5.3, 3.0, 2.1, 0.7, "L1 Execution\nactions")
|
||||
box(ax, 0.5, 2.0, 1.6, 0.7, "L2 Q1\ngeometry", fc="#E8EEF4")
|
||||
box(ax, 2.3, 2.0, 1.6, 0.7, "L2 Q2\nactive measure", fc="#E8EEF4")
|
||||
box(ax, 4.1, 2.0, 1.6, 0.7, "L2 Q3\nomni loop", fc="#E8EEF4")
|
||||
box(ax, 5.9, 2.0, 1.6, 0.7, "L2 Q4\nmixed loop", fc="#E8EEF4")
|
||||
box(ax, 1.4, 0.8, 5.2, 0.7, "L3 Discovery -> Localisation -> Clearance", fc="#E6F0E7", ec=GREEN)
|
||||
box(ax, 0.5, 4.0, 7.0, 0.8, "L0 题面抽象:区域、运动、计时、频道", fc="#EFEFEF",
|
||||
size=9.5)
|
||||
box(ax, 0.5, 3.0, 2.1, 0.7, "L1 证据层\n候选集", size=8.5)
|
||||
box(ax, 2.9, 3.0, 2.1, 0.7, "L1 决策层\n任务表", size=8.5)
|
||||
box(ax, 5.3, 3.0, 2.1, 0.7, "L1 执行层\n动作", size=8.5)
|
||||
box(ax, 0.5, 2.0, 1.6, 0.7, "L2 问题一\n几何", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 2.3, 2.0, 1.6, 0.7, "L2 问题二\n主动测量", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 4.1, 2.0, 1.6, 0.7, "L2 问题三\n全向闭环", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 5.9, 2.0, 1.6, 0.7, "L2 问题四\n混合闭环", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 1.4, 0.8, 5.2, 0.7, "L3 发现 → 定位 → 清除", fc="#E6F0E7", ec=GREEN,
|
||||
size=9.5)
|
||||
# L0 → L1:三条竖直箭头,终点取各框顶边中点
|
||||
for px in (1.55, 3.95, 6.35):
|
||||
arrow(ax, (px, 4.0), (px, 3.7))
|
||||
# L2 → L3:四条线先接到 L3 上方的一条汇流线,再由一个箭头进入 L3。
|
||||
# 直接让四条箭头落在同一条边上时,箭头头部会彼此压住,故改用汇流线。
|
||||
bus_y = 1.62
|
||||
ax.plot([2.65, 5.15], [bus_y, bus_y], color=GRAY, linewidth=1.0, zorder=1)
|
||||
for px in (1.3, 3.1, 4.9, 6.7):
|
||||
arrow(ax, (px, 2.7), (3.9, 1.5))
|
||||
ax.set_xlim(0, 8); ax.set_ylim(0, 5.2)
|
||||
ax.plot([px, px], [2.0, bus_y], color=GRAY, linewidth=1.0, zorder=1)
|
||||
arrow(ax, (3.9, bus_y), (3.9, 1.5))
|
||||
ax.set_xlim(0, 8); ax.set_ylim(0.5, 5.2)
|
||||
save(fig, "flow-overall-architecture.png")
|
||||
|
||||
|
||||
def fig_framework():
|
||||
fig, ax = plt.subplots(figsize=(5.8, 4.2))
|
||||
"""闭环决策图:观测—候选集—任务—动作的循环,自下而上闭合。
|
||||
|
||||
箭头只画在框与框之间的空白处:起点取框边、终点取框边,
|
||||
不在框内穿过,也不在一点挤成一团。因此这里给四周留出布线通道,
|
||||
并用一条水平汇流线把"任务选择"到"退出/报告"的支路接出去。
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(7.6, 5.15))
|
||||
ax.axis("off")
|
||||
box(ax, 0.3, 2.8, 2.0, 0.7, "Observation\nbearing / near / no-signal")
|
||||
box(ax, 3.1, 2.8, 2.0, 0.7, "Candidate update\ngeometry / grid / heading")
|
||||
box(ax, 3.1, 1.4, 2.0, 0.7, "Task selection\ncover / locate / clear")
|
||||
box(ax, 0.3, 1.4, 2.0, 0.7, "Action execution\nmove and timing")
|
||||
arrow(ax, (2.3, 3.15), (3.1, 3.15)); arrow(ax, (4.1, 2.8), (4.1, 2.1))
|
||||
arrow(ax, (3.1, 1.75), (2.3, 1.75)); arrow(ax, (1.3, 2.1), (1.3, 2.8))
|
||||
box(ax, 3.1, 0.3, 2.0, 0.6, "Exit / report", fc="#EFEFEF")
|
||||
arrow(ax, (4.1, 1.4), (4.1, 0.9))
|
||||
ax.set_xlim(0, 5.6); ax.set_ylim(0, 4.0)
|
||||
box(ax, 0.3, 2.8, 2.0, 0.7, "观测\n示向度 / 近距离 / 无信号", size=8.5)
|
||||
box(ax, 3.1, 2.8, 2.0, 0.7, "候选集更新\n几何 / 网格 / 朝向", size=8.5)
|
||||
box(ax, 3.1, 1.4, 2.0, 0.7, "任务选择\n覆盖 / 定位 / 清除", size=8.5)
|
||||
box(ax, 0.3, 1.4, 2.0, 0.7, "动作执行\n移动与计时", size=8.5)
|
||||
box(ax, 3.1, 0.25, 2.0, 0.6, "退出 / 报告", fc="#EFEFEF", size=8.5)
|
||||
|
||||
# 回路:观测 →(右)候选集更新 →(下)任务选择 →(左)动作执行 →(上)观测
|
||||
arrow(ax, (2.3, 3.15), (3.1, 3.15))
|
||||
arrow(ax, (4.1, 2.8), (4.1, 2.1))
|
||||
arrow(ax, (3.1, 1.75), (2.3, 1.75))
|
||||
arrow(ax, (1.3, 2.1), (1.3, 2.8))
|
||||
# 支路:任务选择下行到"退出/报告",箭头端点都落在框边,不进入框内
|
||||
arrow(ax, (4.1, 1.4), (4.1, 0.85))
|
||||
|
||||
ax.set_xlim(0.12, 5.28); ax.set_ylim(0.13, 3.62)
|
||||
save(fig, "framework-decision-loop.png")
|
||||
ax.set_xlim(0, 5.6); ax.set_ylim(0.3, 3.7)
|
||||
save(fig, "framework-decision-loop.png")
|
||||
|
||||
|
||||
def fig_state_machine():
|
||||
fig, ax = plt.subplots(figsize=(7.0, 2.2))
|
||||
ax.axis("off")
|
||||
xs = [(0.1, "IDLE"), (1.8, "ENTER\n5 s countdown"), (3.8, "OPEN\ninterface"),
|
||||
(5.8, "CLOSED\nexit / timeout")]
|
||||
xs = [(0.1, "未进入"), (1.8, "进入\n5 s 倒计时"), (3.8, "接口开放"),
|
||||
(5.8, "关闭\n退出 / 超时")]
|
||||
for x, t in xs:
|
||||
box(ax, x, 0.6, 1.5, 0.8, t)
|
||||
for x in (1.6, 3.6, 5.6):
|
||||
@@ -139,17 +165,17 @@ def fig_state_machine():
|
||||
|
||||
|
||||
def fig_timing_attachment():
|
||||
steps = ["step 1", "step 2", "step 3", "step 4"]
|
||||
steps = ["步骤 1", "步骤 2", "步骤 3", "步骤 4"]
|
||||
move = np.array([100.0, 0.0, 80.0, 0.0])
|
||||
action = np.array([5.0, 5.0, 3.0, 5.0])
|
||||
switch = np.array([0.0, 1.0, 0.0, 0.0])
|
||||
fig, ax = plt.subplots(figsize=(6.0, 3.2))
|
||||
ax.bar(steps, move, color=INK, label="Move")
|
||||
ax.bar(steps, action, bottom=move, color=LIGHT, label="Measure / clear")
|
||||
ax.bar(steps, switch, bottom=move + action, color=GRAY, label="Switch")
|
||||
ax.bar(steps, move, color=INK, label="移动")
|
||||
ax.bar(steps, action, bottom=move, color=LIGHT, label="检测 / 清除")
|
||||
ax.bar(steps, switch, bottom=move + action, color=GRAY, label="换频")
|
||||
for i, total in enumerate(move + action + switch):
|
||||
ax.text(i, total + 2, "%.0f" % total, ha="center", fontsize=8)
|
||||
ax.set_ylabel("Virtual time / s")
|
||||
ax.set_ylabel("虚拟时间 / s")
|
||||
ax.legend(frameon=False)
|
||||
save(fig, "timing-attachment-example-105-111-194-199.png")
|
||||
|
||||
@@ -231,56 +257,93 @@ def min_enclosing_circle(pts):
|
||||
return best_c, best_r
|
||||
|
||||
|
||||
def fig_q1_counterexample():
|
||||
A = np.array([0.0, 0.0]); B = np.array([40.0, 0.0]); C = np.array([20.0, 20.0 * math.sqrt(3)])
|
||||
fig, ax = plt.subplots(figsize=(4.6, 4.2))
|
||||
ax.add_patch(Polygon([A, B, C], closed=True, fill=False, edgecolor=INK, linewidth=1.4))
|
||||
dc, dr = pair_circle(A, B); ax.add_patch(Circle(dc, dr, fill=False, edgecolor=GOLD, linestyle="--", linewidth=1.2))
|
||||
mc, mr = min_enclosing_circle(np.array([A, B, C])); ax.add_patch(Circle(mc, mr, fill=False, edgecolor=GREEN, linewidth=1.4))
|
||||
ax.scatter([A[0], B[0], C[0]], [A[1], B[1], C[1]], color=INK, s=12)
|
||||
ax.text(C[0] + 0.8, C[1] + 0.6, "C", fontsize=9)
|
||||
ax.text(-1.2, -2.0, "A", fontsize=9); ax.text(40.0, -2.0, "B", fontsize=9)
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE"); ax.set_xlim(-8, 52); ax.set_ylim(-8, 42)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m")
|
||||
ax.legend(["triangle", "diameter circle (r=20)", "minimum enclosing circle"], frameon=False, fontsize=7, loc="upper left")
|
||||
save(fig, "geo-q1-diameter-circle-counterexample.png")
|
||||
|
||||
def load_q1_example():
|
||||
"""问题一交会算例(scripts/build_practice_data.py 复算自建模拟器观测)。"""
|
||||
path = ROOT / "docs" / "paper" / "data" / "practice-geometry.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def fig_q1_meetup():
|
||||
data_path = ROOT / "examples" / "q1" / "triangle.json"
|
||||
meas = json.loads(data_path.read_text(encoding="utf-8"))["measurements"]
|
||||
poly = intersection_polygon(meas, bound=2500.0)
|
||||
if len(poly) < 3:
|
||||
poly = np.array([[0.0, 0.0], [40.0, 0.0], [40.0, 30.0], [0.0, 30.0]])
|
||||
fig, axes = plt.subplots(1, 2, figsize=(8.2, 4.0))
|
||||
ax = axes[0]
|
||||
def set_equal_view(ax, centre, half_width, box_aspect):
|
||||
"""设定等比例视野,并让轴框高宽比等于 ``box_aspect``。
|
||||
|
||||
``set_box_aspect`` 与固定坐标范围同时使用会让 matplotlib 丢弃范围并告警,
|
||||
因此这里直接按目标高宽比放大较窄的一维:三幅取同一 ``box_aspect`` 与同一宽度后,
|
||||
轴框尺寸完全一致,版面高度自然严格相等。
|
||||
"""
|
||||
half_height = half_width * box_aspect
|
||||
ax.set_xlim(centre[0] - half_width, centre[0] + half_width)
|
||||
ax.set_ylim(centre[1] - half_height, centre[1] + half_height)
|
||||
ax.set_aspect("equal")
|
||||
|
||||
|
||||
def fig_q1_geometry():
|
||||
"""问题一几何图:角域交会、定位区域放大、等边三角形反例,三幅等高并排。
|
||||
|
||||
三幅必须等高才能并排读,因此用 :func:`set_equal_view` 把三个轴框
|
||||
固定为同一高宽比;三幅取相同宽度后,版面高度严格相等。
|
||||
视野由观测点与定位区域联合确定,避免只按观测点设限导致区域跑出画面。
|
||||
反例图原本是独立插图,这里并入同一张图,省去一次浮动与一个图注。
|
||||
"""
|
||||
example = load_q1_example()
|
||||
meas = example["measurements"]
|
||||
poly = np.array(example["vertices"], dtype=float)
|
||||
box_aspect = 1.15
|
||||
fig, (ax, ax2, ax3) = plt.subplots(1, 3, figsize=(9.6, 4.0))
|
||||
|
||||
stations = np.array([[m["x"], m["y"]] for m in meas], dtype=float)
|
||||
for m in meas:
|
||||
x, y = m["x"], m["y"]; th = math.radians(m["bearing_deg"]); d = math.radians(m["half_angle_deg"])
|
||||
x, y = m["x"], m["y"]
|
||||
th = math.radians(m["bearing_deg"])
|
||||
d = math.radians(example["half_angle_deg"])
|
||||
for sgn in (-1.0, 1.0):
|
||||
ang = th + sgn * d
|
||||
ray = np.array([x, y]) + 3600.0 * np.array([math.cos(ang), math.sin(ang)])
|
||||
ax.plot([x, ray[0]], [y, ray[1]], color=LIGHT, linewidth=0.8)
|
||||
ax.scatter([x], [y], color=GRAY, s=14)
|
||||
ax.text(x + 40, y + 40, "S", fontsize=8)
|
||||
ax.add_patch(Polygon(poly, closed=True, facecolor=PALE, edgecolor=INK, linewidth=1.4))
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE")
|
||||
ax.set_xlim(-1200, 1000); ax.set_ylim(-1000, 1100)
|
||||
end = np.array([x, y]) + 900.0 * np.array([math.cos(ang), math.sin(ang)])
|
||||
ax.plot([x, end[0]], [y, end[1]], color=LIGHT, linewidth=0.9, zorder=1)
|
||||
ax.scatter([x], [y], color=GRAY, s=16, zorder=3)
|
||||
ax.annotate("S", (x, y), textcoords="offset points", xytext=(6, 5), fontsize=8)
|
||||
ax.add_patch(Polygon(poly, closed=True, facecolor=PALE, edgecolor=INK,
|
||||
linewidth=1.4, zorder=2))
|
||||
# 视野取"观测点 ∪ 定位区域"的外包框,再补边距,保证两者同时入画。
|
||||
stacked = np.vstack([stations, poly])
|
||||
lo, hi = stacked.min(axis=0), stacked.max(axis=0)
|
||||
set_equal_view(ax, (lo + hi) / 2.0, max(hi - lo) * 0.54, box_aspect)
|
||||
ax.grid(True, color="#EEEEEE")
|
||||
ax.set_title("(a) 角域交会", fontsize=9)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m")
|
||||
ax.set_title("Wedge intersection", fontsize=9)
|
||||
ax2 = axes[1]
|
||||
|
||||
ax2.add_patch(Polygon(poly, closed=True, facecolor=PALE, edgecolor=INK, linewidth=1.4))
|
||||
c, r = min_enclosing_circle(poly)
|
||||
d = max(np.linalg.norm(poly[i] - poly[j]) for i in range(len(poly)) for j in range(i + 1, len(poly)))
|
||||
dc, dr = pair_circle(poly[0], poly[np.argmax(np.linalg.norm(poly - poly[0], axis=1))])
|
||||
ax2.add_patch(Circle(dc, dr, fill=False, edgecolor=GOLD, linestyle="--", linewidth=1.1))
|
||||
ax2.add_patch(Circle(c, r, fill=False, edgecolor=GREEN, linewidth=1.3))
|
||||
ax2.scatter(poly[:, 0], poly[:, 1], color=INK, s=10)
|
||||
lo = poly.min(axis=0) - 4; hi = poly.max(axis=0) + 4
|
||||
ax2.set_xlim(lo[0], hi[0]); ax2.set_ylim(lo[1], hi[1])
|
||||
ax2.set_aspect("equal"); ax2.grid(True, color="#EEEEEE")
|
||||
ax2.set_title("Zoom: diameter vs. enclosing circle", fontsize=9)
|
||||
ax2.add_patch(Circle(example["diameter_circle_centre"], example["diameter_circle_radius_m"],
|
||||
fill=False, edgecolor=GOLD, linestyle="--", linewidth=1.2,
|
||||
label="直径圆"))
|
||||
ax2.add_patch(Circle(example["mec_centre"], example["mec_radius_m"],
|
||||
fill=False, edgecolor=GREEN, linewidth=1.4, label="最小包围圆"))
|
||||
ax2.scatter(poly[:, 0], poly[:, 1], color=INK, s=16, label="定位区域顶点")
|
||||
lo, hi = poly.min(axis=0), poly.max(axis=0)
|
||||
set_equal_view(ax2, (lo + hi) / 2.0, max(hi - lo) * 0.62, box_aspect)
|
||||
ax2.grid(True, color="#EEEEEE")
|
||||
ax2.set_title("(b) 定位区域放大", fontsize=9)
|
||||
ax2.set_xlabel("x / m"); ax2.set_ylabel("y / m")
|
||||
save(fig, "geo-q1-convex-intersection-zoom.png")
|
||||
ax2.legend(frameon=False, fontsize=7, loc="lower left", bbox_to_anchor=(0.0, 0.0))
|
||||
|
||||
tri = np.array([[0.0, 0.0], [40.0, 0.0], [20.0, 20.0 * math.sqrt(3.0)]])
|
||||
ax3.add_patch(Polygon(tri, closed=True, fill=False, edgecolor=INK, linewidth=1.4))
|
||||
ax3.add_patch(Circle((20.0, 0.0), 20.0, fill=False, edgecolor=GOLD,
|
||||
linestyle="--", linewidth=1.2, label="直径圆 ($r=20$ m)"))
|
||||
ax3.add_patch(Circle((20.0, 20.0 / math.sqrt(3.0)), 40.0 / math.sqrt(3.0), fill=False,
|
||||
edgecolor=GREEN, linewidth=1.4,
|
||||
label="最小包围圆 ($\\rho=40/\\sqrt{3}$ m)"))
|
||||
ax3.scatter(tri[:, 0], tri[:, 1], color=INK, s=16)
|
||||
for (px, py), name, dx, dy in ((tri[0], "A", -11.0, -3.0), (tri[1], "B", 7.0, -3.0),
|
||||
(tri[2], "C", -2.0, 9.0)):
|
||||
ax3.annotate(name, (px, py), textcoords="offset points", xytext=(dx, dy), fontsize=9)
|
||||
set_equal_view(ax3, (20.0, 15.0), 30.0, box_aspect)
|
||||
ax3.grid(True, color="#EEEEEE")
|
||||
ax3.set_title("(c) 等边三角形反例", fontsize=9)
|
||||
ax3.set_xlabel("x / m"); ax3.set_ylabel("y / m")
|
||||
ax3.legend(frameon=False, fontsize=6.5, loc="upper right")
|
||||
save(fig, "geo-q1-geometry.png")
|
||||
|
||||
|
||||
def fig_q2_safe_disk():
|
||||
delta = math.radians(1.0); rp = 1500.0; a = rp / (2.0 * math.cos(delta)); safe = 1000.0 - a
|
||||
@@ -294,61 +357,293 @@ def fig_q2_safe_disk():
|
||||
ax.text(a + 20, 20, "c", fontsize=9); ax.text(10, 15, "s", fontsize=9)
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE")
|
||||
ax.set_xlim(-80, rp + 80); ax.set_ylim(-320, 320)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.set_title("Truncated wedge and safe disk", fontsize=9)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.set_title("截断角域与安全候选盘", fontsize=9)
|
||||
ax = axes[1]
|
||||
ax.plot([0, 0], [0, 1], color=GRAY, linewidth=2)
|
||||
ax.plot([0, 1], [0, 0], color=INK, linewidth=4)
|
||||
ax.text(0.5, -0.08, "a = 1500/(2cos delta)", ha="center", fontsize=8)
|
||||
ax.text(0.5, 0.55, "safe radius = 1000 - a", ha="center", fontsize=8)
|
||||
ax.text(0.5, -0.08, "a = 1500/(2cosδ)", ha="center", fontsize=8)
|
||||
ax.text(0.5, 0.55, "安全半径 = 1000 - a", ha="center", fontsize=8)
|
||||
ax.axis("off")
|
||||
save(fig, "geo-q2-guaranteed-reception-disk.png")
|
||||
|
||||
|
||||
def fig_q3_coverage():
|
||||
a = 1159.7; R = 1800.0
|
||||
fig, ax = plt.subplots(figsize=(5.2, 5.0))
|
||||
ax.add_patch(Circle((0, 0), R, fill=False, edgecolor=INK, linewidth=1.4))
|
||||
stations = [(0.0, 0.0)] + [(a * math.cos(k * math.pi / 3.0), a * math.sin(k * math.pi / 3.0)) for k in range(6)]
|
||||
for x, y in stations:
|
||||
ax.add_patch(Circle((x, y), 1000.0, facecolor=PALE, edgecolor=LIGHT, linewidth=0.7, alpha=0.35))
|
||||
ax.scatter([x], [y], color=INK, s=16)
|
||||
ax.add_patch(Circle((0, 0), a, fill=False, edgecolor=GRAY, linestyle="--", linewidth=1.0))
|
||||
ax.text(0, 0, "O", fontsize=9, ha="right", va="top"); ax.text(a, 0, "a", fontsize=9, ha="center", va="bottom")
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE"); ax.set_xlim(-2000, 2000); ax.set_ylim(-2000, 2000)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.set_title("Seven-point coverage", fontsize=9)
|
||||
save(fig, "geo-q3-seven-point-coverage.png")
|
||||
|
||||
|
||||
def fig_q4_plan():
|
||||
data = json.loads((ROOT / "src" / "mcm26b" / "guarantee" / "plans" / "q4_mincover_v2.json").read_text(encoding="utf-8"))
|
||||
"""31 点最小命中集:站点分布 + 圆域外站点 + 定向源的读图约定。
|
||||
|
||||
原图把一枚前向半盘楔形压在站点与轨迹附近,读者无法判断它在说什么。
|
||||
这里改为:圆外用空心标记单独区分,并在"圆外站点"上给出定向源示例——
|
||||
绿箭头为朝向、浅绿细线扇形为 ±90° 发射范围、红色虚线为该点实测到的示向度,
|
||||
三者构成一条完整的"朝向 → 可见范围 → 观测量"读图链。
|
||||
"""
|
||||
data = json.loads((ROOT / "src" / "mcm26b" / "guarantee" / "plans"
|
||||
/ "q4_mincover_v2.json").read_text(encoding="utf-8"))
|
||||
pts = np.array(data["points"], dtype=float)
|
||||
fig, ax = plt.subplots(figsize=(5.4, 5.0))
|
||||
ax.add_patch(Circle((0, 0), 1800.0, facecolor=PALE, edgecolor=INK, linewidth=1.1, alpha=0.45))
|
||||
ax.scatter(pts[:, 0], pts[:, 1], color=INK, s=18, label="31 stations")
|
||||
ax.scatter([0], [0], color=GOLD, marker="*", s=50, label="origin")
|
||||
ax.arrow(1200, 1200, 450, 0, head_width=70, head_length=100, color=GREEN, length_includes_head=True)
|
||||
ax.add_patch(Wedge((1200, 1200), 650, -90, 90, facecolor=GREEN, alpha=0.10, edgecolor=GREEN, linewidth=0.8))
|
||||
ax.text(1250, 1250, "forward half-disk", fontsize=8)
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE"); ax.set_xlim(-2800, 2800); ax.set_ylim(-2800, 2800)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.legend(frameon=False, fontsize=8, loc="lower right")
|
||||
inside = np.linalg.norm(pts, axis=1) <= 1800.0
|
||||
fig, ax = plt.subplots(figsize=(7.6, 6.6))
|
||||
ax.add_patch(Circle((0, 0), 1800.0, facecolor=PALE, edgecolor=INK,
|
||||
linewidth=1.1, alpha=0.45, zorder=1))
|
||||
ax.text(0, 1840, "Ω", ha="center", va="bottom", fontsize=9)
|
||||
ax.scatter(pts[inside, 0], pts[inside, 1], color=INK, s=18,
|
||||
label=f"圆域内检测站({int(inside.sum())} 个)", zorder=4)
|
||||
ax.scatter(pts[~inside, 0], pts[~inside, 1], facecolor="white", edgecolor=INK,
|
||||
linewidth=1.1, s=26, label=f"圆域外检测站({int((~inside).sum())} 个)",
|
||||
zorder=4)
|
||||
ax.scatter([0], [0], color=GOLD, marker="*", s=60, label="原点(出发位置)", zorder=5)
|
||||
|
||||
# 定向源示例挂在"离圆域最近的圆外站点"上,扇形朝外,不再压住站点云。
|
||||
outside = pts[~inside]
|
||||
anchor = outside[np.argmin(np.linalg.norm(outside, axis=1))] if len(outside) else pts[0]
|
||||
bearing = 20.0
|
||||
draw_source(ax, {"x": anchor[0], "y": anchor[1], "heading_deg": bearing,
|
||||
"directional": True}, fan_radius=560.0, annotate=True)
|
||||
|
||||
# 源放在该站的前向半盘内,并画出它到检测站的实测示向度,补全读图链。
|
||||
src = np.array([anchor[0] + 820.0 * math.cos(math.radians(bearing - 30.0)),
|
||||
anchor[1] + 820.0 * math.sin(math.radians(bearing - 30.0))])
|
||||
ax.scatter([src[0]], [src[1]], marker="X", s=110, color="#B33A3A",
|
||||
label="定向源读图示例(非真实源)", zorder=6)
|
||||
ax.plot([anchor[0], src[0]], [anchor[1], src[1]], color="#B33A3A",
|
||||
linestyle=(0, (4, 3)), linewidth=1.1, zorder=3)
|
||||
measured = math.degrees(math.atan2(src[1] - anchor[1], src[0] - anchor[0]))
|
||||
ax.annotate(f"实测示向度 {measured:.0f}°",
|
||||
xy=((anchor[0] + src[0]) / 2, (anchor[1] + src[1]) / 2),
|
||||
xytext=(src[0] + 320, src[1] + 380), fontsize=7.5, color="#B33A3A",
|
||||
arrowprops=dict(arrowstyle="->", color="#B33A3A", linewidth=0.8))
|
||||
draw_reading_guide(ax, "定向源读图约定:绿箭头=朝向,浅绿细线扇形\n"
|
||||
"=该源 ±90° 发射范围,红色虚线=检测站测得\n"
|
||||
"的示向度(有朝向遮挡时才可能出现无信号)",
|
||||
anchor=(0.015, 0.30))
|
||||
|
||||
ax.set_aspect("equal")
|
||||
ax.set_xlim(-3100, 3100)
|
||||
ax.set_ylim(-3100, 3100)
|
||||
ax.set_xlabel("x / m")
|
||||
ax.set_ylabel("y / m")
|
||||
ax.grid(True, color="#EEEEEE", linewidth=0.6)
|
||||
ax.legend(frameon=False, fontsize=8, loc="center left", bbox_to_anchor=(1.02, 0.5))
|
||||
save(fig, "geo-q4-minimum-cover-31-points.png")
|
||||
|
||||
|
||||
def load_practice():
|
||||
"""自建模拟器演练局复算结果(scripts/build_practice_data.py 生成)。"""
|
||||
path = ROOT / "docs" / "paper" / "data" / "practice-results.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))["records"]
|
||||
|
||||
|
||||
def fig_practice_total():
|
||||
"""演练局虚拟时间:逐局散点 + 全清局均值,用对数轴容纳四次方的跨度。"""
|
||||
rows = load_practice()
|
||||
fig, ax = plt.subplots(figsize=(7.6, 3.6))
|
||||
styles = {3: (INK, "o", "问题三:全向源"), 4: (GOLD, "s", "问题四:混合源")}
|
||||
for problem, (color, marker, label) in styles.items():
|
||||
subset = [r for r in rows if r["problem_no"] == problem]
|
||||
times = [r["virtual_time_s"] for r in subset]
|
||||
ax.scatter(times, [problem + (0.13 if r["complete"] else -0.13) for r in subset],
|
||||
color=color, marker=marker, s=34, label=label, zorder=3)
|
||||
ok = [r["virtual_time_s"] for r in subset if r["complete"]]
|
||||
mean = sum(ok) / len(ok)
|
||||
ax.plot([mean, mean], [problem - 0.28, problem + 0.28], color=color, linewidth=2.2, zorder=4)
|
||||
ax.text(mean, problem + 0.34, "均值 %.0f s" % mean, color=color, fontsize=8, ha="center")
|
||||
ax.set_xscale("log")
|
||||
ax.set_yticks([3, 4])
|
||||
ax.set_yticklabels(["问题三\n(全向源)", "问题四\n(混合源)"])
|
||||
ax.set_ylim(2.5, 4.6)
|
||||
ax.set_xlabel("总虚拟时间 / s")
|
||||
ax.grid(axis="x", color="#E5E5E5", linewidth=0.7)
|
||||
ax.set_axisbelow(True)
|
||||
ax.legend(frameon=False, fontsize=8, ncol=2, loc="lower center",
|
||||
bbox_to_anchor=(0.5, -0.46))
|
||||
save(fig, "practice-virtual-time.png")
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
fig_formal_total()
|
||||
fig_formal_time_bars()
|
||||
fig_practice_total()
|
||||
fig_flow_overall()
|
||||
fig_framework()
|
||||
fig_state_machine()
|
||||
fig_timing_attachment()
|
||||
fig_q1_counterexample()
|
||||
fig_q1_meetup()
|
||||
fig_q1_geometry()
|
||||
fig_q2_safe_disk()
|
||||
fig_q3_coverage()
|
||||
fig_q4_plan()
|
||||
fig_selfbuilt_path(practice_macro("PractQThreeMainCase"),
|
||||
"自建模拟器:全向源搜索与清除路径",
|
||||
"selfbuilt-q3-search-clear-path.png")
|
||||
fig_selfbuilt_path(
|
||||
practice_macro("PractQFourMainCase"),
|
||||
"自建模拟器:混合源搜索与清除路径(含定向源前向半盘)",
|
||||
"selfbuilt-q4-search-clear-path.png",
|
||||
guide="定向源读图约定:绿箭头=发射朝向,\n"
|
||||
"浅绿细线扇形=该源 ±90° 可见范围;\n"
|
||||
"每条细射线=一次检测得到的示向度")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def load_selfbuilt_session(case_code):
|
||||
paths = [ROOT / "referee-data" / "sessions" / case_code / "log.jsonl"]
|
||||
replay_dir = ROOT / "examples" / "replay"
|
||||
if replay_dir.exists():
|
||||
paths += sorted(replay_dir.glob(case_code + "*.jsonl"))
|
||||
for path in paths:
|
||||
if path.exists():
|
||||
data = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip():
|
||||
data.append(json.loads(line))
|
||||
truth = next((ev.get("truth", []) for ev in data if ev.get("event") == "session_start"), [])
|
||||
actions = [ev for ev in data if ev.get("event") == "action"]
|
||||
return truth, actions
|
||||
raise FileNotFoundError(case_code)
|
||||
|
||||
|
||||
RAY_MAX_M = 620.0 # 示向度射线显示长度上限:过长会盖住轨迹,只画到能读出方向为止
|
||||
FAN_RADIUS_M = 260.0 # 检测点处示意接收角域的扇形半径
|
||||
SRC_FAN_RADIUS_M = 300.0 # 定向源前向半盘示意半径:过大即与站点/轨迹重叠
|
||||
|
||||
|
||||
def selfbuilt_path_data(actions):
|
||||
"""把行为日志整理成画图所需的序列。
|
||||
|
||||
返回轨迹、逐次检测(位置、结果、示向度)、成功与失败清除点。
|
||||
检测结果是画图的关键:``direction`` 反映测到了示向度,
|
||||
``near`` 表示已进入 5 m 可直接清除,``no_signal`` 表示该点收不到信号。
|
||||
"""
|
||||
path = [(0.0, 0.0)]
|
||||
measures = []
|
||||
clears_ok = []
|
||||
clears_fail = []
|
||||
pos = (0.0, 0.0)
|
||||
for ev in actions:
|
||||
req = ev.get("request") or {}
|
||||
res = ev.get("response") or {}
|
||||
if "position" in req:
|
||||
pos = (float(req["position"]["x"]), float(req["position"]["y"]))
|
||||
if path[-1] != pos:
|
||||
path.append(pos)
|
||||
if ev.get("path") == "/measure":
|
||||
measures.append({"xy": pos,
|
||||
"result": res.get("measure_result", ""),
|
||||
"bearing": res.get("svd_deg"),
|
||||
"channel": req.get("channel")})
|
||||
elif ev.get("path") == "/clear":
|
||||
if res.get("clear_result") == "success":
|
||||
clears_ok.append(pos)
|
||||
elif "clear_result" in res:
|
||||
clears_fail.append(pos)
|
||||
return np.array(path, dtype=float), measures, clears_ok, clears_fail
|
||||
|
||||
|
||||
def draw_source(ax, src, fan_radius=SRC_FAN_RADIUS_M, annotate=False):
|
||||
"""画一个干扰源;定向源额外画出朝向箭头与其 ±90° 前向半盘。
|
||||
|
||||
``annotate`` 只在需要解释读图约定时置真,且每个源只写一次角度标注,
|
||||
避免同一句话在图上重复出现。
|
||||
"""
|
||||
x, y = float(src["x"]), float(src["y"])
|
||||
if not src.get("directional"):
|
||||
return
|
||||
heading = float(src.get("heading_deg", 0.0))
|
||||
ax.add_patch(Wedge((x, y), fan_radius, heading - 90, heading + 90,
|
||||
facecolor=GREEN, edgecolor=GREEN, alpha=0.16,
|
||||
linewidth=0.7, zorder=2))
|
||||
ax.add_patch(FancyArrowPatch((x, y),
|
||||
(x + fan_radius * 0.95 * math.cos(math.radians(heading)),
|
||||
y + fan_radius * 0.95 * math.sin(math.radians(heading))),
|
||||
arrowstyle="-|>", mutation_scale=9, linewidth=1.2,
|
||||
color=GREEN, zorder=4))
|
||||
if annotate:
|
||||
ax.annotate(f"{heading:.0f}°", xy=(x, y),
|
||||
xytext=(x - 70, y + fan_radius * 1.12),
|
||||
fontsize=7.5, color=GREEN, ha="right",
|
||||
arrowprops=dict(arrowstyle="-", color=GREEN, linewidth=0.6,
|
||||
shrinkA=1, shrinkB=3))
|
||||
|
||||
|
||||
def draw_reading_guide(ax, text, anchor=(0.015, 0.985)):
|
||||
"""在坐标区一角写读图约定,替代散落在图上的大段解释文字。"""
|
||||
ax.text(anchor[0], anchor[1], text, transform=ax.transAxes,
|
||||
fontsize=7.5, color=GREEN, va="top", ha="left",
|
||||
bbox=dict(boxstyle="round,pad=0.35", facecolor="white",
|
||||
edgecolor=GREEN, linewidth=0.6, alpha=0.92), zorder=10)
|
||||
|
||||
|
||||
def draw_measures(ax, measures, ray_color=None):
|
||||
"""画检测点与示向度射线,并按检测结果区分标记。
|
||||
|
||||
射线长度取固定上限:既能读出方向,又不会像原来那样铺满整张图掩盖轨迹。
|
||||
线宽与不透明度按印刷可辨识度选取——早期版本用 0.5 pt/0.5 太细,
|
||||
缩到论文栏宽后几乎看不见,这里统一加粗到 1.1 pt 并提高对比度。
|
||||
"""
|
||||
ray_color = ray_color or "#7FA6C4"
|
||||
for item in measures:
|
||||
x, y = item["xy"]
|
||||
if item["result"] == "direction" and item["bearing"] is not None:
|
||||
ang = math.radians(float(item["bearing"]))
|
||||
unit = np.array([math.cos(ang), math.sin(ang)])
|
||||
ax.plot([x, x + unit[0] * RAY_MAX_M], [y, y + unit[1] * RAY_MAX_M],
|
||||
color=ray_color, linewidth=1.1, alpha=0.75, zorder=1,
|
||||
solid_capstyle="round")
|
||||
kinds = {
|
||||
"direction": dict(marker="o", size=16, color=INK, label="检测到示向度(含射线)"),
|
||||
"near": dict(marker="D", size=18, color=GOLD, label="检测到近距离"),
|
||||
"no_signal": dict(marker="x", size=12, color="#9A9A9A", label="检测为无信号"),
|
||||
}
|
||||
for result, style in kinds.items():
|
||||
subset = [m for m in measures if m["result"] == result]
|
||||
if not subset:
|
||||
continue
|
||||
xs = [m["xy"][0] for m in subset]
|
||||
ys = [m["xy"][1] for m in subset]
|
||||
ax.scatter(xs, ys, marker=style["marker"], s=style["size"], color=style["color"],
|
||||
linewidths=0.9, label=f"{style['label']}({len(subset)} 次)", zorder=6)
|
||||
|
||||
|
||||
def fig_selfbuilt_path(case_code, title, outname, guide=None):
|
||||
"""画一局演练的搜索与清除路径。
|
||||
|
||||
``guide`` 只在需要解释定向源读图约定时传入;全向源局不画扇形符号,
|
||||
以免出现图上没有对应元素的图例文字。
|
||||
"""
|
||||
truth, actions = load_selfbuilt_session(case_code)
|
||||
path, measures, clears_ok, clears_fail = selfbuilt_path_data(actions)
|
||||
fig, ax = plt.subplots(figsize=(8.6, 7.4))
|
||||
ax.add_patch(Circle((0, 0), 1800, fill=False, edgecolor=INK, linewidth=1.2, zorder=2))
|
||||
ax.text(0, 1830, "Ω", ha="center", va="bottom", fontsize=9)
|
||||
for src in truth:
|
||||
draw_source(ax, src, annotate=bool(src.get("directional")))
|
||||
ax.scatter([float(s["x"]) for s in truth], [float(s["y"]) for s in truth],
|
||||
marker="X", s=70, color=GOLD, label="干扰源真值", zorder=7)
|
||||
ax.plot(path[:, 0], path[:, 1], color=INK, linewidth=1.4, alpha=0.95,
|
||||
label="机器狗轨迹", zorder=5)
|
||||
ax.scatter(path[0, 0], path[0, 1], marker="o", s=45, color=GREEN, label="起点", zorder=8)
|
||||
ax.scatter(path[-1, 0], path[-1, 1], marker="s", s=45, color=GRAY, label="终点", zorder=8)
|
||||
draw_measures(ax, measures)
|
||||
if guide:
|
||||
draw_reading_guide(ax, guide)
|
||||
if clears_ok:
|
||||
xy = np.array(clears_ok)
|
||||
ax.scatter(xy[:, 0], xy[:, 1], marker="*", s=95, color=GREEN,
|
||||
edgecolor="white", linewidth=0.5, label="成功清除点", zorder=9)
|
||||
if clears_fail:
|
||||
xy = np.array(clears_fail)
|
||||
ax.scatter(xy[:, 0], xy[:, 1], marker="x", s=45, color="#B33A3A",
|
||||
label="清除失败点", zorder=9)
|
||||
ax.set_aspect("equal")
|
||||
ax.set_xlim(-2150, 2150)
|
||||
ax.set_ylim(-2150, 2150)
|
||||
ax.set_xlabel("x / m")
|
||||
ax.set_ylabel("y / m")
|
||||
ax.set_title(title, fontsize=11)
|
||||
ax.grid(True, color="#EEEEEE", linewidth=0.6)
|
||||
ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), frameon=False, fontsize=8)
|
||||
save(fig, outname)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user