"""Conversion and climate study book. Not the winter resource stack.""" from __future__ import annotations from pathlib import Path import yaml STUDY_YAML = Path(__file__).with_name("heat_pump_study.yaml") def load_study(path: Path = STUDY_YAML) -> dict: data = yaml.safe_load(path.read_text()) return {k: v for k, v in data.items() if not str(k).startswith("_")} def sv(key: str, path: Path = STUDY_YAML): return load_study(path)[key]["value"] def unmanaged_peak_mw(n_dwellings: float, share: float, kw_per_home: float) -> float: return float(n_dwellings) * float(share) * float(kw_per_home) / 1000.0 def dual_fuel_extra_mw(n_dwellings: float, share: float, kw_per_home: float) -> float: del n_dwellings, share, kw_per_home return 0.0 def identity_verdict( computed_mw: tuple[float, ...], registry_mw: float = 675.0, tol_mw: float = 25.0, ) -> dict: lo, hi = min(computed_mw), max(computed_mw) status = ( "confirm" if abs(lo - registry_mw) <= tol_mw and abs(hi - registry_mw) <= tol_mw else "fence" ) return { "status": status, "range_mw": (lo, hi), "round_identity_mw": unmanaged_peak_mw(300_000, 0.15, 15.0), "registry_mw": registry_mw, "do_not_add_to_shortfall": True, } def organic_peak_2030_mw(record_mw: float, years: int, rate: float) -> float: return float(record_mw) * (1.0 + float(rate)) ** int(years) def shortfall_reconstruction( organic_2030_mw: float, record_mw: float, stated_shortfall_mw: float = 600.0, ) -> dict: growth = float(organic_2030_mw) - float(record_mw) return { "organic_2030_mw": float(organic_2030_mw), "organic_growth_mw": growth, "stated_shortfall_mw": float(stated_shortfall_mw), "verdict": "keep_pd", "reason": ( "600 is a drought firm-supply gap, not organic load growth from the " "2025 record peak. Firm hydro MW is not in the main registry. Keep " "shortfall_2030_mw as PD." ), "do_not_replace_shortfall": True, "do_not_add_to_675": True, } def load_daily_mins(path: Path) -> list[float]: import csv with path.open() as f: return [float(row["min_c"]) for row in csv.DictReader(f)] def count_extremes( temps: list[float], thresholds: tuple[float, ...] = (-30.0, -35.0) ) -> dict: return {t: sum(1 for x in temps if x <= t) for t in thresholds} def apply_warming_bands(count_at_minus_35: float, low: float, high: float) -> dict: return { "low": float(count_at_minus_35) * float(low), "high": float(count_at_minus_35) * float(high), } def climate_forecast_or_observations_only( atlas_factors: dict | None, observed_count: float ) -> dict: if not atlas_factors: return {"mode": "observations_only", "observed_count": observed_count} return { "mode": "c_plus", "observed_count": observed_count, "forecast": apply_warming_bands( observed_count, atlas_factors["low"], atlas_factors["high"] ), } def rollout_share(n: float, annual_installs: float, years: int) -> float: return (float(annual_installs) * int(years)) / float(n) def household_book_cad(installs: float, net_cost_per_home: float) -> float: return float(installs) * float(net_cost_per_home) def utility_peak_capital_b( extra_mw: float, gas_capex_b: float, gas_nameplate_mw: float ) -> float: if extra_mw <= 0: return 0.0 return float(extra_mw) / float(gas_nameplate_mw) * float(gas_capex_b) def combined_forbidden(household_cad: float, utility_b: float) -> None: raise ValueError("never add household $ to utility peak $") def render_study_note() -> str: nrcan_n = float(sv("nrcan_mb_gas_heating_stock")) share = float(sv("conversion_share")) kw = float(sv("kw_resistance_at_design")) households = float(sv("mb_private_households_ref")) cer_n = households * float(sv("cer_mb_gas_share")) nrcan_mw = unmanaged_peak_mw(nrcan_n, share, kw) cer_mw = unmanaged_peak_mw(cer_n, share, kw) verdict = identity_verdict((nrcan_mw, cer_mw), 675.0, 25.0) from regulator_paper_assumptions import v organic = organic_peak_2030_mw( float(v("peak_record_mw")), int(sv("years_record_to_2030")), float(sv("organic_growth_rate")), ) rec = shortfall_reconstruction(organic, float(v("peak_record_mw")), 600.0) installs_2030 = float(sv("annual_installs_e")) * int(sv("years_to_2030")) share_2030 = rollout_share( nrcan_n, float(sv("annual_installs_e")), int(sv("years_to_2030")) ) share_2040 = rollout_share( nrcan_n, float(sv("annual_installs_e")), int(sv("years_to_2040")) ) hh = household_book_cad(installs_2030, float(sv("net_cost_per_home_cad"))) util = utility_peak_capital_b( nrcan_mw, float(v("gas_capex_b")), float(v("gas_nameplate_mw")) ) return ( "# Heat-pump rollout and peak study\n\n" "Separate book. Never add Hydro’s 600 MW shortfall to the 675 MW conversion " "stress. Never add either to the winter resource stack.\n\n" "## Chapter 1 — Is 600 real?\n\n" f"Organic peak in 2030 from the 2025 record at 1.2%/yr is " f"**{organic:.0f} MW** (growth **{rec['organic_growth_mw']:.0f} MW**). " "That is not 600. Verdict: **keep_pd**. 600 is a drought firm-supply gap. " "Do not replace `shortfall_2030_mw`. Do not add it to 675.\n\n" "## Chapter 2 — Is 675 real?\n\n" f"NRCan 2021 gas heating stock **{nrcan_n:,.0f}** × 15% × 15 kW = " f"**{nrcan_mw:.1f} MW**. CER 51% of {households:,.0f} households = " f"**{cer_n:,.0f}** → **{cer_mw:.1f} MW**. The live **675 MW** is the " "round identity 300,000 × 15% × 15 kW. Sources disagree. " f"Verdict: **{verdict['status']}**. Do not add to the shortfall.\n\n" "## Chapter 1b — Is −35 °C real?\n\n" "Registry already splits the 20 January 2025 peak at **−32.7 °C** from " "the **−35 °C** design hour. Fixture counts are in " "`research/data/climate_fixtures/`. Climate Atlas factors are **E** " "placeholders until pasted; missing Atlas → observations only.\n\n" "## Chapter 3 — Rollout\n\n" f"At {float(sv('annual_installs_e')):,.0f} homes/year (E), 2030 share of NRCan " f"stock is **{share_2030:.1%}** — below 15%. 2040 tail is **{share_2040:.1%}**. " f"Household book (2030 installs): **${hh:,.0f}**. Utility book on the " f"NRCan furnace-out peak: **${util:.2f}B** of Brandon-like capital. " "Never add the two books. Dual-fuel extra coincident MW at the design " "hour is **0**.\n" ) if __name__ == "__main__": note = render_study_note() out = Path(__file__).resolve().parent / "dist" / "heat-pump-rollout-study.md" out.write_text(note) print(out)