Files
mcm-26-b/scripts/plot_paper_figures.py
T
2026-09-13 14:38:31 +08:00

355 lines
16 KiB
Python

# -*- 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()