brush-strokes-0.1.0.0: src/arc-length/bench/plot_bench.py
"""
plot_bench.py – Visualise arc-length benchmark results from bench_results.csv
Usage:
python plot_bench.py [bench_results.csv]
Produces one PNG per benchmark group, e.g.:
bench_Quadratic_Beziers.png
bench_Cubic_Beziers.png
Layout (per figure)
-------------------
One panel per test case arranged in a grid.
Within each panel:
• Bars going UP = time (µs, log scale)
• Bars going DOWN = relative error (log scale, flipped)
Each half is independently normalised to the same pixel height, so both
halves always use their full visual space.
Colours / variants
------------------
Colour family = method family (Polyline / Romberg / CC / TanhSinh / GL)
Shade within family = base parameters (coarser = lighter)
Edge overlay = [S] split variant (thick coloured border)
Edge + xhatch = [S][R:√] split+regularised variant
Error bars = same colour, reduced alpha, soft same-colour hatching
"""
import sys, re, csv, math
from pathlib import Path
from collections import defaultdict
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.ticker as mticker
# ──────────────────────────────────────────────────────────────────────────────
# Colour helpers
# ──────────────────────────────────────────────────────────────────────────────
FAMILY_HUE = {
"Polyline": "#59a14f",
"Gravesen": "#76b7b2",
"Romberg": "#f28e2b",
"Gauss-Legendre": "#e15759",
"Clenshaw-Curtis": "#4e79a7",
"TanhSinh": "#b07aa1",
}
def _lighten(h, t):
r,g,b = int(h[1:3],16), int(h[3:5],16), int(h[5:7],16)
return "#{:02x}{:02x}{:02x}".format(int(r+(255-r)*t), int(g+(255-g)*t), int(b+(255-b)*t))
def _darken(h, t):
r,g,b = int(h[1:3],16), int(h[3:5],16), int(h[5:7],16)
return "#{:02x}{:02x}{:02x}".format(int(r*(1-t)), int(g*(1-t)), int(b*(1-t)))
# ──────────────────────────────────────────────────────────────────────────────
# Method parsing
# ──────────────────────────────────────────────────────────────────────────────
def _family(label):
if label.startswith("Polyline"): return "Polyline"
if label.startswith("Romberg"): return "Romberg"
if label.startswith("Clenshaw"): return "Clenshaw-Curtis"
if label.startswith("TanhSinh"): return "TanhSinh"
if label.startswith("Gauss"): return "Gauss-Legendre"
if label.startswith("Gravesen"): return "Gravesen"
return "Other"
def _parse(label):
"""Returns (family, base_label, has_split, has_reg)."""
has_split = "[S]" in label
has_reg = "[R:√]" in label
base = label.replace(" [R:√]", "").replace(" [S]", "").strip()
return _family(base), base, has_split, has_reg
def build_colour_map(all_labels):
"""
Assign fill colour by (family, base_label).
Within a family, earlier (coarser) base params get a lighter shade.
All variants of the same base params share exactly one colour.
"""
fam_bases: dict[str, list] = defaultdict(list)
for lbl in all_labels:
_, base, _, _ = _parse(lbl)
fam = _family(base)
if base not in fam_bases[fam]:
fam_bases[fam].append(base)
base_col: dict[str, str] = {}
for fam, bases in fam_bases.items():
hue = FAMILY_HUE.get(fam, "#999999")
n = max(len(bases), 1)
for i, base in enumerate(bases):
fade = 0.40 * (1.0 - i / n) # coarser → lighter
base_col[base] = _lighten(hue, fade)
return {lbl: base_col[_parse(lbl)[1]] for lbl in all_labels}
# ──────────────────────────────────────────────────────────────────────────────
# CSV loading
# ──────────────────────────────────────────────────────────────────────────────
def load(path):
data = defaultdict(lambda: defaultdict(list))
with open(path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
data[row["group"]][row["case"]].append({
"method": row["method"],
"time_us": float(row["time_us"]),
"rel_error": float(row["rel_error"]),
})
return data
# ──────────────────────────────────────────────────────────────────────────────
# Axis tick helpers
# ──────────────────────────────────────────────────────────────────────────────
def nice_log_ticks(lo, hi, max_n=5):
"""Integer log10 values spanning [lo, hi], spaced to give ≤ max_n ticks."""
lo_i, hi_i = int(math.floor(lo)), int(math.ceil(hi))
step = max(1, math.ceil((hi_i - lo_i) / max_n))
start = math.ceil(lo_i / step) * step
return list(range(start, hi_i + 1, step))
def fmt_time(lv):
v = 10 ** lv # value in µs
if v < 1: return f"{v*1000:.0f} ns"
if v < 1000: return f"{v:.0f} µs"
return f"{v/1000:.1f} ms"
def fmt_err(lv):
return f"1e{int(lv)}"
# ──────────────────────────────────────────────────────────────────────────────
# Main plot routine
# ──────────────────────────────────────────────────────────────────────────────
HALF = 1.0 # each axis half (time / error) is normalised to [0, HALF]
def plot_group(group_name, cases, out_dir):
case_names = list(cases.keys())
all_methods = [r["method"] for r in next(iter(cases.values()))]
n_methods = len(all_methods)
n_cases = len(case_names)
col_map = build_colour_map(all_methods)
parsed = {m: _parse(m) for m in all_methods}
ncols = min(3, n_cases)
nrows = math.ceil(n_cases / ncols)
fig, axes = plt.subplots(
nrows, ncols,
figsize=(max(ncols * 5.5, 8), nrows * 4.8),
constrained_layout=True,
)
axes_flat = np.array(axes).reshape(-1) if n_cases > 1 else [axes]
x = np.arange(n_methods)
bar_w = 0.72
# ── Compute shared log-range across all test cases ────────────────────
all_lt, all_le = [], []
for rows in cases.values():
for r in rows:
if r["time_us"] > 0: all_lt.append(math.log10(r["time_us"]))
if r["rel_error"] > 0: all_le.append(math.log10(r["rel_error"]))
lt_lo, lt_hi = min(all_lt) - 0.3, max(all_lt) + 0.3
le_lo, le_hi = min(all_le) - 0.3, max(all_le) + 0.3
t_span = lt_hi - lt_lo
e_span = le_hi - le_lo
# ── Build explicit tick positions on the normalised axis ──────────────
#
# Time ticks: normalised position = HALF * (log_t - lt_lo) / t_span → [0, HALF]
# Error ticks: normalised position = -HALF * (le_hi - log_e) / e_span → [-HALF, 0]
#
# These are the values we pass to ax.set_yticks() — no implicit conversion
# through the axis scale at all, so no rounding/extrapolation bugs.
# Filter to strictly within the mapped range so no tick crosses the
# zero line onto the wrong half.
t_ticks = [t for t in nice_log_ticks(lt_lo, lt_hi) if lt_lo <= t <= lt_hi]
e_ticks = [e for e in nice_log_ticks(le_lo, le_hi) if le_lo <= e <= le_hi]
tick_y = ([ HALF * (t - lt_lo) / t_span for t in t_ticks] +
[-HALF * (le_hi - e) / e_span for e in e_ticks])
tick_lbl = [fmt_time(t) for t in t_ticks] + [fmt_err(e) for e in e_ticks]
# ── Draw panels ───────────────────────────────────────────────────────
for ax_idx, case_name in enumerate(case_names):
ax = axes_flat[ax_idx]
rows = cases[case_name]
lts = [math.log10(max(r["time_us"], 1e-12)) for r in rows]
les = [math.log10(max(r["rel_error"], 1e-20)) for r in rows]
bu = np.array([HALF * (t - lt_lo) / t_span for t in lts]) # height above 0
bd = np.array([HALF * (le_hi - e) / e_span for e in les]) # depth below 0
for i, m in enumerate(all_methods):
col = col_map[m]
_, _, has_split, has_reg = parsed[m]
if has_reg:
hatch, ec = "///\\\\\\", _darken(col, 0.3)
elif has_split:
hatch, ec = "///", _darken(col, 0.3)
else:
hatch, ec = None, "none"
# ── Time bar: hatch encodes variant, no border on any bar ────
ax.bar(x[i], bu[i], width=bar_w, color=col,
edgecolor=ec, linewidth=0, hatch=hatch, zorder=2)
# ── Error bar: faded colour only, no extra styling ────────────
ax.bar(x[i], -bd[i], width=bar_w, color=col, alpha=0.45,
edgecolor=ec, linewidth=0, hatch=hatch, zorder=2)
ax.axhline(0, color="#333", linewidth=0.9, zorder=4)
ax.set_xlim(-0.6, n_methods - 0.4)
ax.set_ylim(-HALF * 1.12, HALF * 1.12)
ax.set_yticks(tick_y)
ax.set_yticklabels(tick_lbl, fontsize=6.5)
ax.yaxis.set_minor_locator(mticker.NullLocator())
ax.set_title(case_name, fontsize=9, pad=3)
ax.tick_params(axis="x", bottom=False, labelbottom=False)
ax.set_facecolor("#f8f8f8")
ax.grid(axis="y", color="white", linewidth=0.7, zorder=1)
ax.text(0.01, 0.98, "time ↑", transform=ax.transAxes,
fontsize=7, va="top", color="#555")
ax.text(0.01, 0.02, "accuracy ↓", transform=ax.transAxes,
fontsize=7, va="bottom", color="#555")
for ax in axes_flat[n_cases:]:
ax.set_visible(False)
# ── Legend ────────────────────────────────────────────────────────────
handles = []
seen_bases = []
for m in all_methods:
_, base, _, _ = parsed[m]
if base not in seen_bases:
seen_bases.append(base)
handles.append(mpatches.Patch(color=col_map[m], label=base))
grey = "#b0b0b0"
grey_dk = _darken(grey, 0.3)
handles += [
mpatches.Patch(facecolor=grey, edgecolor="none", linewidth=0,
label="basic"),
mpatches.Patch(facecolor=grey, edgecolor=grey_dk, linewidth=0,
hatch="///", label="split at cusps"),
mpatches.Patch(facecolor=grey, edgecolor=grey_dk, linewidth=0,
hatch="\\\\\\", label="regularisation"),
]
fig.legend(handles=handles, loc="lower center",
ncol=min(len(handles), 5), fontsize=7.5,
frameon=False, bbox_to_anchor=(0.5, -0.04))
safe = re.sub(r"[^\w]+", "_", group_name).strip("_")
out = out_dir / f"bench_{safe}.png"
fig.suptitle(group_name, fontsize=13, y=1.01)
fig.savefig(out, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f"Saved: {out}")
# ──────────────────────────────────────────────────────────────────────────────
def main():
csv_path = sys.argv[1] if len(sys.argv) > 1 else "bench_results.csv"
out_dir = Path(csv_path).parent
data = load(csv_path)
for group_name, cases in data.items():
plot_group(group_name, cases, out_dir)
if __name__ == "__main__":
main()