#!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse, re, json, sys from pathlib import Path import numpy as np import pandas as pd import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import re # from matplotlib.transforms import Bbox plt.rcParams.update({ "font.size": 12, # base font size "axes.titlesize": 14, # plot titles "axes.labelsize": 12, # x/y labels "xtick.labelsize": 10, "ytick.labelsize": 10, "legend.fontsize": 12, }) # ---------- Config ---------- RES_COLS = { "p50": "clean_absMED_ns", "p95": "clean_absP95_ns", "p99": "clean_absP99_ns", "mad": "clean_absMAD_ns", "fraction": "fraction_kept", } LOWER_BETTER = {"p50","p95","p99","mad"} HIGHER_BETTER = {"fraction"} HOP_MAP_DEFAULT = {"apu00":0,"apu01":1,"apu02":2,"apu03":3,"apu04":4,"apu09":5,"apu14":6,"apu19":7,"apu24":8} WEIGHTS = {"p95":0.40, "p99":0.25, "p50":0.20, "mad":0.10, "fraction":0.05} ANALYSIS_SUBPATH = Path("_analysis_new") / "sarb_clean_nodes.csv" OUT_SUBDIR = "sweep_analysis/compare_8hop" # ---------- Helpers: parsing ---------- SERVO_DEFAULT = "LINREG" # if not detectable, assume linreg-style (no kp/ki tokens) def parse_mode(run_id: str): """ Determine transport mode ('P2P' or 'E2E') robustly. Priority: 1) LAST 'BC_*_(P2P|E2E)' marker (handles '__BC__BC_P2P_PI__...' forms) 2) Token adjacent to PI/LINREG (either side) 3) LAST '(p2p|e2e)' anywhere (avoids leading 'e2e_ptp4l') """ s = (run_id or "") # 1) LAST BC_*_(P2P|E2E) bc_iter = list(re.finditer(r"(?i)BC[\W_]*_(P2P|E2E)(?=$|[\W_])", s)) if bc_iter: return bc_iter[-1].group(1).upper() # 2) Adjacent to PI/LINREG m = re.search(r"(?i)(?:LINREG|PI)[\W_]*(P2P|E2E)(?=$|[\W_])", s) if m: return m.group(1).upper() m = re.search(r"(?i)(P2P|E2E)[\W_]*(?:LINREG|PI)(?=$|[\W_])", s) if m: return m.group(1).upper() # 3) LAST standalone p2p/e2e token toks = list(re.finditer(r"(?i)(^|[\W_])(p2p|e2e)(?=[\W_]|$)", s)) if toks: return toks[-1].group(2).upper() return None def parse_servo(run_id: str): """Detect servo family: 'PI' vs 'LINREG'.""" s = (run_id or "") if re.search(r"(^|[\W_])PI([\W_]|$)", s, re.I): return "PI" if re.search(r"(?:^|[\W_])LINREG(?:[\W_]|$)", s, re.I): return "LINREG" return SERVO_DEFAULT def _parse_gain_from_pi(run_id: str, key: str): """ Parse kp/ki tokens typical for PI runs: kp1p00, ki0p05 (decimal 'p' separator) Accepts uppercase/lowercase and common separators around it. """ s = run_id or "" m = re.search(rf"(?i)(^|[\W_]){key}(\d+p\d+)(?=$|[\W_])", s) return float(m.group(2).replace("p", ".")) if m else None def parse_gain(run_id: str, key: str): """ Unified gain parser: - For PI runs: return parsed numeric kp/ki (falls back to 0.0 if missing). - For LINREG runs: return 0.0 so equality/grouping works (avoids NaN!=NaN). """ servo = parse_servo(run_id) if servo == "PI": val = _parse_gain_from_pi(run_id, key) return 0.0 if val is None else val # LINREG (no gains encoded) return 0.0 def parse_sync_pow(run_id: str): """ Parse sync power from 'syncm3', 'sync0', 'sync1' even when followed by '_' or other separators. Matches: '_syncm3_', '-sync0-', '…linreg_sync1_ann1…', 'syncm3' at end. """ s = run_id or "" m = re.search(r"(?i)(?:^|[\W_])sync\s*(m?\d)(?=$|[\W_])", s) if not m: return None tok = m.group(1).lower() return -int(tok[1:]) if tok.startswith("m") else int(tok) def sync_label_from_pow(p:int): return "m3" if p == -3 else str(p) # ---------- IO ---------- def load_all(sweep_dir:Path, exclude_dirs:set) -> pd.DataFrame: rows = [] for csv_path in sweep_dir.rglob(ANALYSIS_SUBPATH.as_posix()): # skip excluded dirs anywhere in path parts_lower = {p.lower() for p in csv_path.parts} if any(d.lower() in parts_lower for d in exclude_dirs): continue run_dir = csv_path.parent.parent run_dir_name = run_dir.name try: df = pd.read_csv(csv_path) # Prefer the CSV's 'run' column if present; else fallback to directory name df["run_id"] = df["run"] if "run" in df.columns else run_dir_name except Exception as e: print(f"[WARN] read fail {csv_path}: {e}", file=sys.stderr) continue if "node" not in df.columns: print(f"[WARN] missing 'node' in {csv_path}, skip", file=sys.stderr) continue need = set(RES_COLS.values()) have = set(df.columns) if not need.intersection(have): print(f"[WARN] missing residual cols in {csv_path}, skip", file=sys.stderr) continue # Parse from the per-row run_id df["mode"] = df["run_id"].apply(parse_mode) df["kp"] = df["run_id"].apply(lambda s: parse_gain(s, "kp")) df["ki"] = df["run_id"].apply(lambda s: parse_gain(s, "ki")) df["sync_pow"] = df["run_id"].apply(parse_sync_pow) rows.append(df) if not rows: raise RuntimeError("No sarb_clean_nodes.csv found (after exclusions).") return pd.concat(rows, ignore_index=True) def map_hops(df:pd.DataFrame, hopmap:dict) -> pd.DataFrame: out = df.copy() out["hop"] = out["node"].map(hopmap) out = out[out["hop"].notna()].copy() out["hop"] = out["hop"].astype(int) return out # ---------- Selection / metrics ---------- def pick_winners(avg:pd.DataFrame, threshold_ns:int): """Return dict (mode,sync_pow)->(kp,ki) winner and table of survivors at hop8.""" winners = {} surv_tbl = [] for mode in sorted(avg["mode"].dropna().unique()): for sp in sorted(avg[avg["mode"]==mode]["sync_pow"].dropna().unique()): sub = avg[(avg["mode"]==mode) & (avg["sync_pow"]==sp)] hop8 = sub[sub["hop"]==8].copy() if hop8.empty: continue if RES_COLS["p95"] not in hop8.columns: continue hop8 = hop8[hop8[RES_COLS["p95"]] <= threshold_ns] if hop8.empty: winners[(mode,int(sp))] = None continue hop8 = hop8.assign( tie_p95 = hop8[RES_COLS["p95"]].astype(float), tie_p99 = hop8[RES_COLS["p99"]].astype(float) if RES_COLS["p99"] in hop8.columns else np.inf, tie_p50 = hop8[RES_COLS["p50"]].astype(float) if RES_COLS["p50"] in hop8.columns else np.inf, tie_mad = hop8[RES_COLS["mad"]].astype(float) if RES_COLS["mad"] in hop8.columns else np.inf, tie_frac = hop8[RES_COLS["fraction"]].astype(float) if RES_COLS["fraction"] in hop8.columns else -np.inf, ).sort_values( ["tie_p95","tie_p99","tie_p50","tie_mad","tie_frac"], ascending=[True,True,True,True,False] ) best = hop8.iloc[0] winners[(mode,int(sp))] = (float(best["kp"]), float(best["ki"])) for (kp,ki), g in sub.groupby(["kp","ki"]): g8 = g[g["hop"]==8] if g8.empty: continue row = {"mode": mode, "sync_pow": int(sp), "kp": float(kp), "ki": float(ki)} for key, col in RES_COLS.items(): row[key] = float(g8[col].iloc[0]) if col in g8.columns and pd.notna(g8[col].iloc[0]) else np.nan surv_tbl.append(row) surv_df = pd.DataFrame(surv_tbl) return winners, surv_df def compute_8hr(row, ref): keys = [k for k in WEIGHTS.keys() if pd.notna(row.get(k, np.nan)) and pd.notna(ref.get(k, np.nan)) and ref.get(k,0)!=0] if not keys: return np.nan wsum = sum(WEIGHTS[k] for k in keys) score = 0.0 for k in keys: norm = (row[k] / ref[k]) if k in LOWER_BETTER else (ref[k] / row[k] if row[k] != 0 else np.inf) score += WEIGHTS[k] * norm return score / wsum # def make_radar(table, ref_label, out_dir:Path, metrics:list[str]): # labels = list(table.index) # ref = table.loc[ref_label] # def score(val, refv, m): # if pd.isna(val) or pd.isna(refv) or refv==0: return np.nan # return refv/val if m in LOWER_BETTER else val/refv # M = np.array([[score(table.loc[l,m], ref[m], m) for m in metrics] for l in labels], dtype=float) # angles = np.linspace(0, 2*np.pi, len(metrics), endpoint=False).tolist() # angles += angles[:1] # fig = plt.figure(figsize=(7,7)) # ax = plt.subplot(111, polar=True) # ax.set_theta_offset(np.pi/2); ax.set_theta_direction(-1) # ax.set_xticks(angles[:-1]); ax.set_xticklabels(metrics) # if np.isfinite(np.nanmax(M)): ax.set_ylim(0, np.nanmax(M)*1.1) # ax.set_yticklabels([]) # for i, lab in enumerate(labels): # vals = M[i].tolist(); vals += vals[:1] # if lab == ref_label: # ax.plot(angles, vals, linewidth=2.8, label=f"{lab} (ref)") # ax.fill(angles, vals, alpha=0.15) # else: # ax.plot(angles, vals, linewidth=1.6, alpha=0.9, label=lab) # ax.fill(angles, vals, alpha=0.08) # ax.legend(loc="upper right", bbox_to_anchor=(1.25,1.05)) # ax.set_title("Normalized comparison (reference = 1.0 per metric)", va="bottom") # out_dir.mkdir(parents=True, exist_ok=True) # plt.tight_layout() # plt.savefig(out_dir / "radar_8hop.png", dpi=200, bbox_inches="tight") # plt.close() # def make_radar( # table, ref_label, out_dir: Path, metrics: list[str], # ncol_e2e: int | None = None, ncol_p2p: int | None = None # ): # labels = list(table.index) # ref = table.loc[ref_label] # # normalize so ref = 1.0; larger area = better (invert lower-better) # def score(val, refv, m): # if pd.isna(val) or pd.isna(refv) or refv == 0: # return np.nan # return (refv / val) if m in LOWER_BETTER else (val / refv) # M = np.array([[score(table.loc[l, m], ref[m], m) for m in metrics] for l in labels], dtype=float) # angles = np.linspace(0, 2*np.pi, len(metrics), endpoint=False).tolist() # angles += angles[:1] # fig = plt.figure(figsize=(8.5, 8.5)) # ax = plt.subplot(111, polar=True) # ax.set_theta_offset(np.pi / 2) # ax.set_theta_direction(-1) # ax.set_xticks(angles[:-1]) # ax.set_xticklabels(metrics) # if np.isfinite(np.nanmax(M)): # ax.set_ylim(0, np.nanmax(M) * 1.1) # ax.set_yticklabels([]) # # Plot series and store handles by *logical* label (without "(ref)") # handle_by_label = {} # for i, lab in enumerate(labels): # vals = M[i].tolist(); vals += vals[:1] # # line + fill # if lab == ref_label: # (hline,) = ax.plot(angles, vals, linewidth=2.8) # ax.fill(angles, vals, alpha=0.15) # else: # (hline,) = ax.plot(angles, vals, linewidth=1.6, alpha=0.9) # ax.fill(angles, vals, alpha=0.08) # handle_by_label[lab] = hline # # Group labels # e2e_labels = [lab for lab in labels if lab.upper().startswith("E2E")] # p2p_labels = [lab for lab in labels if lab.upper().startswith("P2P")] # # Legend row widths # if ncol_e2e is None: ncol_e2e = max(1, len(e2e_labels)) # if ncol_p2p is None: ncol_p2p = max(1, len(p2p_labels)) # # Build display labels (add "(ref)" to the reference entry) # def disp_labels(group): # out = [] # for lab in group: # out.append(f"{lab} (ref)" if lab == ref_label else lab) # return out # # Figure-level legends so they don't get clipped/overwritten # if e2e_labels: # fig.legend( # [handle_by_label[l] for l in e2e_labels], # disp_labels(e2e_labels), # loc="upper center", bbox_to_anchor=(0.5, 0.98), # ncol=ncol_e2e, frameon=False, handlelength=2.6, columnspacing=1.6 # ) # if p2p_labels: # fig.legend( # [handle_by_label[l] for l in p2p_labels], # disp_labels(p2p_labels), # loc="upper center", bbox_to_anchor=(0.5, 0.93), # ncol=ncol_p2p, frameon=False, handlelength=2.6, columnspacing=1.6 # ) # # ax.set_title("Normalized comparison (reference = 1.0 per metric)", va="bottom") # # Give the stacked legends headroom # plt.subplots_adjust(top=0.82) # out_dir.mkdir(parents=True, exist_ok=True) # plt.savefig(out_dir / "radar_8hop.png", dpi=200, bbox_inches="tight") # plt.close() # def make_radar( # table, ref_label, out_dir: Path, metrics: list[str], # panel_rect=(0.05, 0.86, 0.90, 0.11), # label_fontsize=10, # col_compact=0.72, # line_len=0.22, # label_above=True, # <-- NEW: put text above the line (swap if False) # text_gap=0.18, # ): # labels = list(table.index) # ref = table.loc[ref_label] # def score(val, refv, m): # if pd.isna(val) or pd.isna(refv) or refv == 0: return np.nan # return (refv/val) if m in LOWER_BETTER else (val/refv) # M = np.array([[score(table.loc[l, m], ref[m], m) for m in metrics] for l in labels], dtype=float) # angles = np.linspace(0, 2*np.pi, len(metrics), endpoint=False).tolist() # angles += angles[:1] # fig = plt.figure(figsize=(8.8, 8.8)) # ax = plt.subplot(111, polar=True) # ax.set_theta_offset(np.pi/2); ax.set_theta_direction(-1) # ax.set_xticks(angles[:-1]); ax.set_xticklabels(metrics) # if np.isfinite(np.nanmax(M)): ax.set_ylim(0, np.nanmax(M)*1.1) # ax.set_yticklabels([]) # handle_by_label = {} # for i, lab in enumerate(labels): # vals = M[i].tolist(); vals += vals[:1] # if lab == ref_label: # (hline,) = ax.plot(angles, vals, linewidth=2.8) # ax.fill(angles, vals, alpha=0.15) # else: # (hline,) = ax.plot(angles, vals, linewidth=1.6, alpha=0.9) # ax.fill(angles, vals, alpha=0.08) # handle_by_label[lab] = hline # # ---- aligned legend grid ---- # e2e_labels = [lab for lab in labels if lab.upper().startswith("E2E")] # p2p_labels = [lab for lab in labels if lab.upper().startswith("P2P")] # def disp(lab): return f"{lab} (ref)" if lab == ref_label else lab # ncols = max(len(e2e_labels), len(p2p_labels), 1) # e2e_padded = e2e_labels + [""]*(ncols - len(e2e_labels)) # p2p_padded = p2p_labels + [""]*(ncols - len(p2p_labels)) # L, B, W, H = panel_rect # legend_ax = fig.add_axes([L, B, W, H]) # legend_ax.set_axis_off() # # compress the horizontal domain to make cells narrower # total_width = ncols * col_compact # legend_ax.set_xlim(0, total_width) # legend_ax.set_ylim(0, 2) # # centers of columns with compact spacing # xs = np.linspace(col_compact/2, total_width - col_compact/2, ncols) # def draw_cell(x_center, y_mid, label): # if not label: return # base_label = label.replace(" (ref)", "") # h = handle_by_label.get(base_label) # if h is None: return # color = h.get_color() # lw = h.get_linewidth() # alpha = h.get_alpha() if h.get_alpha() is not None else 1.0 # # positions for text and line around the cell's midline # if label_above: # y_text = y_mid + text_gap # y_line = y_mid # va_text = "bottom" # else: # y_text = y_mid - text_gap # y_line = y_mid # va_text = "top" # # text # legend_ax.text(x_center, y_text, disp(label), # ha="center", va=va_text, fontsize=label_fontsize) # # color line # legend_ax.plot([x_center - line_len, x_center + line_len], # [y_line, y_line], linewidth=lw, color=color, alpha=alpha, solid_capstyle="round") # # optional row tags # # legend_ax.text(-0.12, 1.5, "E2E", ha="right", va="center", fontsize=label_fontsize, fontweight="bold") # # legend_ax.text(-0.12, 0.5, "P2P", ha="right", va="center", fontsize=label_fontsize, fontweight="bold") # for x, top, bot in zip(xs, e2e_padded, p2p_padded): # if top: draw_cell(x, 1.5, top) # if bot: draw_cell(x, 0.5, bot) # # leave room for the panel # plt.subplots_adjust(top=B - 0.05) # out_dir.mkdir(parents=True, exist_ok=True) # # plt.savefig(out_dir / "radar_8hop.png", dpi=200, bbox_inches="tight") # plt.savefig(out_dir / "radar_8hop.png", dpi=400, bbox_inches="tight", pad_inches=0.00) # plt.close() def make_radar( table, ref_label, out_dir: Path, metrics: list[str], panel_rect=(0.05, 0.86, 0.90, 0.11), label_fontsize=14, col_compact=0.82, line_len=0.22, label_above=True, # put text above the line (swap if False) text_gap=0.05, # NEW: metric_texts: dict[str, str] | None = None, # custom axis labels per metric key metric_fontsize: int = 18, # font size for axis labels ): labels = list(table.index) ref = table.loc[ref_label] def score(val, refv, m): if pd.isna(val) or pd.isna(refv) or refv == 0: return np.nan return (refv/val) if m in LOWER_BETTER else (val/refv) M = np.array([[score(table.loc[l, m], ref[m], m) for m in metrics] for l in labels], dtype=float) angles = np.linspace(0, 2*np.pi, len(metrics), endpoint=False).tolist() angles += angles[:1] fig = plt.figure(figsize=(8.8, 8.8)) ax = plt.subplot(111, polar=True) ax.set_theta_offset(np.pi/2); ax.set_theta_direction(-1) # --- metric tick labels (customizable) --- if metric_texts is None: # sensible defaults; use mathtext & line breaks if you like metric_texts = { "p50": r"$P_{50}$", "p95": r"$P_{95}$", "p99": r"$P_{99}$", "mad": "MAD ", "fraction": "F ", } tick_labels = [metric_texts.get(m, m) for m in metrics] ax.set_xticks(angles[:-1]) ax.set_xticklabels(tick_labels) for t in ax.get_xticklabels(): t.set_fontsize(metric_fontsize) t.set_ha("center"); t.set_va("center") # allow multi-line labels like "MAD\n(ns)" try: t.set_multialignment("center") except Exception: pass if np.isfinite(np.nanmax(M)): ax.set_ylim(0, np.nanmax(M)*1.1) ax.set_yticklabels([]) handle_by_label = {} for i, lab in enumerate(labels): vals = M[i].tolist(); vals += vals[:1] if lab == ref_label: (hline,) = ax.plot(angles, vals, linewidth=2.8) ax.fill(angles, vals, alpha=0.15) else: (hline,) = ax.plot(angles, vals, linewidth=1.6, alpha=0.9) ax.fill(angles, vals, alpha=0.08) handle_by_label[lab] = hline # ---- aligned legend grid ---- e2e_labels = [lab for lab in labels if lab.upper().startswith("E2E")] p2p_labels = [lab for lab in labels if lab.upper().startswith("P2P")] # def disp(lab): return f"{lab} (ref)" if lab == ref_label else lab # ---------- Helpers: legend ---------- def taufy(lab: str) -> str: SYNC_TO_TAU = {"m3": "0.125s", "0": "1s", "1": "2s"} # adjust to your truth """ Convert 'E2E m3' -> 'E2E $\\tau$=m3' (and same for P2P). Leaves unrecognized labels unchanged. """ m = re.match(r"^(E2E|P2P)\s+(.+)$", lab.strip(), re.IGNORECASE) if not m: return lab mode, sync = m.groups() # return f"{mode} $\\tau$={sync}" return f"{mode} $\\tau_{{{'sync'}}}$={SYNC_TO_TAU.get(sync, sync)}" def disp(lab: str) -> str: base = taufy(lab) return f"{base} (ref)" if lab == ref_label else base ncols = max(len(e2e_labels), len(p2p_labels), 1) e2e_padded = e2e_labels + [""]*(ncols - len(e2e_labels)) p2p_padded = p2p_labels + [""]*(ncols - len(p2p_labels)) L, B, W, H = panel_rect legend_ax = fig.add_axes([L, B, W, H]) legend_ax.set_axis_off() # compress the horizontal domain to make cells narrower total_width = ncols * col_compact legend_ax.set_xlim(0, total_width) legend_ax.set_ylim(0, 2) # centers of columns with compact spacing xs = np.linspace(col_compact/2, total_width - col_compact/2, ncols) def draw_cell(x_center, y_mid, label): if not label: return base_label = label.replace(" (ref)", "") h = handle_by_label.get(base_label) if h is None: return color = h.get_color() lw = h.get_linewidth() alpha = h.get_alpha() if h.get_alpha() is not None else 1.0 # positions for text and line around the cell's midline if label_above: y_text = y_mid + text_gap; va_text = "bottom" else: y_text = y_mid - text_gap; va_text = "top" y_line = y_mid # text legend_ax.text(x_center, y_text, disp(label), ha="center", va=va_text, fontsize=label_fontsize) # color line legend_ax.plot([x_center - line_len, x_center + line_len], [y_line, y_line], linewidth=lw, color=color, alpha=alpha, solid_capstyle="round") # optional row tags # legend_ax.text(-0.12, 1.5, "E2E", ha="right", va="center", fontsize=label_fontsize, fontweight="bold") # legend_ax.text(-0.12, 0.5, "P2P", ha="right", va="center", fontsize=label_fontsize, fontweight="bold") for x, top, bot in zip(xs, e2e_padded, p2p_padded): if top: draw_cell(x, 1.5, top) if bot: draw_cell(x, 0.5, bot) # leave room for the panel plt.subplots_adjust(top=B - 0.04) out_dir.mkdir(parents=True, exist_ok=True) plt.savefig(out_dir / "radar_8hop.png", dpi=400, bbox_inches="tight", pad_inches=0.00) plt.close() # ---------- Main ---------- def main(): ap = argparse.ArgumentParser(description="Compare best (kp,ki) per transport×sync at hop-8.") ap.add_argument("sweep_dir", type=Path, help="Path to the unzipped sweep folder.") ap.add_argument("--threshold-ns", type=int, default=10_000, help="Survivor rule at hop-8 (p95 ≤ threshold). Default 10000 ns.") ap.add_argument("--hop-map-json", type=Path, default=None, help="Optional node->hop JSON.") ap.add_argument("--exclude-dir", action="append", default=["unused"], help="Folder names to exclude (repeatable).") ap.add_argument("--reference", type=str, default="P2P m3", help='Reference label: "E2E m3", "E2E 0", "E2E 1", "P2P m3", "P2P 0", "P2P 1"') args = ap.parse_args() sweep_dir = args.sweep_dir.resolve() out_dir = sweep_dir / OUT_SUBDIR out_dir.mkdir(parents=True, exist_ok=True) hopmap = HOP_MAP_DEFAULT if args.hop_map_json: try: hopmap = json.loads(Path(args.hop_map_json).read_text()) except Exception as e: print(f"[WARN] hop map JSON read failed, using default: {e}", file=sys.stderr) # 1) Load + map hops df = load_all(sweep_dir, set(args.exclude_dir or [])) df = map_hops(df, hopmap) # 2) Average per (mode,sync,kp,ki,hop) in case duplicates exist grp_keys = ["mode","sync_pow","kp","ki","hop"] metrics_cols = [c for c in RES_COLS.values() if c in df.columns] avg = df.groupby(grp_keys, dropna=False)[metrics_cols].mean().reset_index() # 3) Pick winners per combo winners, survivors = pick_winners(avg, args.threshold_ns) # 4) Build 6-row table from winners (extract hop-8 metrics) rows = [] labels = [] for mode in ["E2E","P2P"]: for sp in [-3,0,1]: label = f"{mode} {sync_label_from_pow(sp)}" labels.append(label) winner = winners.get((mode,sp), None) row = {"label": label, "mode": mode, "sync_pow": sp, "kp": np.nan, "ki": np.nan} if winner is None: for m in RES_COLS.keys(): row[m] = np.nan else: kp, ki = winner; row["kp"]=kp; row["ki"]=ki hop8 = avg[(avg["mode"]==mode)&(avg["sync_pow"]==sp)&(avg["kp"]==kp)&(avg["ki"]==ki)&(avg["hop"]==8)] if hop8.empty: for m in RES_COLS.keys(): row[m]=np.nan else: r = hop8.iloc[0] for key, col in RES_COLS.items(): row[key] = float(r[col]) if col in hop8.columns and pd.notna(r[col]) else np.nan rows.append(row) table = pd.DataFrame(rows).set_index("label") # 5) Compute Δ% vs reference and 8HR ref_label = args.reference if ref_label not in table.index: print(f"[WARN] reference '{ref_label}' not found among labels; will use first non-NaN row.", file=sys.stderr) non_na = table.dropna(subset=["p95"]) if non_na.empty: raise RuntimeError("No valid rows to use as reference (all metrics NaN).") ref_label = non_na.index[0] ref = table.loc[ref_label] # deltas for m in RES_COLS.keys(): if m in LOWER_BETTER: table[f"{m}_deltapct"] = (table[m] - ref[m]) / ref[m] * 100.0 else: table[f"{m}_deltapct"] = (ref[m] - table[m]) / ref[m] * 100.0 # 8HR index table["8HR"] = table.apply(lambda r: compute_8hr(r, ref), axis=1) # 6) Save outputs # winners list wrows = [] for (mode, sp), best in winners.items(): wrows.append({"mode":mode,"sync_pow":sp,"label":f"{mode} {sync_label_from_pow(sp)}", "kp": None if best is None else best[0], "ki": None if best is None else best[1]}) pd.DataFrame(wrows).to_csv(out_dir/"winners.csv", index=False) # pretty export pretty = table.copy() # format raw values for m in ["p50","p95","p99","mad"]: if m in pretty.columns: pretty[m] = pretty[m].map(lambda x: f"{x:.0f}" if pd.notna(x) else "NA") if "fraction" in pretty.columns: pretty["fraction"] = pretty["fraction"].map(lambda x: f"{x:.2f}" if pd.notna(x) else "NA") for m in ["p50","p95","p99","mad","fraction"]: dp = f"{m}_deltapct" if dp in pretty.columns: pretty[dp] = pretty[dp].map(lambda x: f"{x:+.1f}%" if pd.notna(x) else "NA") pretty["8HR"] = pretty["8HR"].map(lambda x: f"{x:.3f}" if pd.notna(x) else "NA") pretty.to_csv(out_dir/"comparison_8hop.csv", index=True) # LaTeX with open(out_dir/"comparison_8hop.tex","w") as fh: fh.write("% Reference: " + ref_label + "\n") fh.write(pretty.to_latex(escape=True, index=True, na_rep="NA")) # Radar metrics_for_radar = ["p50","p95","p99","mad","fraction"] radar_tbl = table[metrics_for_radar].copy() radar_tbl = radar_tbl.loc[~radar_tbl.isna().all(axis=1)] if not radar_tbl.empty and ref_label in radar_tbl.index: make_radar(radar_tbl, ref_label, out_dir, metrics_for_radar) # make_radar(radar_tbl, ref_label, out_dir, metrics_for_radar, ncol_e2e=3, ncol_p2p=3) print(f"✓ Wrote outputs to: {out_dir}") print(" - winners.csv") print(" - comparison_8hop.csv") print(" - comparison_8hop.tex") print(" - radar_8hop.png") print(f"Reference: {ref_label}") if __name__ == "__main__": main()