| """Plotting helpers for the RL pipeline. |
| |
| Used by `build_library.py` (per-scenario voltage envelopes, batch traces, |
| acceptance summary across the library) and `evaluate.py` (per-scenario |
| controller comparison plots and aggregate roll-up across the test set). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import math |
| from pathlib import Path |
|
|
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import numpy as np |
| from scenarios import ScenarioRecord |
|
|
| from systems import V_MAX, V_MIN |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| DISPLAY_NAMES: dict[str, str] = { |
| "baseline_no_tap": "No Control", |
| "rule_based": "Droop Control", |
| "ofo": "OFO Control", |
| } |
|
|
|
|
| def _display_order_key(mode: str) -> int: |
| if mode == "baseline_no_tap": |
| return 0 |
| if mode == "rule_based" or mode.startswith("rule_based_s"): |
| return 1 |
| if mode.startswith("ppo_"): |
| return 2 |
| if mode == "ofo" or mode.startswith("ofo_"): |
| return 3 |
| return 99 |
|
|
|
|
| def _sort_modes(modes: list[str]) -> list[str]: |
| return sorted(modes, key=_display_order_key) |
|
|
|
|
| def _display_name(mode: str) -> str: |
| if mode in DISPLAY_NAMES: |
| return DISPLAY_NAMES[mode] |
| if mode.startswith("rule_based_s"): |
| return "Droop Control" |
| if mode.startswith("ppo_"): |
| return "PPO Control" |
| return mode.replace("_", " ").title() |
|
|
|
|
| def _voltage_envelope(grid_states, *, exclude_buses: tuple[str, ...]) -> tuple[np.ndarray, np.ndarray]: |
| """Return (vmin_t, vmax_t) per step for plotting.""" |
| drop = {b.lower() for b in exclude_buses} |
| vmin = np.full(len(grid_states), np.inf) |
| vmax = np.full(len(grid_states), -np.inf) |
| for i, gs in enumerate(grid_states): |
| for bus in gs.voltages.buses(): |
| if bus.lower() in drop: |
| continue |
| pv = gs.voltages[bus] |
| for v in (pv.a, pv.b, pv.c): |
| if math.isnan(v): |
| continue |
| if v < vmin[i]: |
| vmin[i] = v |
| if v > vmax[i]: |
| vmax[i] = v |
| return vmin, vmax |
|
|
|
|
| def _voltage_envelope_by_zone( |
| grid_states, |
| *, |
| zones: dict[str, list[str]], |
| exclude_buses: tuple[str, ...], |
| ) -> dict[str, tuple[np.ndarray, np.ndarray]]: |
| """Return {zone_name: (vmin_t, vmax_t)} per step, one array pair per zone.""" |
| drop = {b.lower() for b in exclude_buses} |
| zone_sets = {z: {b.lower() for b in buses} for z, buses in zones.items()} |
| n = len(grid_states) |
| vmin = {z: np.full(n, np.inf) for z in zones} |
| vmax = {z: np.full(n, -np.inf) for z in zones} |
| for i, gs in enumerate(grid_states): |
| for bus in gs.voltages.buses(): |
| bl = bus.lower() |
| if bl in drop: |
| continue |
| pv = gs.voltages[bus] |
| for z, bset in zone_sets.items(): |
| if bl not in bset: |
| continue |
| for v in (pv.a, pv.b, pv.c): |
| if math.isnan(v): |
| continue |
| if v < vmin[z][i]: |
| vmin[z][i] = v |
| if v > vmax[z][i]: |
| vmax[z][i] = v |
| return {z: (vmin[z], vmax[z]) for z in zones} |
|
|
|
|
| def _plot_batch_sizes( |
| records: list[ScenarioRecord], |
| batch_data: dict[int, dict], |
| save_path: Path, |
| *, |
| max_rows: int = 40, |
| ) -> None: |
| """Plot batch size over time per accepted scenario, baseline vs OFO, one row per scenario. |
| |
| When the library has more than `max_rows` scenarios, only the first |
| `max_rows` are shown. A single tall figure of hundreds of rows quickly |
| exceeds matplotlib's 65535-pixel dimension limit, so we cap here. |
| """ |
| n = len(records) |
| if n == 0: |
| return |
| if n > max_rows: |
| logger.info("_plot_batch_sizes: capping at first %d of %d records", max_rows, n) |
| records = records[:max_rows] |
| n = max_rows |
|
|
| |
| |
| |
| first_seed = records[0].seed |
| ofo_by_site = batch_data[first_seed]["ofo"] |
| cols_meta: list[tuple[str, str]] = [] |
| for site_id, sdata in ofo_by_site.items(): |
| for label in sdata["batch_by_model"]: |
| cols_meta.append((site_id, label)) |
| n_cols = len(cols_meta) |
|
|
| fig, axes = plt.subplots(n, n_cols, figsize=(4 * n_cols, 2.5 * n), sharex=True, squeeze=False) |
|
|
| for row, rec in enumerate(records): |
| bd = batch_data[rec.seed] |
| for col, (site_id, label) in enumerate(cols_meta): |
| ax = axes[row][col] |
| bl_site = bd["baseline"][site_id] |
| ofo_site = bd["ofo"][site_id] |
| ax.plot( |
| bl_site["time_s"], |
| bl_site["batch_by_model"][label], |
| color="#888", |
| linewidth=0.7, |
| alpha=0.7, |
| label="baseline", |
| ) |
| ax.plot( |
| ofo_site["time_s"], |
| ofo_site["batch_by_model"][label], |
| color="#2196F3", |
| linewidth=0.7, |
| alpha=0.9, |
| label="OFO", |
| ) |
| if row == 0: |
| short = label.split("/")[-1] if "/" in label else label |
| title = f"{site_id}:{short}" if len(ofo_by_site) > 1 else short |
| ax.set_title(title, fontsize=9) |
| if col == 0: |
| ax.set_ylabel(f"seed={rec.seed}\nBatch", fontsize=8) |
| ax.grid(True, alpha=0.2) |
| if row == 0 and col == 0: |
| ax.legend(fontsize=7, loc="upper right") |
|
|
| for col in range(n_cols): |
| axes[-1][col].set_xlabel("Time (s)") |
| fig.suptitle("Accepted scenarios: batch size (baseline vs OFO)", fontsize=13, fontweight="bold") |
| fig.tight_layout() |
| fig.savefig(save_path, dpi=120, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def _extract_batch_data(log) -> dict: |
| """Extract time_s and per-model batch sizes from a simulation log. |
| |
| Returns {site_id: {"time_s": [...], "batch_by_model": {label: [bs]}}}. |
| Uses `log.dc_states_by_site` (per-site lists) so multi-DC feeders |
| like ieee34 don't get interleaved timestamps or alternating zeros. |
| """ |
| per_site: dict[str, dict] = {} |
| for site_id, states in log.dc_states_by_site.items(): |
| time_s = [s.time_s for s in states] |
| labels: list[str] = [] |
| if states: |
| for m in states[0].batch_size_by_model: |
| if m not in labels: |
| labels.append(m) |
| batch_by_model = {m: [s.batch_size_by_model.get(m, 0) for s in states] for m in labels} |
| per_site[site_id] = {"time_s": time_s, "batch_by_model": batch_by_model} |
| return per_site |
|
|
|
|
| def _plot_envelopes( |
| records: list[ScenarioRecord], |
| envelopes: dict, |
| save_path: Path, |
| *, |
| total_duration_s: int, |
| zones: dict[str, list[str]] | None = None, |
| max_rows: int = 40, |
| ) -> None: |
| """Plot voltage envelope per accepted scenario, baseline vs OFO. |
| |
| When `zones` is provided (multi-zone feeders like ieee123), each scenario |
| gets one subplot per zone showing the per-zone vmin/vmax band. Otherwise a |
| single subplot with the global envelope is used. |
| |
| Caps at `max_rows * 2` records (global mode) or `max_rows` records |
| (per-zone mode) to stay under matplotlib's 65535-pixel dimension limit. |
| """ |
| n = len(records) |
| if n == 0: |
| return |
|
|
| t = np.arange(total_duration_s) |
|
|
| if zones: |
| zone_names = list(zones.keys()) |
| n_zones = len(zone_names) |
| cap = max_rows |
| if n > cap: |
| logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) |
| records = records[:cap] |
| n = cap |
| zone_colors = ["#2196F3", "#4CAF50", "#FF9800", "#9C27B0"] |
| fig, axes = plt.subplots(n, n_zones, figsize=(5 * n_zones, 3 * n), sharex=True, squeeze=False) |
| for row, rec in enumerate(records): |
| env = envelopes[rec.seed] |
| for col, z in enumerate(zone_names): |
| ax = axes[row][col] |
| bl_z = env["baseline_zones"].get(z) |
| of_z = env["ofo_zones"].get(z) |
| color = zone_colors[col % len(zone_colors)] |
| if bl_z is not None: |
| ax.fill_between(t, bl_z[0], bl_z[1], alpha=0.25, color="#888", label="baseline") |
| if of_z is not None: |
| ax.fill_between(t, of_z[0], of_z[1], alpha=0.4, color=color, label="OFO") |
| ax.axhline(V_MIN, color="red", linestyle="--", linewidth=0.8, alpha=0.6) |
| ax.axhline(V_MAX, color="red", linestyle="--", linewidth=0.8, alpha=0.6) |
| ax.grid(True, alpha=0.2) |
| if row == 0: |
| ax.set_title(z, fontsize=10, fontweight="bold") |
| if col == 0: |
| ax.set_ylabel( |
| f"seed={rec.seed}\npv×{rec.pv_scale:.2f} ld×{rec.load_scale:.2f}\n" |
| f"bl={rec.baseline_integral:.1f} ofo={rec.ofo_integral:.1f} " |
| f"rec={rec.recovery_frac:.0%}", |
| fontsize=7, |
| ) |
| else: |
| ax.set_ylabel("V (pu)", fontsize=8) |
| if row == 0 and col == 0: |
| ax.legend(loc="lower right", fontsize=7) |
| for col in range(n_zones): |
| axes[-1][col].set_xlabel("Time (s)", fontsize=8) |
| fig.suptitle( |
| "Accepted scenarios: per-zone voltage envelope (baseline vs OFO)", |
| fontsize=13, |
| fontweight="bold", |
| ) |
| else: |
| cap = max_rows * 2 |
| if n > cap: |
| logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) |
| records = records[:cap] |
| n = cap |
| cols = 2 |
| rows = (n + cols - 1) // cols |
| fig, axes = plt.subplots(rows, cols, figsize=(7 * cols, 3 * rows), sharex=True) |
| axes = np.atleast_2d(axes) |
|
|
| for idx, rec in enumerate(records): |
| r, c = divmod(idx, cols) |
| ax = axes[r][c] |
| bmin, bmax = envelopes[rec.seed]["baseline"] |
| omin, omax = envelopes[rec.seed]["ofo"] |
| ax.fill_between(t, bmin, bmax, alpha=0.25, color="#888", label="baseline") |
| ax.fill_between(t, omin, omax, alpha=0.4, color="#2196F3", label="OFO") |
| ax.axhline(V_MIN, color="red", linestyle="--", linewidth=1, alpha=0.6) |
| ax.axhline(V_MAX, color="red", linestyle="--", linewidth=1, alpha=0.6) |
| ax.set_title( |
| f"seed={rec.seed} pv×{rec.pv_scale:.2f} load×{rec.load_scale:.2f}\n" |
| f"int: bl={rec.baseline_integral:.2f} ofo={rec.ofo_integral:.2f} " |
| f"recov={rec.recovery_frac:.0%}", |
| fontsize=9, |
| ) |
| ax.set_ylabel("V (pu)", fontsize=9) |
| ax.grid(True, alpha=0.2) |
| if idx == 0: |
| ax.legend(loc="lower right", fontsize=8) |
|
|
| for k in range(n, rows * cols): |
| r, c = divmod(k, cols) |
| axes[r][c].axis("off") |
|
|
| for c in range(cols): |
| axes[-1][c].set_xlabel("Time (s)") |
| fig.suptitle("Accepted scenarios: voltage envelope (baseline vs OFO)", fontsize=13, fontweight="bold") |
|
|
| fig.tight_layout() |
| fig.savefig(save_path, dpi=120, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def _plot_summary(all_stats: list[dict], save_path: Path) -> None: |
| """Bar chart of baseline vs OFO integral for every candidate (accepted + rejected).""" |
| n = len(all_stats) |
| if n == 0: |
| return |
| seeds = [s["seed"] for s in all_stats] |
| bl = [s["baseline_integral"] for s in all_stats] |
| of = [s["ofo_integral"] for s in all_stats] |
| accepted = [s["accepted"] for s in all_stats] |
|
|
| x = np.arange(n) |
| w = 0.4 |
| fig, ax = plt.subplots(figsize=(max(8, 0.7 * n), 5)) |
| ax.bar(x - w / 2, bl, w, color="#888", label="baseline integral") |
| ax.bar(x + w / 2, of, w, color="#2196F3", label="OFO integral") |
| for i, ok in enumerate(accepted): |
| marker = "✓" if ok else "✗" |
| color = "green" if ok else "red" |
| ax.annotate(marker, xy=(i, max(bl[i], of[i])), ha="center", va="bottom", color=color, fontsize=12) |
| ax.set_xticks(x) |
| ax.set_xticklabels([str(s) for s in seeds], rotation=45) |
| ax.set_xlabel("Seed") |
| ax.set_ylabel("Integral voltage violation (pu·s)") |
| ax.set_yscale("symlog", linthresh=0.1) |
| ax.set_title("Candidate scenarios: baseline vs OFO integral violation") |
| ax.legend() |
| ax.grid(True, axis="y", alpha=0.2) |
| fig.tight_layout() |
| fig.savefig(save_path, dpi=120, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def plot_voltage_comparison( |
| logs: dict[str, object], |
| save_dir: Path, |
| *, |
| v_min: float = 0.95, |
| v_max: float = 1.05, |
| exclude_buses: tuple[str, ...] = (), |
| scenario_idx: int | None = None, |
| use_display_names: bool = False, |
| ) -> None: |
| """Side-by-side voltage envelopes for each controller mode.""" |
| modes = _sort_modes(list(logs.keys())) |
| n = len(modes) |
| fig, axes = plt.subplots(1, n, figsize=(6 * n, 5), sharey=True) |
| if n == 1: |
| axes = [axes] |
|
|
| drop = {b.lower() for b in exclude_buses} |
|
|
| for ax, mode in zip(axes, modes, strict=False): |
| log = logs[mode] |
| time_s = np.array(log.time_s) |
|
|
| v_min_arr = np.full(len(log.grid_states), np.inf) |
| v_max_arr = np.full(len(log.grid_states), -np.inf) |
|
|
| for t_idx, gs in enumerate(log.grid_states): |
| for bus in gs.voltages.buses(): |
| if bus.lower() in drop: |
| continue |
| pv = gs.voltages[bus] |
| for v in (pv.a, pv.b, pv.c): |
| if not math.isnan(v): |
| v_min_arr[t_idx] = min(v_min_arr[t_idx], v) |
| v_max_arr[t_idx] = max(v_max_arr[t_idx], v) |
|
|
| ax.fill_between(time_s, v_min_arr, v_max_arr, alpha=0.3, color="steelblue") |
| ax.plot(time_s, v_min_arr, color="steelblue", linewidth=0.5, label="Vmin") |
| ax.plot(time_s, v_max_arr, color="coral", linewidth=0.5, label="Vmax") |
| ax.axhline(v_min, color="red", linestyle="--", linewidth=1, alpha=0.7) |
| ax.axhline(v_max, color="red", linestyle="--", linewidth=1, alpha=0.7) |
| ax.set_xlabel("Time (s)", fontsize=13) |
| ax.set_title(_display_name(mode) if use_display_names else mode, fontsize=14) |
| ax.legend(fontsize=12) |
| ax.tick_params(labelsize=12) |
| ax.grid(True, alpha=0.2) |
|
|
| axes[0].set_ylabel("Voltage (pu)", fontsize=13) |
| fig.suptitle("Voltage Envelope Comparison", fontsize=16, fontweight="bold") |
| fig.tight_layout() |
| stem = f"scenario_{scenario_idx:03d}_voltage_comparison" if scenario_idx is not None else "voltage_comparison" |
| fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s.png", stem) |
|
|
|
|
| def plot_violation_bars( |
| results: dict[str, dict], |
| save_dir: Path, |
| *, |
| scenario_idx: int | None = None, |
| use_display_names: bool = False, |
| ) -> None: |
| """Four-panel bar chart for a single scenario: |
| violation time, integral violation, mean throughput, batch size changes. |
| """ |
| modes = _sort_modes(list(results.keys())) |
| if not modes: |
| return |
|
|
| cmap = plt.get_cmap("tab10") |
| colors = [cmap(i % 10) for i in range(len(modes))] |
|
|
| viol_s = [float(results[m].get("violation_time_s", 0.0)) for m in modes] |
| integ = [float(results[m].get("integral", 0.0)) for m in modes] |
| tput = [float(results[m].get("mean_throughput_toks_s", 0.0)) for m in modes] |
| batch_chg = [float(results[m].get("batch_changes", 0.0)) for m in modes] |
| labels = [_display_name(m) if use_display_names else m for m in modes] |
|
|
| fig, axes = plt.subplots(1, 4, figsize=(max(18, 2.0 * len(modes) + 12), 6)) |
| ax_v, ax_i, ax_t, ax_b = axes |
|
|
| x = np.arange(len(modes)) |
| for ax, vals, ylabel, title, fmt in [ |
| (ax_v, viol_s, "Violation time (s)", "Violation time", "{:.0f}"), |
| (ax_i, integ, "Integral violation (pu·s)", "Integral violation", "{:.2f}"), |
| (ax_t, tput, "Throughput (tok/s)", "Mean throughput", "{:.2e}"), |
| (ax_b, batch_chg, "Batch size changes", "Batch size changes", "{:.0f}"), |
| ]: |
| ax.bar(x, vals, color=colors, alpha=0.88, edgecolor="black", linewidth=0.5) |
| ax.set_xticks(x) |
| ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=12) |
| ax.set_ylabel(ylabel, fontsize=13) |
| ax.set_title(title, fontsize=14) |
| ax.tick_params(axis="y", labelsize=12) |
| ax.grid(axis="y", alpha=0.3) |
| for xi, val in zip(x, vals, strict=False): |
| ax.text(xi, val, fmt.format(val), ha="center", va="bottom", fontsize=10) |
|
|
| fig.suptitle("Per-scenario controller metrics", fontsize=16, fontweight="bold") |
| fig.tight_layout() |
| stem = f"scenario_{scenario_idx:03d}_performance_summary" if scenario_idx is not None else "violation_bars" |
| fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s.png", stem) |
|
|
|
|
| def plot_batch_comparison( |
| logs: dict[str, object], |
| save_dir: Path, |
| *, |
| scenario_idx: int | None = None, |
| use_display_names: bool = False, |
| ) -> None: |
| """Batch size over time for each controller mode, one subplot per (site, model) pair.""" |
| modes = _sort_modes(list(logs.keys())) |
| if not modes: |
| logger.info("plot_batch_comparison: no controllers to plot") |
| return |
|
|
| site_models: list[tuple[str, str]] = [] |
| for log in logs.values(): |
| for site_id, states in log.dc_states_by_site.items(): |
| if not states: |
| continue |
| for m in states[0].batch_size_by_model: |
| pair = (site_id, m) |
| if pair not in site_models: |
| site_models.append(pair) |
| break |
|
|
| n_rows = len(site_models) |
| cmap = plt.get_cmap("tab10") |
| fig, axes = plt.subplots( |
| n_rows, |
| 1, |
| figsize=(13, 4 * n_rows), |
| sharex=True, |
| squeeze=False, |
| ) |
|
|
| for row, (site_id, model_label) in enumerate(site_models): |
| ax = axes[row][0] |
| for i, mode in enumerate(modes): |
| log = logs[mode] |
| site_states = log.dc_states_by_site.get(site_id, []) |
| times = [s.time_s for s in site_states] |
| batches = [s.batch_size_by_model.get(model_label, 0) for s in site_states] |
| ax.plot( |
| times, |
| batches, |
| color=cmap(i % 10), |
| linewidth=1.5, |
| alpha=0.85, |
| label=_display_name(mode) if use_display_names else mode, |
| ) |
| ax.set_ylabel("Batch Size", fontsize=13) |
| title = f"{model_label} @ {site_id}" if len(log.dc_states_by_site) > 1 else model_label |
| ax.set_title(title, fontsize=14) |
| ax.legend(fontsize=12, loc="upper right") |
| ax.tick_params(labelsize=12) |
| ax.grid(True, alpha=0.3) |
|
|
| axes[-1][0].set_xlabel("Time (s)", fontsize=13) |
| fig.suptitle("Batch Size Comparison by Model", fontsize=16, fontweight="bold") |
| fig.tight_layout(rect=[0, 0, 1, 0.95]) |
| stem = f"scenario_{scenario_idx:03d}_batch_size_comparison" if scenario_idx is not None else "batch_size_comparison" |
| fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s.png", stem) |
|
|
|
|
| def plot_aggregate( |
| all_results: list[dict], |
| scenario_params: list[dict], |
| save_dir: Path, |
| modes: list[str], |
| *, |
| system: str = "", |
| use_display_names: bool = False, |
| ) -> None: |
| """2×3 aggregate bar chart (means) + per-scenario breakdown + normalized integral + CDF + scatter.""" |
| n_sc = len(all_results) |
| prefix = f"{system}_" if system else "" |
|
|
| colors = ["#999999", "#4CAF50", "#2196F3", "#FF9800", "#E91E63", "#9C27B0", "#00BCD4", "#795548", "#607D8B"] |
|
|
| display_labels = [_display_name(m) if use_display_names else m for m in modes] |
|
|
| metrics = [ |
| ("violation_time_s", "Mean Violation Time (s)"), |
| ("integral", "Mean Integral Violation (pu·s)"), |
| ("batch_changes", "Mean Batch Size Changes"), |
| ("mean_throughput_toks_s", "Mean Throughput (tok/s)"), |
| ("mean_power_kw", "Mean Data Center Power (kW)"), |
| ("itl_violation_rate", "Mean ITL Violation Rate"), |
| ] |
|
|
| fig, axes = plt.subplots(2, 3, figsize=(max(15, len(modes) * 3.0), 10)) |
| x = np.arange(len(modes)) |
|
|
| for ax, (metric, title) in zip(axes.flat, metrics, strict=False): |
| means = [] |
| for mode in modes: |
| vals = [r[mode].get(metric, 0) for r in all_results if mode in r] |
| means.append(np.mean(vals) if vals else 0.0) |
|
|
| ax.bar(x, means, color=colors[: len(modes)], alpha=0.85) |
| ax.set_xticks(x) |
| ax.set_xticklabels(display_labels, rotation=30, ha="right", fontsize=12) |
| ax.set_ylabel(title, fontsize=13) |
| ax.set_title(title, fontsize=14, fontweight="bold") |
| ax.tick_params(axis="y", labelsize=12) |
| ax.grid(axis="y", alpha=0.3) |
|
|
| fig.suptitle(f"Aggregate Controller Metrics: {n_sc} Scenarios", fontsize=16, fontweight="bold") |
| fig.tight_layout(rect=[0, 0, 1, 0.95]) |
| fname = f"{prefix}controller_evaluation.png" |
| fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s", fname) |
|
|
| |
| fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) |
| x = np.arange(n_sc) |
| width = 0.8 / len(modes) |
|
|
| for i, mode in enumerate(modes): |
| vals = [r[mode]["integral"] if mode in r else 0 for r in all_results] |
| ax.bar( |
| x + i * width, |
| vals, |
| width, |
| label=_display_name(mode) if use_display_names else mode, |
| color=colors[i % len(colors)], |
| alpha=0.85, |
| ) |
|
|
| ax.set_xlabel("Scenario", fontsize=13) |
| ax.set_ylabel("Integral Violation (pu·s)", fontsize=13) |
| ax.set_title("Per-Scenario Integral Violation", fontsize=14, fontweight="bold") |
| ax.set_xticks(x + width * (len(modes) - 1) / 2) |
| ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) |
| ax.legend(fontsize=10, loc="upper right") |
| ax.tick_params(axis="y", labelsize=12) |
| ax.grid(axis="y", alpha=0.3) |
| fig.tight_layout() |
| fname = f"{prefix}scenario_summary.png" |
| fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s", fname) |
|
|
| |
| baseline_key = "baseline_no_tap" |
| if baseline_key in modes: |
| fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) |
| x = np.arange(n_sc) |
| non_baseline = [m for m in modes if m != baseline_key] |
| width = 0.8 / len(non_baseline) |
|
|
| for i, mode in enumerate(non_baseline): |
| norm_vals = [] |
| for r in all_results: |
| base = r.get(baseline_key, {}).get("integral", 0.0) |
| val = r.get(mode, {}).get("integral", 0.0) |
| norm_vals.append(val / base if base > 0 else 0.0) |
| ax.bar( |
| x + i * width, |
| norm_vals, |
| width, |
| label=_display_name(mode) if use_display_names else mode, |
| color=colors[(modes.index(mode)) % len(colors)], |
| alpha=0.85, |
| ) |
|
|
| ax.axhline(1.0, color="black", linestyle="--", linewidth=1, alpha=0.6, label="Baseline") |
| ax.set_xlabel("Scenario", fontsize=13) |
| ax.set_ylabel("Normalized Integral (relative to No Control)", fontsize=13) |
| ax.set_title("Per-Scenario Normalized Integral Violation", fontsize=14, fontweight="bold") |
| ax.set_xticks(x + width * (len(non_baseline) - 1) / 2) |
| ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) |
| ax.legend(fontsize=10, loc="upper right") |
| ax.tick_params(axis="y", labelsize=12) |
| ax.grid(axis="y", alpha=0.3) |
| fig.tight_layout() |
| fname = f"{prefix}scenario_normalized_integral.png" |
| fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s", fname) |
|
|
| |
| fig, ax = plt.subplots(figsize=(8, 6)) |
| for i, mode in enumerate(modes): |
| vals = sorted([r[mode].get("integral", 0.0) for r in all_results if mode in r]) |
| if not vals: |
| continue |
| cdf = np.arange(1, len(vals) + 1) / len(vals) |
| ax.plot( |
| vals, |
| cdf, |
| color=colors[i % len(colors)], |
| linewidth=2, |
| label=_display_name(mode) if use_display_names else mode, |
| ) |
| ax.set_xlabel("Integral Violation (pu·s)", fontsize=13) |
| ax.set_ylabel("Cumulative Fraction", fontsize=13) |
| ax.set_title("CDF of Integral Violation Across Scenarios", fontsize=14, fontweight="bold") |
| ax.legend(fontsize=11, loc="lower right") |
| ax.tick_params(labelsize=12) |
| ax.grid(True, alpha=0.3) |
| fig.tight_layout() |
| fname = f"{prefix}cdf_integral.png" |
| fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s", fname) |
|
|
| |
| scatter_data = {} |
| for _i, mode in enumerate(modes): |
| integrals = [r[mode].get("integral", 0.0) for r in all_results if mode in r] |
| tputs = [r[mode].get("mean_throughput_toks_s", 0.0) for r in all_results if mode in r] |
| scatter_data[mode] = (integrals, tputs) |
|
|
| n_modes = len(modes) |
| ncols = 2 |
| nrows = math.ceil(n_modes / ncols) |
| fig, axes = plt.subplots(nrows, ncols, figsize=(12, 5 * nrows), sharex=True, sharey=True, squeeze=False) |
|
|
| for idx, mode in enumerate(modes): |
| ax = axes[idx // ncols][idx % ncols] |
| integrals, tputs = scatter_data[mode] |
| label = _display_name(mode) if use_display_names else mode |
|
|
| for other_mode, (oi, ot) in scatter_data.items(): |
| if other_mode != mode: |
| ax.scatter(oi, ot, color="lightgrey", s=40, alpha=0.6, edgecolors="none", zorder=1) |
|
|
| ax.scatter( |
| integrals, |
| tputs, |
| color=colors[idx % len(colors)], |
| s=80, |
| alpha=0.9, |
| edgecolors="black", |
| linewidths=0.5, |
| zorder=2, |
| ) |
| ax.scatter( |
| np.mean(integrals), |
| np.mean(tputs), |
| color=colors[idx % len(colors)], |
| s=220, |
| marker="*", |
| edgecolors="black", |
| linewidths=0.8, |
| zorder=3, |
| ) |
|
|
| ax.set_title(label, fontsize=14, fontweight="bold") |
| ax.set_xlabel("Integral Violation (pu·s)", fontsize=12) |
| ax.set_ylabel("Mean Throughput (tok/s)", fontsize=12) |
| ax.tick_params(labelsize=11) |
| ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.2e}")) |
| ax.grid(True, alpha=0.3) |
|
|
| for idx in range(n_modes, nrows * ncols): |
| axes[idx // ncols][idx % ncols].set_visible(False) |
|
|
| fig.suptitle("Throughput vs. Voltage Violation by Controller", fontsize=16, fontweight="bold") |
| fig.tight_layout(rect=[0, 0, 1, 0.95]) |
| fname = f"{prefix}throughput_vs_violation.png" |
| fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| logger.info("Saved %s", fname) |
|
|
| logger.info("Saved aggregate figures to %s", save_dir) |
|
|