diff --git a/docs/source/configtables/run.csv b/docs/source/configtables/run.csv index 0cce5f09..4986acc8 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 809f06e6..35c8c0a5 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 00000000..c273e14a --- /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 00000000..5f8bd6f8 --- /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 99fcc23d..30de5ef2 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 a6b968bb..98518516 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 00000000..779711db --- /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: