#!/usr/bin/env python3 """Generate report-v2 figures F1–F12 from assumptions.yaml / System B ledger.""" from __future__ import annotations import os import sys from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Rectangle ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "report")) from regulator_paper_assumptions import ( # noqa: E402 summer_load_with_pdrc_mw, summer_system_load_mw, v, winter_system_load_mw, ) from report_style import ( # noqa: E402 AMBER, BORDER, CHARCOAL, ELECTRIC_BG, GREEN, GREEN_BG, GREEN_DARK, GREEN_LIGHT, GREEN_MID, ICE, ICE_LIGHT, ICE_MID, ORANGE, ORANGE_BG, ORANGE_LIGHT, ORANGE_MID, PAPER, ROSE, SLATE, THERMAL_BG, WHITE, apply_report_style, caption, ) from system_b_ledger import build_ledger # noqa: E402 OUT = ROOT / "rendered" / "report-v2" OUT.mkdir(parents=True, exist_ok=True) LEGACY = ROOT / "rendered" / "regulator-paper" LEGACY.mkdir(parents=True, exist_ok=True) REPO_FIGS = ROOT.parent / "report" / "figures" REPO_FIGS.mkdir(parents=True, exist_ok=True) def _box(ax, xy, w, h, text, fc, ec, fontsize=9, textcolor=CHARCOAL): ax.add_patch( FancyBboxPatch(xy, w, h, boxstyle="round,pad=0.012", fc=fc, ec=ec, lw=1.5) ) ax.text( xy[0] + w / 2, xy[1] + h / 2, text, ha="center", va="center", fontsize=fontsize, color=textcolor, fontweight="bold", ) def _arrow(ax, a, b, color=SLATE): ax.add_patch( FancyArrowPatch(a, b, arrowstyle="-|>", mutation_scale=14, lw=1.8, color=color) ) def _save(fig, name: str, dpi: int = 300): path = OUT / name fig.savefig(path, dpi=dpi, bbox_inches="tight", facecolor=WHITE) # Keep legacy regulator-paper copies for F1–F4 filenames used in markdown if name.startswith("fig") and name[3].isdigit() and int(name[3]) <= 4: legacy_name = { "fig1_graphical_abstract.png": "fig1_graphical_abstract.png", "fig2_capacity_scenarios.png": "fig2_capacity_scenarios.png", "fig3_delivered_cost.png": "fig3_delivered_cost.png", "fig4_lifecycle_bands.png": "fig4_lifecycle_bands.png", }.get(name) if legacy_name: fig.savefig(LEGACY / legacy_name, dpi=dpi, bbox_inches="tight", facecolor=WHITE) # Public package figures (repo root report/) fig.savefig(REPO_FIGS / name, dpi=dpi, bbox_inches="tight", facecolor=WHITE) plt.close(fig) print(f" wrote {path}") MONO = "DejaVu Sans Mono" SOURCE_LINE = "Source: assumptions.yaml / system_b_ledger.py" def _headline(ax, title: str, sub: str = "") -> None: """Shared chart header: left-aligned title, slate subtitle underneath.""" ax.set_title(title, loc="left", fontsize=13.5, fontweight="bold", color=GREEN_DARK, pad=24) if sub: ax.text(0, 1.028, sub, transform=ax.transAxes, fontsize=9.2, color=SLATE, va="bottom") def _footer(ax, note: str, *, y: float = -0.16) -> None: """Shared chart footer: evidence note, then the registry source line.""" caption(ax, f"{note} · {SOURCE_LINE}", y=y) def _flat_card(ax, x, y, w, h, accent, title, lines, bg=WHITE, title_color=CHARCOAL): """Flat card: white fill, hairline border, thin accent top rule, left-aligned text.""" ax.add_patch(Rectangle((x, y), w, h, fc=bg, ec=BORDER, lw=1.0, zorder=2)) ax.add_patch(Rectangle((x, y + h - 0.014), w, 0.014, fc=accent, ec="none", zorder=3)) pad = 0.016 ax.text(x + pad, y + h - 0.055, title, ha="left", va="top", fontsize=9.2, fontweight="bold", color=title_color, zorder=4) ax.text(x + pad, y + h - 0.105, "\n".join(lines), ha="left", va="top", fontsize=8.6, color=SLATE, linespacing=1.4, zorder=4) def _lane_arrow(ax, a, b, color): ax.add_patch(FancyArrowPatch(a, b, arrowstyle="-|>", mutation_scale=11, lw=1.4, color=color, shrinkA=0, shrinkB=0, zorder=1)) def fig1(): fig = plt.figure(figsize=(12.2, 6.0)) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") ax.add_patch(Rectangle((0, 0), 1, 1, fc=WHITE, ec=None)) ax.text(0.045, 0.945, "Use every electron twice", ha="left", va="center", fontsize=16, fontweight="bold", color=GREEN_DARK) ax.text(0.045, 0.885, "NEWPCC clean hybrid portfolio, dual-ledger view", ha="left", va="center", fontsize=10, color=SLATE) xs = [0.045, 0.285, 0.525, 0.765] w, h = 0.19, 0.235 y_heat, y_elec = 0.545, 0.20 arr_y_heat = y_heat + h / 2 arr_y_elec = y_elec + h / 2 # Lane labels, right-aligned into the left margin band ax.text(0.045, y_heat + h + 0.045, "HEAT PATH (MWth)", ha="left", va="center", fontsize=8.2, fontweight="bold", color=AMBER) ax.text(0.045, y_elec + h + 0.045, "ELECTRIC LEDGER (MW)", ha="left", va="center", fontsize=8.2, fontweight="bold", color=ICE) lo_kw, hi_kw = v("node_band_kw") heat_cards = [ ("Hydro reservoirs", ["Store the multi-day", "energy Manitoba owns"], ICE, ICE_LIGHT), (f"Compute nodes {lo_kw}-{hi_kw} kW", ["0 MW until the three", "gates clear [C]"], AMBER, THERMAL_BG), (f"NEWPCC hub {v('hub_thermal_mwth')} MWth", ["Effluent stage; +95 MWth", "compute reject, gated [A]"], AMBER, THERMAL_BG), ("Winnipeg buildings", ["Heated by water,", "not by peak load"], AMBER, THERMAL_BG), ] elec_cards = [ (f"Batteries {v('bess_mw')} MW / 4 h", ["Shave the 7-9 a.m. and", "5-7 p.m. spikes"], ICE, ELECTRIC_BG), (f"DSM {v('dsm_mw')} MW", [f"Interties 0-{v('intertie_mw')} MW,", "deliverability first [A]"], ICE, ELECTRIC_BG), (f"Hub net +{v('hub_net_electric_mw')} MW", ["COP ~4.3 delivered,", "less compressor draw"], ICE, ELECTRIC_BG), (f"Firm {v('scenario_conservative_mw')}-{v('scenario_full_mw')} MW", ["By accreditation", "scenario [C]"], GREEN, "#d8f3e4"), ] for (title, lines, accent, bg), x in zip(heat_cards, xs): _flat_card(ax, x, y_heat, w, h, accent, title, lines, bg=bg) for (title, lines, accent, bg), x in zip(elec_cards, xs): _flat_card(ax, x, y_elec, w, h, accent, title, lines, bg=bg) gap = xs[1] - (xs[0] + w) for a, b in zip(xs[:-1], xs[1:]): _lane_arrow(ax, (a + w + gap * 0.12, arr_y_heat), (b - gap * 0.12, arr_y_heat), AMBER) _lane_arrow(ax, (a + w + gap * 0.12, arr_y_elec), (b - gap * 0.12, arr_y_elec), ICE) ax.plot([0.045, 0.955], [0.135, 0.135], color=BORDER, lw=1.0) lo_ev, hi_ev = v("gas_cold_events_outage") ax.text( 0.045, 0.085, f"Gas comparison: ${v('gas_capex_b'):.1f}B for {v('gas_nameplate_mw')} MW nameplate, " f"{lo_ev}-of-{hi_ev} cold-event record [PD]. Remediation floor ≥{v('remediation_floor_pct')}% [E]. " "Thermal and electric units never share a sum.", ha="left", fontsize=8.5, color=SLATE, ) ax.text(0.045, 0.04, "Source: assumptions.yaml / system_b_ledger.py", ha="left", fontsize=8.0, color=SLATE, style="italic") _save(fig, "fig1_graphical_abstract.png") def fig2(): apply_report_style() fig, ax = plt.subplots(figsize=(9.2, 5.4)) labels = [ "Hybrid\nconservative", "Hybrid\nreference", "Hybrid\nfull", "Gas\nnameplate", "Gas\nderate scen.", ] values = [ v("scenario_conservative_mw"), v("scenario_reference_mw"), v("scenario_full_mw"), v("gas_nameplate_mw"), v("gas_derate_scenario_mw"), ] # Hybrid = green shades; Gas = orange shades colors = [GREEN_LIGHT, GREEN_MID, GREEN, ORANGE_LIGHT, ORANGE] bars = ax.bar(labels, values, color=colors, edgecolor=CHARCOAL, width=0.62) for bar, val in zip(bars, values): ax.text( bar.get_x() + bar.get_width() / 2, val + 14, f"{val} MW", ha="center", fontweight="bold", fontsize=10, color=CHARCOAL, ) ax.axhline(v("shortfall_2030_mw"), color=ROSE, linestyle="--", lw=1.8) ax.text( 4.4, v("shortfall_2030_mw") + 16, f"{int(v('shortfall_2030_mw'))} MW shortfall [PD]", ha="right", color=ROSE, fontsize=9, ) ax.set_ylabel("Firm winter electric capability (MW)") _headline(ax, "Accreditation scenarios vs the shortfall", "Electric ledger only; the gas derate is a scenario, not proven ELCC") _footer( ax, f"[C] Hub {int(v('hub_thermal_mwth'))} MWth enters only as ≤{int(v('hub_net_electric_mw'))} MW " "net electric. Hybrid and gas bars are alternatives, not a stack.", ) fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig2_capacity_scenarios.png") def fig3(): apply_report_style() fig, ax = plt.subplots(figsize=(9.2, 5.4)) scenarios = ["Hybrid\ncons.", "Hybrid\nref.", "Hybrid\nfull", "Gas\nnameplate", "Gas\nderate"] cap = [ v("scenario_conservative_mw"), v("scenario_reference_mw"), v("scenario_full_mw"), v("gas_nameplate_mw"), v("gas_derate_scenario_mw"), ] gross = [v("hybrid_capex_gross_b") * 1e9 / (m * 1000) for m in cap[:3]] + [ v("gas_capex_b") * 1e9 / (m * 1000) for m in cap[3:] ] net = [v("hybrid_capex_net_b") * 1e9 / (m * 1000) for m in cap[:3]] + [None, None] x = list(range(len(scenarios))) # Hybrid bars green; gas bars orange (gross only on gas) gross_colors = [GREEN_MID, GREEN_MID, GREEN_MID, ORANGE, ORANGE_MID] ax.bar([i - 0.19 for i in x], gross, width=0.38, color=gross_colors, edgecolor=CHARCOAL, label="Gross") ax.bar([i + 0.19 for i in x[:3]], net[:3], width=0.38, color=GREEN, edgecolor=CHARCOAL, label="Net ITC [A]") for i, g in enumerate(gross): label_c = GREEN_DARK if i < 3 else ORANGE ax.text(i - 0.19, g + 70, f"${g:,.0f}", ha="center", fontsize=8.2, fontweight="bold", color=label_c) for i, n in enumerate(net[:3]): ax.text(i + 0.19, n + 70, f"${n:,.0f}", ha="center", fontsize=8.2, fontweight="bold", color=GREEN_DARK) ax.set_xticks(x) ax.set_xticklabels(scenarios) ax.set_ylabel("Capital cost per firm kW delivered ($/kW)") _headline(ax, "Delivered cost per firm kilowatt", "Gross and net of the Clean Technology ITC; electric ledger only") ax.legend(loc="upper left", fontsize=9) _footer(ax, "[C/E/A] Net bars need the ITC confirmed.", y=-0.14) fig.tight_layout(rect=(0, 0.05, 1, 1)) _save(fig, "fig3_delivered_cost.png") def fig4(): apply_report_style() fig, ax = plt.subplots(figsize=(9.2, 4.6)) plans = ["Brandon gas plan", "NEWPCC clean hybrid"] lows = [v("gas_20yr_low_b"), v("hybrid_20yr_low_b")] highs = [v("gas_20yr_high_b"), v("hybrid_20yr_high_b")] colors = [ORANGE, GREEN] for i, (lo, hi, c) in enumerate(zip(lows, highs, colors)): ax.barh(i, hi - lo, left=lo, height=0.42, color=c, edgecolor=CHARCOAL) ax.text(lo - 0.1, i, f"${lo:.1f}B", ha="right", va="center", fontweight="bold") ax.text(hi + 0.1, i, f"${hi:.1f}B", ha="left", va="center", fontweight="bold") ax.set_yticks([0, 1]) ax.set_yticklabels(plans) ax.set_xlim(0, 7.8) ax.set_xlabel("Modeled 20-year all-in cost ($ billions)") _headline(ax, "Twenty-year cost bands do not overlap", "Modeled all-in cost for both plans, fuel and carbon included") sav_lo = v("gas_20yr_low_b") - v("hybrid_20yr_high_b") sav_hi = v("gas_20yr_high_b") - v("hybrid_20yr_low_b") # Make the headline claim visible: shade the non-overlap gap itself. hyb_hi = v("hybrid_20yr_high_b") gas_lo = v("gas_20yr_low_b") ax.add_patch(Rectangle((hyb_hi, 0.28), gas_lo - hyb_hi, 0.44, fc=GREEN_MID, alpha=0.18, zorder=1)) for gx in (hyb_hi, gas_lo): ax.plot([gx, gx], [0.28, 0.72], color=GREEN, lw=1.2, ls=(0, (3, 3)), zorder=2) ax.text((hyb_hi + gas_lo) / 2, 0.5, f"gap ≥ ${sav_lo:.1f}B [E/A]", ha="center", va="center", fontsize=10, fontweight="bold", color=GREEN_DARK, zorder=3) _footer(ax, f"[E/A] Savings band ${sav_lo:.1f}-{sav_hi:.1f}B. Replaces retired point ledgers.", y=-0.22) fig.tight_layout(rect=(0, 0.08, 1, 1)) _save(fig, "fig4_lifecycle_bands.png") def fig5(): """Winter load vs temperature — research heating model anchored on peak_record_mw [PD].""" apply_report_style() fig, ax = plt.subplots(figsize=(9.2, 5.0)) # Warm left → cold right (match landing climb narrative) temps = list(range(4, -41, -2)) peak = float(v("peak_record_mw")) design = float(v("winter_design_temp_c")) base = float(v("winter_load_base_mw")) thresh = float(v("winter_heat_threshold_c")) beta = float(v("winter_heat_beta_mw_per_c")) loads = [winter_system_load_mw(t) for t in temps] hub_net = v("hub_net_electric_mw") shaved = [max(l - hub_net, 0) for l in loads] ax.plot(temps, loads, color=ROSE, lw=2.2, label="System load (research heating model) [PD anchor]") ax.plot(temps, shaved, color=ICE, lw=2.2, label=f"After hub net electric −{hub_net} MW [C]") ax.fill_between(temps, shaved, loads, color=ELECTRIC_BG, alpha=0.7, zorder=0) ax.axvline(design, color=SLATE, ls=":", lw=1.2) # Warm left → cold right, so the line climbs with the cold-morning # narrative (matches the landing se-charts winter-load orientation). ax.invert_xaxis() # The PD anchor the whole model is calibrated to. ax.plot([design], [peak], "o", color=ROSE, ms=7, zorder=5) ax.annotate( f"{int(peak):,} MW · 22–23 Jan 2024 peak [PD]", xy=(design, peak), xytext=(design + 16, peak - 60), color=ROSE, fontsize=9.5, fontweight="bold", ha="right", va="center", arrowprops=dict(arrowstyle="-", color=SLATE, lw=0.8), bbox=dict(boxstyle="round,pad=0.28", fc=WHITE, ec=ROSE, lw=0.6, alpha=0.95), ) y_under = min(shaved) + 120 ax.text( design + 14, y_under, f"{design:g} °C cold morning →", color=SLATE, fontsize=9.5, ha="left", va="center", fontweight="bold", bbox=dict(boxstyle="round,pad=0.28", fc=WHITE, ec=SLATE, lw=0.6, alpha=0.95), ) ax.set_xlabel("Outdoor temperature (°C)") ax.set_ylabel("Load (MW)") _headline(ax, "Winter load rises as temperature falls", "The hub enters as net electric megawatts only") ax.legend(fontsize=8.5, loc="upper left") ax.grid(True, alpha=0.35) _footer( ax, f"[E/PD] P = {base:g} + max(0, ({thresh:g} − T) × {beta:g}); " f"beta calibrated to {int(peak):,} MW at {design:g} °C.", ) fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig5_winter_load_vs_temp.png") def fig6(): apply_report_style() import numpy as np ledger = build_ledger() hours = np.arange(24) shave = np.array(ledger.peak_shave_mw_by_hour()) peak = float(v("peak_record_mw")) amp = 700.0 base = peak - amp # Illustrative dual-spike day peaking at PD design load; shave is ledger-computed [C] load = base + amp * ( np.exp(-(((hours - 8) / 2.5) ** 2)) + np.exp(-(((hours - 18) / 2.5) ** 2)) ) shaved = load - shave fig, ax = plt.subplots(figsize=(9.2, 5.0)) ax.plot(hours, load, color=ROSE, lw=2.2, label="Unmanaged cold-snap day (illustrative shape)") ax.plot(hours, shaved, color=ICE, lw=2.2, label="With hub net + batteries + demand response [C]") ax.fill_between(hours, shaved, load, color=ICE_LIGHT, alpha=0.9, label=f"Shave, up to {shave.max():.0f} MW electric") # Headroom so the legend clears the morning peak, and mark the PD anchor. ax.set_ylim(float(shaved.min()) - 90, peak + 360) ax.plot([8], [load[8]], "o", color=ROSE, ms=7, zorder=5) ax.annotate( f"{int(peak):,} MW [PD]", xy=(8, load[8]), xytext=(11.2, peak + 40), color=ROSE, fontsize=9.5, fontweight="bold", ha="left", va="center", arrowprops=dict(arrowstyle="-", color=SLATE, lw=0.8), bbox=dict(boxstyle="round,pad=0.28", fc=WHITE, ec=ROSE, lw=0.6, alpha=0.95), ) ax.set_xticks(range(0, 24, 2)) ax.set_xlabel("Hour of day") ax.set_ylabel("System load (MW)") _headline(ax, "A 24-hour cold snap at -35 °C", "Hub net is steady all day; batteries and demand response cover the two spikes") ax.legend(fontsize=8.5, loc="upper left") ax.grid(True, alpha=0.35) _footer(ax, f"[C/PD] Day peaks near {int(peak):,} MW; shave profile ledger-computed; thermal MWth never added.") fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig6_cold_snap_shave.png") def fig7(): """Dual-ledger how-it-works: two flat ledger panels, one net-only bridge.""" fig = plt.figure(figsize=(12.2, 6.6)) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") ax.add_patch(Rectangle((0, 0), 1, 1, fc=WHITE, ec=None)) ledger = build_ledger() effluent = int(ledger.thermal_ledger_mwth["effluent"]) compute = int(ledger.thermal_ledger_mwth["compute_reject"]) hub_th = int(v("hub_thermal_mwth")) hub_net = int(v("hub_net_electric_mw")) hub_eff = int(v("hub_effluent_stage_net_mw")) comp = hub_th - hub_net bess = int(v("bess_mw")) dsm = int(v("dsm_mw")) intertie = int(v("intertie_mw")) cons = int(v("scenario_conservative_mw")) ref = int(v("scenario_reference_mw")) full = int(v("scenario_full_mw")) m_ref = int(ledger.margin_mw("reference")) m_full = int(ledger.margin_mw("full")) m_cons = int(ledger.margin_mw("conservative")) ax.text(0.045, 0.945, "How the dual ledger works", ha="left", va="center", fontsize=16, fontweight="bold", color=GREEN_DARK) ax.text(0.045, 0.885, "Heat stays on the thermal ledger. Only net electric value enters winter firm capacity.", ha="left", va="center", fontsize=10, color=SLATE) panel_y, panel_h = 0.155, 0.66 lx, lw_, rx, rw_ = 0.045, 0.415, 0.54, 0.415 def panel(x, w, accent, bg, title, unit): ax.add_patch(Rectangle((x, panel_y), w, panel_h, fc=bg, ec=BORDER, lw=1.0, zorder=1)) ax.add_patch(Rectangle((x, panel_y + panel_h - 0.016), w, 0.016, fc=accent, ec="none", zorder=2)) ax.text(x + 0.02, panel_y + panel_h - 0.055, title, ha="left", va="center", fontsize=11.5, fontweight="bold", color=accent, zorder=3) ax.text(x + w - 0.02, panel_y + panel_h - 0.055, unit, ha="right", va="center", fontsize=8.8, color=SLATE, zorder=3) def row(x, w, y, label, val, val_color, bold=False, small=""): ax.text(x + 0.02, y, label, ha="left", va="center", fontsize=9.8, fontweight="bold" if bold else "normal", color=CHARCOAL, zorder=3) ax.text(x + w - 0.02, y, val, ha="right", va="center", fontsize=10.5 if not bold else 11.5, fontweight="bold", color=val_color, family=MONO, zorder=3) if small: ax.text(x + 0.02, y - 0.032, small, ha="left", va="center", fontsize=8.0, color=SLATE, zorder=3) def hairline(x, w, y, color): ax.plot([x + 0.02, x + w - 0.02], [y, y], color=color, lw=0.9, zorder=3) # Thermal ledger (left) panel(lx, lw_, AMBER, THERMAL_BG, "THERMAL LEDGER", "megawatts of heat (MWth)") row(lx, lw_, 0.70, "NEWPCC effluent heat, delivered", f"{effluent} MWth", AMBER) row(lx, lw_, 0.635, "Compute reject, contingent [A]", f"+{compute} gated", AMBER) hairline(lx, lw_, 0.595, AMBER) row(lx, lw_, 0.55, "Counts today (effluent stage)", f"{hub_th} MWth", AMBER, bold=True) ax.text(lx + 0.02, 0.48, f"Heat pumps lift at delivered COP ~4.3; compressors draw {comp} MW.\n" "Compute reject heat never books as firm supply: its own\n" "IT load is netted first (curtail at peak + BTES banking).", ha="left", va="center", fontsize=8.8, color=SLATE, linespacing=1.5, zorder=3) hairline(lx, lw_, 0.40, AMBER) row(lx, lw_, 0.35, "Credits the electric ledger, net only", f"+{hub_net} MW", GREEN, bold=True, small="assumes displaced resistance heating [E]") # Electric ledger (right); hub-credit row aligned with the thermal credit row panel(rx, rw_, ICE, ELECTRIC_BG, "ELECTRIC LEDGER", "megawatts of power (MW)") row(rx, rw_, 0.70, "Batteries, 4-hour BESS", f"{bess} MW", ICE) row(rx, rw_, 0.635, "Demand response (DSM)", f"{dsm} MW", ICE) row(rx, rw_, 0.57, "Interties, deliverability first [A]", f"0-{intertie} MW", ICE) row(rx, rw_, 0.35, "Hub net electric credit", f"+{hub_net} MW", GREEN) # The one bridge: thermal panel credit row -> electric panel credit row ax.add_patch(FancyArrowPatch((lx + lw_, 0.35), (rx, 0.35), arrowstyle="-|>", mutation_scale=13, lw=1.8, color=GREEN, zorder=4)) ax.text((lx + lw_ + rx) / 2, 0.375, "net only", ha="center", fontsize=8.2, fontweight="bold", color=GREEN, zorder=4) hairline(rx, rw_, 0.305, ICE) ax.text(rx + 0.02, 0.265, "Winter firm by accreditation", ha="left", va="center", fontsize=9.8, fontweight="bold", color=CHARCOAL, zorder=3) tiles = [ ("Conservative", cons, f"stress floor, {m_cons:+d}"), ("Reference", ref, f"covers {int(v('shortfall_2030_mw'))}, {m_ref:+d}"), ("Full", full, f"ceiling, {m_full:+d}"), ] tw = (rw_ - 0.04 - 0.02 * 2) / 3 for i, (name, mw, note) in enumerate(tiles): tx = rx + 0.02 + i * (tw + 0.02) ax.add_patch(Rectangle((tx, 0.175), tw, 0.075, fc=WHITE, ec=BORDER, lw=1.0, zorder=3)) ax.add_patch(Rectangle((tx, 0.242), tw, 0.008, fc=ICE, ec="none", zorder=4)) ax.text(tx + tw / 2, 0.228, name, ha="center", fontsize=7.8, color=SLATE, zorder=4) ax.text(tx + tw / 2, 0.204, f"{mw} MW", ha="center", fontsize=10.5, fontweight="bold", color=GREEN_DARK, family=MONO, zorder=4) ax.text(tx + tw / 2, 0.184, note, ha="center", fontsize=7.0, color=SLATE, zorder=4) ax.text(0.045, 0.075, "Thermal and electric units never share a sum. [C]", ha="left", fontsize=8.8, fontweight="bold", color=CHARCOAL) ax.text(0.045, 0.038, "Source: assumptions.yaml / system_b_ledger.py", ha="left", fontsize=8.0, color=SLATE, style="italic") _save(fig, "fig7_dual_ledger.png") def fig8(): fig = plt.figure(figsize=(11.4, 4.9)) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") ax.add_patch(Rectangle((0, 0), 1, 1, fc=WHITE, ec=None)) ax.text(0.045, 0.92, "Three gates before any compute megawatt counts", ha="left", va="center", fontsize=15, fontweight="bold", color=GREEN_DARK) ax.text(0.045, 0.845, "Every gate is a verifiable contract or measurement, not a promise", ha="left", va="center", fontsize=9.5, color=SLATE) xs = [0.045, 0.285, 0.525] w, h, y = 0.19, 0.34, 0.38 cards = [ ("Gate 1", ["Verified heat customer,", "contracted offtake"]), ("Gate 2", ["Verified grid headroom", "at the connection point"]), ("Gate 3", ["Contracted compute", "buyer, public terms"]), ] for (title, lines), x in zip(cards, xs): _flat_card(ax, x, y, w, h, ICE, title, lines, bg=ELECTRIC_BG) gap = xs[1] - (xs[0] + w) for a, b in zip(xs[:-1], xs[1:]): _lane_arrow(ax, (a + w + gap * 0.15, y + h / 2), (b - gap * 0.15, y + h / 2), ICE) _lane_arrow(ax, (xs[2] + w + gap * 0.15, y + h / 2), (0.765 - gap * 0.15, y + h / 2), GREEN) _flat_card(ax, 0.765, y, 0.19, h, GREEN, "All three clear", ["Modules may count.", "Until then: 0 MW [C]"], bg="#d8f3e4") ax.text(0.045, 0.20, "Eligible capacity today = 0 MW [C]. A conditional ceiling is not eligibility.", ha="left", fontsize=9.5, fontweight="bold", color=CHARCOAL) ax.text(0.045, 0.13, SOURCE_LINE, ha="left", fontsize=8.0, color=SLATE, style="italic") _save(fig, "fig8_three_gates.png") def fig9(): """3-of-10 cold-event record as an honest event grid, plus the two conclusions.""" fig = plt.figure(figsize=(9.6, 4.9)) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") ax.add_patch(Rectangle((0, 0), 1, 1, fc=WHITE, ec=None)) ax.text(0.045, 0.92, "The event record earns an audit, not a derate", ha="left", fontsize=15, fontweight="bold", color=GREEN_DARK) ax.text(0.045, 0.845, "Brandon gas in the ten highest-demand winter events of the last five years [PD]", ha="left", fontsize=9.5, color=SLATE) lo_ev, hi_ev = v("gas_cold_events_outage") n_fail = int(lo_ev) n_total = int(hi_ev) # One slot per top-10 event; which three is in the PUB record, order here is not ranked. sw, gap_w, y0, sh = 0.074, 0.017, 0.52, 0.20 x0 = 0.045 for i in range(n_total): x = x0 + i * (sw + gap_w) failed = i < n_fail ax.add_patch(Rectangle((x, y0), sw, sh, fc=ROSE if failed else "#eef2f4", ec=ROSE if failed else BORDER, lw=1.2, zorder=2)) if failed: ax.text(x + sw / 2, y0 + sh / 2, "✕", ha="center", va="center", fontsize=13, fontweight="bold", color=WHITE, zorder=3) brace_y = y0 + sh + 0.05 fail_w = n_fail * sw + (n_fail - 1) * gap_w ax.text(x0 + fail_w / 2, brace_y, f"{n_fail} with outages / deratings [PD]", ha="center", fontsize=9, fontweight="bold", color=ROSE) rest_x = x0 + n_fail * (sw + gap_w) rest_w = (n_total - n_fail) * sw + (n_total - n_fail - 1) * gap_w ax.text(rest_x + rest_w / 2, brace_y, f"{n_total - n_fail} delivered", ha="center", fontsize=9, color=SLATE) # The two conclusions, side by side. cy, ch, cw = 0.155, 0.24, 0.435 _flat_card(ax, 0.045, cy, cw, ch, "#9aa5b1", "Flat 30% derate — retired", ["Do not assert it as fact; 525 MW stays a scenario [A]"], bg="#f5f7f8", title_color=SLATE) _flat_card(ax, 0.52, cy, cw, ch, ICE, "Independent ELCC study — the Board ask", ["Identical accreditation treatment for gas and hybrid alike"], bg=ELECTRIC_BG, title_color=ICE) ax.text(0.045, 0.075, "Which three events: see the PUB testimony record [PD]. Slots above are unranked.", ha="left", fontsize=8.2, color=SLATE) ax.text(0.045, 0.035, SOURCE_LINE, ha="left", fontsize=8.0, color=SLATE, style="italic") _save(fig, "fig9_reliability_elcc.png") def fig10(): """Basin / Emerson evidence schematic (provenance, not a GIS screenshot substitute).""" fig = plt.figure(figsize=(10, 5.2)) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") ax.add_patch(Rectangle((0, 0), 1, 1, fc=WHITE, ec=None)) ax.text(0.045, 0.92, "Basin evidence provenance", ha="left", fontsize=15, fontweight="bold", color=GREEN_DARK) ax.text(0.045, 0.845, "Binational datasets joined on the Emerson gauge spine", ha="left", fontsize=9.5, color=SLATE) ca_obs = int(v("ca_observations")) us_obs = int(v("us_observations")) p_days = int(v("emerson_load_days")) layers = [ (0.08, 0.62, f"ECCC / MB nutrients [O]\n{ca_obs:,} observations", ICE_LIGHT, ICE), (0.38, 0.62, "WSC 05OC001 Emerson [O]\ndaily streamflow spine", ELECTRIC_BG, ICE), (0.68, 0.62, f"US WQP nutrients [O]\n{us_obs:,} obs · 3,574 stations", ICE_LIGHT, ICE), (0.08, 0.28, "NHDPlus + MB waterways [O]", "#d8f3e4", GREEN), (0.38, 0.28, f"{p_days} paired P load days [C]", THERMAL_BG, AMBER), (0.68, 0.28, "Heat sinks / gates [C]", THERMAL_BG, AMBER), ] for x, y, text, fc, ec in layers: _box(ax, (x, y), 0.24, 0.2, text, fc, ec, 8.5) ax.text( 0.045, 0.1, "[O/C] Full map: research/outputs/red-river-heathost-gis/ · " + SOURCE_LINE, ha="left", fontsize=8, color=SLATE, style="italic", ) _save(fig, "fig10_basin_evidence_map.png") def fig11(): apply_report_style() fig, ax = plt.subplots(figsize=(9.2, 5.0)) labels = ["NEWPCC\nrecovery [E]", "Surplus-funded\nremediation, high [E]", "All basin measures\n(beyond this plan) [E]", "External\nload gap [E]"] vals = [130, 320, 1260, 3050] colors = [AMBER, ICE, GREEN, ROSE] bars = ax.bar(labels, vals, color=colors, edgecolor=CHARCOAL) for bar, val in zip(bars, vals): ax.text(bar.get_x() + bar.get_width() / 2, val + 40, f"{val} t/yr", ha="center", fontweight="bold", fontsize=9) ax.set_ylabel("Phosphorus (tonnes / year)") _headline(ax, "Honest phosphorus arithmetic", "The plan's own yield is 130–320 t/yr — ~4% of the gap; the 1,260 t stack needs basin-wide measures beyond this plan") _footer(ax, "[E] NEWPCC ~130 t/yr ≈ 4% of the 3,050 t gap. ≥35% surplus first lien. Border objective 1,400 t/yr [O].") fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig11_phosphorus_honesty.png") def fig12(): fig = plt.figure(figsize=(10, 6.2)) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") ax.add_patch(Rectangle((0, 0), 1, 1, fc=WHITE, ec=None)) ax.text(0.045, 0.94, "Six Board orders", ha="left", fontsize=16, fontweight="bold", color=GREEN_DARK) ax.text(0.045, 0.885, "The closing ask: a fair exam for both plans, before the concrete", ha="left", fontsize=9.5, color=SLATE) orders = [ "Head-to-head hybrid vs gas modelling before any capital certificate", "Independent cold-weather reliability audit, identical ELCC treatment", "Machine-readable economic models from both parties", "Disclose interconnection headroom and extreme-event logs", "Preserve the NEWPCC trench option for thermal headers", "Authorize a measured pilot: effluent hub plus 10-20 kW nodes", ] accents = [GREEN, ROSE, ICE, "#3d2c6b", AMBER, "#7a2e0b"] y = 0.775 for i, (text, accent) in enumerate(zip(orders, accents), start=1): ax.add_patch(Rectangle((0.045, y - 0.045), 0.91, 0.093, fc=WHITE, ec=BORDER, lw=1.0)) ax.add_patch(Rectangle((0.045, y - 0.045), 0.007, 0.093, fc=accent, ec="none")) ax.text(0.072, y, str(i), ha="center", va="center", fontsize=10.5, fontweight="bold", color=accent, family=MONO) ax.text(0.095, y, text, ha="left", va="center", fontsize=9.6, color=CHARCOAL) y -= 0.117 ax.text(0.045, 0.035, "From regulator paper §11 · " + SOURCE_LINE, ha="left", fontsize=8, color=SLATE, style="italic") _save(fig, "fig12_board_orders.png") def fig13(): """Summer load vs temperature: PDRC + BTES as an add-on, never winter firm.""" apply_report_style() import numpy as np pdrc = int(v("pdrc_summer_shave_mw")) btes = int(v("btes_summer_chiller_shave_mw")) anchor = int(v("summer_peak_anchor_mw")) temps = np.linspace(15, 38, 100) load = np.array([summer_system_load_mw(t) for t in temps]) with_pdrc = np.array([summer_load_with_pdrc_mw(t) for t in temps]) # BTES avoids chiller load, so its shave only exists once cooling load does # (above the cooling threshold); short ramp instead of a hard step. thresh = float(v("summer_heat_threshold_c")) btes_shave = np.clip((temps - thresh) / 3.0, 0.0, 1.0) * btes with_both = with_pdrc - btes_shave fig, ax = plt.subplots(figsize=(9.2, 5.0)) ax.plot(temps, load, color=ROSE, lw=2.2, label="Unmanaged summer load (illustrative shape)") ax.plot(temps, with_pdrc, color=ICE, lw=2.0, ls="--", label=f"With PDRC roof film, -{pdrc} MW at +35 °C [E]") ax.plot(temps, with_both, color=GREEN, lw=2.2, label=f"PDRC + BTES chiller avoidance, -{pdrc + btes} MW [E]") ax.scatter([35], [anchor], color=ROSE, s=90, zorder=5, label=f"Heatwave anchor ~{anchor:,} MW (illustrative)") ax.set_xlabel("Outdoor temperature (°C)") ax.set_ylabel("System load (MW)") _headline(ax, "Summer load falls with radiative cooling", "A residual benefit on its own ledger line, never winter firm") ax.legend(fontsize=8.5, loc="upper left") ax.grid(True, alpha=0.35) _footer(ax, "[E] Slopes from registry summer_* keys; BTES shave applies only once cooling load exists.") fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig13_summer_load_vs_temp.png") def fig14(): """A 24-hour heatwave day: PDRC tracks the sun, BTES avoids chillers all day.""" apply_report_style() import numpy as np pdrc = int(v("pdrc_summer_shave_mw")) btes = int(v("btes_summer_chiller_shave_mw")) anchor = float(v("summer_peak_anchor_mw")) hours = np.arange(24) amp = 650.0 base = anchor - amp load = base + amp * np.exp(-(((hours - 16) / 4.0) ** 2)) shaved = load - pdrc * np.exp(-(((hours - 14) / 4.5) ** 2)) - btes fig, ax = plt.subplots(figsize=(9.2, 5.0)) ax.plot(hours, load, color=ROSE, lw=2.2, label="Unmanaged heatwave day (illustrative shape)") ax.plot(hours, shaved, color=GREEN, lw=2.2, label=f"With PDRC + BTES, up to -{pdrc + btes} MW [E]") ax.fill_between(hours, shaved, load, color=THERMAL_BG, alpha=0.95, label="Summer shave zone") ax.set_xticks(range(0, 24, 2)) ax.set_xlabel("Hour of day") ax.set_ylabel("System load (MW)") _headline(ax, "A 24-hour heatwave day at +35 °C", "PDRC tracks the sun; BTES avoids parasitic chillers around the clock") ax.legend(fontsize=8.5, loc="upper left") ax.grid(True, alpha=0.35) _footer(ax, f"[E] Peaks near {int(anchor):,} MW; summer add-on only — never winter firm.") fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig14_summer_day_24h.png") def fig15(): """Dual-season roles on one plate: winter ledgers, summer add-on, accreditation.""" fig = plt.figure(figsize=(12.2, 5.6)) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") ax.add_patch(Rectangle((0, 0), 1, 1, fc=WHITE, ec=None)) ledger = build_ledger() effluent = int(ledger.thermal_ledger_mwth["effluent"]) reject = int(ledger.thermal_ledger_mwth["compute_reject"]) hub_th = int(v("hub_thermal_mwth")) hub_net = int(v("hub_net_electric_mw")) pdrc = int(v("pdrc_summer_shave_mw")) btes = int(v("btes_summer_chiller_shave_mw")) cons, ref, full = (int(v(k)) for k in ("scenario_conservative_mw", "scenario_reference_mw", "scenario_full_mw")) m_ref = int(ledger.margin_mw("reference")) ax.text(0.045, 0.93, "One system, two seasons, three ledger lines", ha="left", va="center", fontsize=16, fontweight="bold", color=GREEN_DARK) ax.text(0.045, 0.855, "Winter heat and firm electric stay separate; summer relief is an add-on line of its own", ha="left", va="center", fontsize=10, color=SLATE) y, h, w = 0.24, 0.5, 0.28 _flat_card(ax, 0.045, y, w, h, ICE, f"Winter, -35 °C", [f"Effluent heat {effluent} MWth delivered", f"+{reject} MWth compute (gated)", f"Counts today: {hub_th} MWth", f"Credits +{hub_net} MW net electric"], bg=ELECTRIC_BG) _flat_card(ax, 0.365, y, w, h, AMBER, f"Summer, +35 °C", [f"PDRC roof film -{pdrc} MW", f"BTES chiller avoidance -{btes} MW", f"Total add-on -{pdrc + btes} MW [E]", "Never counted as winter firm"], bg=THERMAL_BG) _flat_card(ax, 0.685, y, w, h, GREEN, "Winter firm, accredited", [f"Conservative {cons} MW", f"Reference {ref} MW ({m_ref:+d} vs {int(v('shortfall_2030_mw'))})", f"Full {full} MW", "Electric ledger only [C]"], bg="#d8f3e4") gap = 0.365 - (0.045 + w) _lane_arrow(ax, (0.045 + w + gap * 0.2, y + h / 2), (0.365 - gap * 0.2, y + h / 2), SLATE) _lane_arrow(ax, (0.365 + w + gap * 0.2, y + h / 2), (0.685 - gap * 0.2, y + h / 2), SLATE) ax.text(0.045, 0.14, "Same machines, both seasons. Only the electric ledger decides the winter case.", ha="left", fontsize=9.5, fontweight="bold", color=CHARCOAL) ax.text(0.045, 0.07, SOURCE_LINE, ha="left", fontsize=8.0, color=SLATE, style="italic") _save(fig, "fig15_dual_season_matrix.png") def fig16(): """2024–2035 winter peak path — branded replacement for the legacy projections chart.""" apply_report_style() import numpy as np ledger = build_ledger() hub_e = float(v("hub_net_electric_mw")) cons = float(ledger.accreditation_mw["conservative"]) ref = float(ledger.accreditation_mw["reference"]) full = float(ledger.accreditation_mw["full"]) gas_name = float(v("gas_nameplate_mw")) gas_derate = float(v("gas_derate_scenario_mw")) years = np.array([2024, 2026, 2028, 2030, 2032, 2034, 2035]) demand = np.array([5096, 5250, 5420, 5650, 5900, 6150, 6300]) shortfall = float(v("shortfall_2030_mw")) # ONE baseline: existing firm resources implied by the PD shortfall # (2030 demand 5,650 - 600 MW shortfall). Firm hydro (~4,800) is a # component of that; do not mix the two on the same exhibit. firm_existing = np.full_like(demand, int(demand[3] - shortfall)) gas_plan = firm_existing + np.array([0, 0, 0, gas_name, gas_name, gas_name, gas_name]) gas_derated = firm_existing + np.array([0, 0, 0, gas_derate, gas_derate, gas_derate, gas_derate]) shave = np.array([0, hub_e, cons, ref, ref, full, full]) managed = demand - shave fig, ax = plt.subplots(figsize=(9.2, 5.2)) ax.fill_between(years, firm_existing, demand, where=(demand > firm_existing), color=ROSE, alpha=0.08, label="Gap vs existing firm resources") ax.plot(years, demand, color=ROSE, lw=2.2, marker="o", ms=4, label="Projected peak demand (unmanaged) [PD/E]") ax.plot(years, managed, color=ICE, lw=2.2, marker="s", ms=4, label=f"Hybrid-managed demand (ledger ramp → {ref:.0f} ref / {full:.0f} full MW) [C]") ax.plot(years, gas_plan, color=CHARCOAL, lw=1.4, ls="--", label=f"Existing firm + {gas_name:.0f} MW gas nameplate") ax.plot(years, gas_derated, color=ORANGE, lw=1.6, ls=":", label=f"Existing firm + gas derate sensitivity ({gas_derate:.0f} MW) [A]") ax.axhline(float(firm_existing[0]), color=SLATE, lw=1.2, ls="--", label=f"Existing firm resources ({int(firm_existing[0]):,} MW, implied by the {int(shortfall)} MW 2030 shortfall [PD])") ax.set_xlabel("Year") ax.set_ylabel("Winter peak (MW)") _headline(ax, "2024–2035 winter peak path", "Hybrid covers the 2030 decision window; NEITHER plan closes 2035 alone — build the one you can extend") ax.legend(fontsize=7.8, loc="upper left") ax.grid(True, alpha=0.35) _footer(ax, "[E/C] Demand path illustrative; shave ramp uses ledger accreditation only. One baseline throughout.") fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig16_peak_projections.png") def fig17(): """Our plan vs Brandon gas — print twin of the landing page decision-compare chart.""" apply_report_style() from matplotlib.patches import Rectangle as Rect ledger = build_ledger() gas_lo, gas_hi = ledger.gas_20yr_low_b, ledger.gas_20yr_high_b hyb_lo, hyb_hi = ledger.hybrid_20yr_low_b, ledger.hybrid_20yr_high_b sav_lo, sav_hi = ledger.savings_band_b fig, ax = plt.subplots(figsize=(9.2, 4.4)) xmax = 8.0 rows = [ ("Brandon gas", gas_lo, gas_hi, ORANGE_LIGHT, 0.85, SLATE), ("Our plan (hybrid)", hyb_lo, hyb_hi, GREEN, 1.0, GREEN_DARK), ] ys = [1.0, 0.0] for (label, lo, hi, color, alpha, _), y in zip(rows, ys): ax.barh(y, hi - lo, left=lo, height=0.42, color=color, alpha=alpha, edgecolor=CHARCOAL, lw=0.8, zorder=3) ax.text(lo - 0.08, y, f"${lo:.1f}B", ha="right", va="center", fontsize=9.5, fontweight="bold", color=CHARCOAL) ax.text(hi + 0.08, y, f"${hi:.1f}B", ha="right" if hi > xmax - 0.5 else "left", va="center", fontsize=9.5, fontweight="bold", color=CHARCOAL) # Non-overlapping gap (hybrid high → gas low) = minimum savings [E/A] ax.add_patch(Rect((hyb_hi, 0.21), gas_lo - hyb_hi, 0.58, fc=GREEN_MID, alpha=0.28, zorder=2)) for x in (hyb_hi, gas_lo): ax.plot([x, x], [0.21, 0.79], color=GREEN, lw=1.4, ls=(0, (3, 3)), zorder=4) ax.text((hyb_hi + gas_lo) / 2, 0.5, f"≥ ${sav_lo:.1f}B", ha="center", va="center", fontsize=10.5, fontweight="bold", color=GREEN_DARK, zorder=5) ax.set_yticks(ys) ax.set_yticklabels([r[0] for r in rows], fontsize=10) for tick, (_, _, _, _, _, c) in zip(ax.get_yticklabels(), rows): tick.set_color(c) ax.set_xlim(0, xmax) ax.set_ylim(-0.75, 1.55) ax.set_xticks(range(0, 9)) ax.set_xticklabels([f"${t}B" for t in range(0, 9)]) ax.set_xlabel("Twenty-year all-in cost (\\$ billions)") ax.grid(True, axis="x", alpha=0.35) ax.set_axisbelow(True) _headline(ax, "Our plan vs Brandon gas", "Capital and twenty-year all-in — savings band highlighted") ax.text(xmax / 2, -0.52, f"Ratepayer savings ${sav_lo:.1f}–{sav_hi:.1f}B [E/A]", ha="center", va="center", fontsize=10, fontweight="bold", color=GREEN_DARK, bbox=dict(boxstyle="square,pad=0.55", fc=GREEN_BG, ec=GREEN, lw=1.2)) _footer(ax, "[E/A] Bands modeled with fuel and carbon; gap = hybrid high to gas low.", y=-0.22) fig.tight_layout(rect=(0, 0.06, 1, 1)) _save(fig, "fig17_decision_compare.png") def main(): apply_report_style() print(f"Generating report-v2 figures → {OUT}") fig1() fig2() fig3() fig4() fig5() fig6() fig7() fig8() fig9() fig10() fig11() fig12() fig13() fig14() fig15() fig16() fig17() ledger = build_ledger() assert ledger.accreditation_mw["full"] == v("scenario_full_mw") print("all F1–F15 generated; ledger consistency OK") if __name__ == "__main__": main()