From 1e9fcdc6d9fc21ff5ea3aea9188adf87f2e92741 Mon Sep 17 00:00:00 2001 From: ktehranchi Date: Sat, 29 Aug 2026 15:49:50 -0700 Subject: [PATCH] Add CPUC Baseline Generator List capacity benchmark (PR 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compares installed capacity between the CPUC Baseline Generator List and the model's generator fleet, aggregated by SERVM benchmark region and technology category, per planning horizon. Emits a long-format comparison CSV and a per-horizon deviation heatmap under the run's figures/benchmark/ directory. Gated by the new run.benchmark_cpuc flag (default false), which is independent of the demand profile: the fleet benchmark is useful for any California run. Region attribution: EIA reports every CAISO plant under the single BA code CISO, so powerplants.csv cannot separate PGE from SCE from SDGE. The benchmark therefore runs at the coarsest resolution both sides support — CAISO (= PGE + SCE + SDGE), LADWP (LDWP), IID, NCNC (BANC + TIDC) — with the collapse stated in repo_data/CPUC/servm_benchmark_regions.csv. The model side is restricted to state == CA so EIA's CISO code does not drag in the Nevada CISO-VEA footprint. Technology attribution: repo_data/CPUC/servm_tech_map.csv maps both SERVM tech categories and PyPSA carriers into a shared compare_category. Anything unmapped on either side becomes an explicit UNMAPPED: row rather than being dropped. Vintage/retirement filtering is shared by both sides and mirrors add_electricity.load_powerplants, so the benchmark measures the fleet the model actually builds. Co-Authored-By: Claude Fable 5 --- docs/source/configtables/run.csv | 1 + workflow/Snakefile | 23 + .../CPUC/servm_benchmark_regions.csv | 8 + workflow/repo_data/CPUC/servm_tech_map.csv | 52 ++ workflow/repo_data/config/config.default.yaml | 1 + workflow/rules/validate.smk | 29 + workflow/scripts/benchmark_cpuc_baseline.py | 626 ++++++++++++++++++ .../test/test_benchmark_cpuc_baseline.py | 474 +++++++++++++ 8 files changed, 1214 insertions(+) create mode 100644 workflow/repo_data/CPUC/servm_benchmark_regions.csv create mode 100644 workflow/repo_data/CPUC/servm_tech_map.csv create mode 100644 workflow/scripts/benchmark_cpuc_baseline.py create mode 100644 workflow/scripts/test/test_benchmark_cpuc_baseline.py diff --git a/docs/source/configtables/run.csv b/docs/source/configtables/run.csv index 0cce5f09c..4986acc83 100644 --- a/docs/source/configtables/run.csv +++ b/docs/source/configtables/run.csv @@ -4,3 +4,4 @@ disable_progrssbar,bool,"{true, false}",Switch to select whether progressbar sho shared_resources,bool,"{true, false}",Switch to select whether resources should be shared across runs. shared_cutouts,bool,"{true, false}",Switch to select whether cutouts should be shared across runs. validation,bool,"{true, false}",Switch to enable back-casting validation plotting +benchmark_cpuc,bool,"{true, false}",Switch to enable the CPUC Baseline Generator List capacity benchmark (California runs) diff --git a/workflow/Snakefile b/workflow/Snakefile index 809f06e62..35c8c0a59 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -258,6 +258,28 @@ def validation_figures(wildcards): return [] +BENCHMARK_CPUC_OUTPUTS = [ + "cpuc_capacity_benchmark.csv", + "cpuc_capacity_deviation.pdf", +] + + +def benchmark_figures(wildcards): + """CPUC Baseline Generator List capacity benchmark, opt-in via run.benchmark_cpuc. + + Gated on its own flag rather than on the demand profile: the fleet + benchmark is useful for any California run, whatever drives its load. + """ + if not config["run"].get("benchmark_cpuc", False): + return [] + return expand( + RESULTS + + "{interconnect}/figures/s{simpl}_cluster_{clusters}/l{ll}_{opts}_{sector}/benchmark/{figure}", + **config["scenario"], + figure=BENCHMARK_CPUC_OUTPUTS, + ) + + rule all: input: #"repo_data/dag.jpg", @@ -265,6 +287,7 @@ rule all: electricity_figures, sector_figures, validation_figures, + benchmark_figures, rule data_model: diff --git a/workflow/repo_data/CPUC/servm_benchmark_regions.csv b/workflow/repo_data/CPUC/servm_benchmark_regions.csv new file mode 100644 index 000000000..c273e14af --- /dev/null +++ b/workflow/repo_data/CPUC/servm_benchmark_regions.csv @@ -0,0 +1,8 @@ +servm_region,eia_ba_code,benchmark_region,note +PGE,CISO,CAISO,EIA reports every CAISO plant under the single BA code CISO; PGE/SCE/SDGE cannot be split without geography +SCE,CISO,CAISO,rolled into CAISO for the same reason +SDGE,CISO,CAISO,rolled into CAISO for the same reason +LADWP,LDWP,LADWP, +IID,IID,IID, +NCNC,BANC,NCNC,Balancing Authority of Northern California +NCNC,TIDC,NCNC,Turlock Irrigation District diff --git a/workflow/repo_data/CPUC/servm_tech_map.csv b/workflow/repo_data/CPUC/servm_tech_map.csv new file mode 100644 index 000000000..5f8bd6f87 --- /dev/null +++ b/workflow/repo_data/CPUC/servm_tech_map.csv @@ -0,0 +1,52 @@ +side,source_category,compare_category,note +cpuc,CC,Gas CC, +cpuc,CT,Gas CT/ICE/Steam, +cpuc,ICE,Gas CT/ICE/Steam, +cpuc,Steam,Gas CT/ICE/Steam,CA gas steam boilers; EIA maps 'Natural Gas Steam Turbine' to OCGT so it cannot be split out on the model side +cpuc,Cogen,Gas Cogen/CHP,CPUC-only bucket; EIA/PyPSA carriers have no CHP concept so these units land in CCGT/OCGT on the model side +cpuc,Coal,Coal, +cpuc,Nuclear,Nuclear, +cpuc,Geothermal,Geothermal, +cpuc,Biomass/Wood,Biomass, +cpuc,Biogas,Biomass, +cpuc,Hydro,Hydro, +cpuc,PSH,PSH, +cpuc,Wind,Wind, +cpuc,OOS_Wind,Wind,out-of-state wind contracted to a CA region; absent from the CA subset of the 2026 baseline +cpuc,Solar_1Axis,Solar, +cpuc,Solar_Fixed,Solar, +cpuc,Solar_2Axis,Solar, +cpuc,Solar_Thermal,Solar, +cpuc,Paired_Solar_1Axis,Solar, +cpuc,Paired_Solar_Fixed,Solar, +cpuc,Hybrid_Solar_1Axis,Solar, +cpuc,Hybrid_Solar_Fixed,Solar, +cpuc,Battery_4h,Battery, +cpuc,Paired_BattStorage,Battery, +cpuc,Hybrid_BattStorage,Battery, +cpuc,DR,Demand Response,CPUC-only; PyPSA-USA has no demand-response resource +cpuc,Pumping_Load,Pumping Load,CPUC-only; modelled as load rather than as a resource in PyPSA-USA +pypsa,CCGT,Gas CC, +pypsa,CCGT-95CCS,Gas CC, +pypsa,OCGT,Gas CT/ICE/Steam,EIA 'Natural Gas Steam Turbine' / 'Combustion Turbine' / 'Internal Combustion Engine' all map here +pypsa,hydrogen_ct,Gas CT/ICE/Steam, +pypsa,coal,Coal, +pypsa,nuclear,Nuclear, +pypsa,geothermal,Geothermal, +pypsa,EGS,Geothermal, +pypsa,biomass,Biomass, +pypsa,waste,Biomass,municipal solid waste; CPUC folds it into Biogas +pypsa,hydro,Hydro,pumped storage is split off before mapping via prime_mover_code == PS +pypsa,PHS,PSH, +pypsa,onwind,Wind, +pypsa,offwind,Wind, +pypsa,offwind_floating,Wind, +pypsa,solar,Solar, +pypsa,battery,Battery, +pypsa,2hr_battery_storage,Battery, +pypsa,4hr_battery_storage,Battery, +pypsa,6hr_battery_storage,Battery, +pypsa,8hr_battery_storage,Battery, +pypsa,10hr_battery_storage,Battery, +pypsa,oil,Oil,model-only; CPUC has no oil bucket for CA +pypsa,other,Other,"model-only catch-all (flywheels, other gases, compressed air)" diff --git a/workflow/repo_data/config/config.default.yaml b/workflow/repo_data/config/config.default.yaml index 99fcc23d6..30de5ef2a 100644 --- a/workflow/repo_data/config/config.default.yaml +++ b/workflow/repo_data/config/config.default.yaml @@ -34,6 +34,7 @@ run: shared_resources: false # true = reuse resources/ across all runs (DAG-keyed); false = isolate per run.name shared_cutouts: true # true = share atlite cutouts across runs (recommended; cutouts are large and slow) validation: false # true = wire in back-casting validation plots (historical-only) + benchmark_cpuc: false # true = compare installed capacity against the CPUC Baseline Generator List (California runs) # ==================================================================== # RENEWABLE DATASET — capacity-factor source for wind & solar diff --git a/workflow/rules/validate.smk b/workflow/rules/validate.smk index a6b968bbd..985185163 100644 --- a/workflow/rules/validate.smk +++ b/workflow/rules/validate.smk @@ -66,3 +66,32 @@ rule plot_validation_figures: mem_mb=5000, script: "../scripts/plot_validation_production.py" + + +# Compares installed capacity against the CPUC Baseline Generator List, +# aggregated by SERVM benchmark region and technology, per planning horizon. +# Gated by run.benchmark_cpuc (see benchmark_figures in the Snakefile). +rule benchmark_cpuc_baseline: + params: + planning_horizons=config_provider("scenario", "planning_horizons"), + input: + network=NETWORKS + + "{interconnect}/elec_s{simpl}_c{clusters}_ec_l{ll}_{opts}_{sector}.nc", + powerplants="resources/powerplants/powerplants.csv", + cpuc_baseline=DATA + "cpuc/BaselineGeneratorList_CAISO.xlsx", + region_map="repo_data/CPUC/servm_benchmark_regions.csv", + tech_map="repo_data/CPUC/servm_tech_map.csv", + output: + comparison=RESULTS + + "{interconnect}/figures/s{simpl}_cluster_{clusters}/l{ll}_{opts}_{sector}/benchmark/cpuc_capacity_benchmark.csv", + heatmap=RESULTS + + "{interconnect}/figures/s{simpl}_cluster_{clusters}/l{ll}_{opts}_{sector}/benchmark/cpuc_capacity_deviation.pdf", + log: + LOGS + + "benchmark_cpuc_baseline/{interconnect}/elec_s{simpl}_c{clusters}_l{ll}_{opts}_{sector}.log", + threads: 1 + resources: + walltime="00:20:00", + mem_mb=5000, + script: + "../scripts/benchmark_cpuc_baseline.py" diff --git a/workflow/scripts/benchmark_cpuc_baseline.py b/workflow/scripts/benchmark_cpuc_baseline.py new file mode 100644 index 000000000..779711db9 --- /dev/null +++ b/workflow/scripts/benchmark_cpuc_baseline.py @@ -0,0 +1,626 @@ +# BY PyPSA-USA Authors +"""Benchmark the modelled California fleet against the CPUC Baseline Generator List. + +The CPUC publishes, alongside the SERVM hourly load, a unit-level baseline +generator list covering the whole WECC. Its California rows are the reference +fleet the CPUC's own SERVM/RESOLVE runs start from, so comparing installed +capacity against PyPSA-USA's fleet is a direct check on whether the model starts +a California study from a plausible resource mix. + +Two axes have to be reconciled before the sides are comparable. + +Region +------ +The CPUC tags every unit with a ``SERVM Region`` (PGE, SCE, SDGE, IID, LADWP, +NCNC). PyPSA-USA's ``powerplants.csv`` carries ``balancing_authority_code_eia``, +which is the *EIA* balancing-authority code — and EIA reports every CAISO plant +under the single code ``CISO`` with no sub-BA breakdown. There is therefore no +column on the model side that separates PGE from SCE from SDGE, and inventing +one (point-in-polygon against the clustered BA shapes) would need a +``resources/`` artifact that this rule does not consume. + +So the benchmark is run at the coarsest region resolution both sides support: + +* ``CAISO`` — CPUC PGE + SCE + SDGE vs. model BA ``CISO`` (the CPUC data + dictionary defines CAISO as exactly PGE + SCE + SDGE) +* ``LADWP`` — CPUC LADWP vs. model BA ``LDWP`` +* ``IID`` — CPUC IID vs. model BA ``IID`` +* ``NCNC`` — CPUC NCNC vs. model BAs ``BANC`` + ``TIDC`` + +The mapping lives in ``repo_data/CPUC/servm_benchmark_regions.csv`` so the +collapse is explicit and editable rather than hard-coded here. The model side is +additionally restricted to ``state == "CA"``: EIA's ``CISO`` also covers the +Valley Electric (CISO-VEA) footprint, which sits in Nevada and is excluded from +the SERVM California regions. + +Technology +---------- +SERVM splits technology far finer than PyPSA carriers do (nine solar buckets, a +paired/hybrid storage split, a CHP bucket). Both sides are therefore mapped into +a common ``compare_category`` by ``repo_data/CPUC/servm_tech_map.csv``, which +carries a ``side`` column (``cpuc`` / ``pypsa``) so one file documents both +directions. Anything unmapped on either side becomes ``UNMAPPED:`` and is +reported as its own row — a category is never silently dropped, because a silent +drop is exactly how a benchmark starts agreeing with itself. + +Two mappings deserve to be flagged when reading the output: + +* ``Gas Cogen/CHP`` is CPUC-only. EIA technology descriptions have no CHP + concept, so California gas cogeneration lands in ``CCGT``/``OCGT`` by prime + mover. Expect the model to be short in ``Gas Cogen/CHP`` and long in the two + gas buckets by roughly the same amount. +* ``Demand Response`` and ``Pumping Load`` are CPUC-only resources with no + PyPSA-USA counterpart; they are kept as rows with ``model_mw == 0``. + +Vintage and retirement +---------------------- +Both sides are filtered by the same rule (:func:`filter_active`): in service by +December 31 of the horizon, and not retired by then. The model side reproduces +``add_electricity.load_powerplants`` exactly (see +:func:`prepare_model_plants`) so this benchmark measures the fleet the model +actually builds, not a differently-filtered idealisation of it. +""" + +from __future__ import annotations + +import logging + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pypsa +import seaborn as sns +from _helpers import configure_logging + +logger = logging.getLogger(__name__) + +sns.set_theme("paper", style="whitegrid") + +DPI = 300 + +CPUC_SHEET = "BaselineGeneratorList" +#: The workbook's first row is blank; the real header is the second one. +CPUC_HEADER_ROW = 1 + +#: SERVM regions that make up California. Every other ``SERVM Region`` value in +#: the workbook is a non-CA WECC balancing area. +CA_SERVM_REGIONS = ("IID", "LADWP", "NCNC", "PGE", "SCE", "SDGE") + +CPUC_CAPACITY_COL = "Capmax MW" +CPUC_REGION_COL = "SERVM Region" +CPUC_TECH_COL = "SERVM Tech Category" +CPUC_INSV_COL = "Insvdt" +CPUC_RETIRE_COL = "RetireDate" + +#: Sentinel used by :func:`filter_active` for "no retirement date on record". +NEVER_RETIRES = pd.Timestamp("2262-01-01") + +UNMAPPED_PREFIX = "UNMAPPED:" + +#: Pseudo-region prefix for :func:`reconcile_totals`' informational rows. +RECONCILE_PREFIX = "RECONCILE:" + + +# --------------------------------------------------------------------------- # +# inputs +# --------------------------------------------------------------------------- # + + +def read_cpuc_baseline(xlsx_path: str) -> pd.DataFrame: + """ + Read the California rows of the CPUC Baseline Generator List. + + The workbook opens with a blank row, so the header is row 2 (``header=1``). + The published sheet covers all of WECC; only units whose ``SERVM Region`` is + one of :data:`CA_SERVM_REGIONS` are kept. + + ``Capmax MW`` is the nameplate capacity of the unit as SERVM sees it. Paired + and hybrid resources are published as separate rows per component (a + ``Paired_Solar_1Axis`` row and a ``Paired_BattStorage`` row), so summing + ``Capmax MW`` by technology counts each component once and never + double-counts the point of interconnection. + """ + df = pd.read_excel(xlsx_path, sheet_name=CPUC_SHEET, header=CPUC_HEADER_ROW) + + missing = [ + c + for c in (CPUC_CAPACITY_COL, CPUC_REGION_COL, CPUC_TECH_COL, CPUC_INSV_COL, CPUC_RETIRE_COL) + if c not in df.columns + ] + if missing: + raise ValueError( + f"{xlsx_path} sheet '{CPUC_SHEET}' is missing {missing}. The CPUC has changed the " + "workbook layout; update benchmark_cpuc_baseline.py's column constants.", + ) + + df = df.copy() + df[CPUC_REGION_COL] = df[CPUC_REGION_COL].astype("object").where(df[CPUC_REGION_COL].notna()).str.strip() + df[CPUC_TECH_COL] = df[CPUC_TECH_COL].astype("object").where(df[CPUC_TECH_COL].notna()).str.strip() + df[CPUC_CAPACITY_COL] = pd.to_numeric(df[CPUC_CAPACITY_COL], errors="coerce") + for col in (CPUC_INSV_COL, CPUC_RETIRE_COL): + df[col] = pd.to_datetime(df[col], errors="coerce") + + ca = df[df[CPUC_REGION_COL].isin(CA_SERVM_REGIONS)].copy() + if ca.empty: + raise ValueError( + f"No rows in {xlsx_path} carry a California SERVM Region ({', '.join(CA_SERVM_REGIONS)}). " + "Either the wrong sheet was read or the region labels have changed.", + ) + + logger.info( + "Read %d CPUC baseline units, %d of them in California across %d SERVM regions.", + len(df), + len(ca), + ca[CPUC_REGION_COL].nunique(), + ) + return ca + + +def load_tech_map(path: str) -> dict[str, dict[str, str]]: + """ + ``{side: {source_category: compare_category}}`` read from ``servm_tech_map.csv``. + + One file holds both directions, keyed by a ``side`` column of ``cpuc`` or + ``pypsa``, so the two halves of a comparison category can never drift apart + across separate files. + """ + df = pd.read_csv(path, dtype=str) + for col in ("side", "source_category", "compare_category"): + if col not in df.columns: + raise ValueError( + f"{path} must have columns side, source_category, compare_category; got {list(df.columns)}", + ) + df[col] = df[col].str.strip() + + unknown_sides = sorted(set(df.side.unique()) - {"cpuc", "pypsa"}) + if unknown_sides: + raise ValueError(f"{path} has unknown side values {unknown_sides}; expected 'cpuc' or 'pypsa'.") + + out: dict[str, dict[str, str]] = {} + for side, grp in df.groupby("side"): + duplicated = grp.source_category[grp.source_category.duplicated()].unique() + if len(duplicated): + raise ValueError(f"{path} maps {list(duplicated)} more than once on side '{side}'.") + out[side] = dict(zip(grp.source_category, grp.compare_category)) + + for side in ("cpuc", "pypsa"): + out.setdefault(side, {}) + return out + + +def load_benchmark_regions(path: str) -> tuple[pd.Series, pd.Series]: + """ + Return ``(servm_region -> benchmark_region, eia_ba_code -> benchmark_region)``. + + Both lookups come from the same file so the CAISO collapse (PGE/SCE/SDGE and + EIA ``CISO`` landing on one ``CAISO`` row) is stated once. + """ + df = pd.read_csv(path, dtype=str) + for col in ("servm_region", "eia_ba_code", "benchmark_region"): + if col not in df.columns: + raise ValueError( + f"{path} must have columns servm_region, eia_ba_code, benchmark_region; got {list(df.columns)}", + ) + df[col] = df[col].str.strip() + + servm = df.drop_duplicates("servm_region").set_index("servm_region").benchmark_region + dupes = servm.index[servm.index.duplicated()].unique() + if len(dupes): + raise ValueError(f"{path} maps SERVM regions {list(dupes)} to more than one benchmark region.") + + ba = df.drop_duplicates("eia_ba_code") + conflicting = ba.groupby("eia_ba_code").benchmark_region.nunique() + conflicting = conflicting[conflicting > 1] + if len(conflicting): + raise ValueError(f"{path} maps EIA BA codes {list(conflicting.index)} to more than one benchmark region.") + + return servm, ba.set_index("eia_ba_code").benchmark_region + + +# --------------------------------------------------------------------------- # +# shared filtering / mapping +# --------------------------------------------------------------------------- # + + +def filter_active( + df: pd.DataFrame, + horizon: int, + insv_col: str, + retire_col: str, +) -> pd.DataFrame: + """ + Units in service by the end of ``horizon`` and not yet retired. + + A unit counts if it enters service on or before December 31 of the horizon + year and its retirement year is strictly greater than the horizon — the same + inequality ``add_electricity.load_powerplants`` applies to the model fleet, + so the two sides of the benchmark are filtered identically. + + A missing retirement date means "never retires" (the workbook leaves the + field empty for units with no announced retirement; it also uses a + ``2050-12-01`` placeholder for many, which needs no special handling since a + 2050 retirement genuinely keeps the unit alive through any earlier horizon). + A missing in-service date is treated as already in service: the workbook is a + list of the *existing* baseline fleet, so an absent date is a data gap rather + than a future project. + """ + insv = pd.to_datetime(df[insv_col], errors="coerce") + retire = pd.to_datetime(df[retire_col], errors="coerce").fillna(NEVER_RETIRES) + + n_missing_insv = int(insv.isna().sum()) + if n_missing_insv: + logger.warning( + "%d of %d units have no in-service date; treating them as already in service.", + n_missing_insv, + len(df), + ) + + in_service = insv.isna() | (insv <= pd.Timestamp(year=horizon, month=12, day=31)) + not_retired = retire.dt.year > horizon + return df[in_service & not_retired] + + +def map_compare_category(values: pd.Series, mapping: dict[str, str]) -> pd.Series: + """ + Map source technology labels onto comparison categories. + + Anything absent from ``mapping`` becomes ``UNMAPPED: