图表换正式数据

This commit is contained in:
2026-09-13 14:38:31 +08:00
parent 705c4c9b6a
commit 9fb15f53f5
28 changed files with 527 additions and 409 deletions
+354
View File
@@ -0,0 +1,354 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import csv, json, math
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Arc, Circle, FancyArrowPatch, Polygon, Rectangle, Wedge
ROOT = Path(__file__).resolve().parents[1]
FIG = ROOT / "docs" / "figures"
FIG.mkdir(parents=True, exist_ok=True)
INK = "#234C70"
GRAY = "#676767"
LIGHT = "#9DB7CA"
PALE = "#D9E4EC"
GOLD = "#B58B3A"
GREEN = "#4E7A55"
plt.rcParams.update({"font.sans-serif": ["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):
fig.savefig(FIG / name, bbox_inches="tight", facecolor="white")
plt.close(fig)
def load_formal():
rows = []
path = ROOT / "docs" / "paper" / "data" / "formal-results.csv"
with path.open(encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
for key in ("problem_no", "formal_index", "cleared_jammer_count",
"measure_accepted_count", "channel_switch_count",
"clear_failure_count"):
row[key] = int(row[key])
for key in ("virtual_time_s", "program_run_duration_s",
"inferred_movement_time_s"):
row[key] = float(row[key])
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()
labels = ["Q%d-%d" % (r["problem_no"], r["formal_index"]) for r in rows]
move = np.array([r["inferred_movement_time_s"] for r in rows])
measure = np.array([5.0 * r["measure_accepted_count"] for r in rows])
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.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):
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)
def arrow(ax, p, q, color=GRAY):
ax.add_patch(FancyArrowPatch(p, q, arrowstyle="-|>", mutation_scale=10,
linewidth=1.0, color=color))
def fig_flow_overall():
fig, ax = plt.subplots(figsize=(8.0, 4.8))
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)
for px in (1.55, 3.95, 6.35):
arrow(ax, (px, 4.0), (px, 3.7))
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)
save(fig, "flow-overall-architecture.png")
def fig_framework():
fig, ax = plt.subplots(figsize=(5.8, 4.2))
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)
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")]
for x, t in xs:
box(ax, x, 0.6, 1.5, 0.8, t)
for x in (1.6, 3.6, 5.6):
arrow(ax, (x, 1.0), (x + 0.2, 1.0))
ax.set_xlim(0, 7.4); ax.set_ylim(0, 2.0)
save(fig, "steps-state-machine.png")
def fig_timing_attachment():
steps = ["step 1", "step 2", "step 3", "step 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")
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.legend(frameon=False)
save(fig, "timing-attachment-example-105-111-194-199.png")
def clip_halfplane(poly, a, b, c):
if len(poly) == 0:
return poly
out = []
for i in range(len(poly)):
p = poly[i]; q = poly[(i + 1) % len(poly)]
fp = a * p[0] + b * p[1] - c
fq = a * q[0] + b * q[1] - c
if fp <= 1e-9:
out.append(p)
if (fp < -1e-9 and fq > 1e-9) or (fp > 1e-9 and fq < -1e-9):
t = fp / (fp - fq)
out.append(p + t * (q - p))
return np.array(out, dtype=float)
def wedge_halfplanes(sx, sy, theta_deg, delta_deg):
theta = math.radians(theta_deg); delta = math.radians(delta_deg)
a1 = math.sin(theta - delta); b1 = -math.cos(theta - delta)
c1 = a1 * sx + b1 * sy
a2 = -math.sin(theta + delta); b2 = math.cos(theta + delta)
c2 = a2 * sx + b2 * sy
a3 = -math.cos(theta); b3 = -math.sin(theta)
c3 = a3 * sx + b3 * sy
return [(a1, b1, c1), (a2, b2, c2), (a3, b3, c3)]
def intersection_polygon(meas, bound=2500.0):
poly = np.array([[-bound, -bound], [bound, -bound], [bound, bound], [-bound, bound]], dtype=float)
for m in meas:
for a, b, c in wedge_halfplanes(m["x"], m["y"], m["bearing_deg"], m["half_angle_deg"]):
poly = clip_halfplane(poly, a, b, c)
if len(poly) == 0:
return poly
return poly
def pair_circle(p, q):
c = (p + q) / 2.0
r = np.linalg.norm(p - q) / 2.0
return c, r
def contains_all(c, r, pts, tol=1e-6):
return all(np.linalg.norm(p - c) <= r + tol for p in pts)
def circumcircle(p, q, r):
ax, ay = p; bx, by = q; cx, cy = r
d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
if abs(d) < 1e-12:
return None
ux = ((ax*ax + ay*ay) * (by - cy) + (bx*bx + by*by) * (cy - ay) + (cx*cx + cy*cy) * (ay - by)) / d
uy = ((ax*ax + ay*ay) * (cx - bx) + (bx*bx + by*by) * (ax - cx) + (cx*cx + cy*cy) * (bx - ax)) / d
c = np.array([ux, uy])
return c, np.linalg.norm(c - p)
def min_enclosing_circle(pts):
pts = np.asarray(pts, dtype=float)
best_c = pts[0]; best_r = 0.0
for i in range(len(pts)):
for j in range(i + 1, len(pts)):
c, r = pair_circle(pts[i], pts[j])
if contains_all(c, r, pts) and r < best_r or (best_r == 0.0 and contains_all(c, r, pts)):
best_c, best_r = c, r
for i in range(len(pts)):
for j in range(i + 1, len(pts)):
for k in range(j + 1, len(pts)):
cc = circumcircle(pts[i], pts[j], pts[k])
if cc is None:
continue
c, r = cc
if contains_all(c, r, pts) and r < best_r:
best_c, best_r = c, r
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 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]
for m in meas:
x, y = m["x"], m["y"]; th = math.radians(m["bearing_deg"]); d = math.radians(m["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)
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.set_xlabel("x / m"); ax2.set_ylabel("y / m")
save(fig, "geo-q1-convex-intersection-zoom.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
fig, axes = plt.subplots(1, 2, figsize=(8.0, 3.6))
ax = axes[0]
ax.plot([0, rp * math.cos(delta)], [0, rp * math.sin(delta)], color=GRAY, linewidth=0.8)
ax.plot([0, rp * math.cos(delta)], [0, -rp * math.sin(delta)], color=GRAY, linewidth=0.8)
ax.add_patch(Circle((a, 0), a, fill=False, edgecolor=LIGHT, linestyle="--", linewidth=1.2))
ax.add_patch(Circle((a, 0), safe, facecolor=PALE, edgecolor=INK, linewidth=1.2, alpha=0.8))
ax.scatter([0], [0], color=GRAY, s=12); ax.scatter([a], [0], color=INK, s=12)
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 = 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.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"))
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")
save(fig, "geo-q4-minimum-cover-31-points.png")
def main():
fig_formal_total()
fig_formal_time_bars()
fig_flow_overall()
fig_framework()
fig_state_machine()
fig_timing_attachment()
fig_q1_counterexample()
fig_q1_meetup()
fig_q2_safe_disk()
fig_q3_coverage()
fig_q4_plan()
if __name__ == "__main__":
main()