diff --git a/docs/source/config-configuration.md b/docs/source/config-configuration.md index f6f6b38d6..411912f00 100644 --- a/docs/source/config-configuration.md +++ b/docs/source/config-configuration.md @@ -50,8 +50,13 @@ network is aggregated to. `transmission_network` chooses between the ReEDS zonal TAMU synthetic nodal network; `topological_boundaries` sets the zone type used after clustering (county, REeDS zone, state, or balancing area). Use `include` to subset the modeled footprint to specific zones, states, or balancing authorities (mixed zone types are not supported), and -`aggregate` to pre-aggregate buses into larger regions. `interface_transmission_limits` applies -NARIS2024 inter-regional transfer capacity limits and requires the ReEDS backbone. +`aggregate` to pre-aggregate buses into larger regions. `interface_transmission_limits` switches +on the aggregate inter-regional transfer limits read from +`electricity: transmission_interface_limits` and requires the ReEDS backbone; the limits are +applied in `solve_network` as a per-snapshot cap on the total flow across each interface, and +constrain only the import/export links, so they are inert unless `electricity: imports` or +`electricity: exports` is enabled. See {ref}`spatial` for a worked California example +(`workflow/repo_data/config/config.california.yaml`) at both REeDS-zone and county resolution. ```{eval-rst} .. literalinclude:: ../../workflow/repo_data/config/config.default.yaml diff --git a/docs/source/config-spatial.md b/docs/source/config-spatial.md index 04fe7f71b..f94deb9bf 100644 --- a/docs/source/config-spatial.md +++ b/docs/source/config-spatial.md @@ -35,6 +35,49 @@ model_topology: Alternatively, you can use the code reeds_state: 'CA' option to achieve the same result by specifying the entire state. +A complete, maintained California configuration ships with the repository as +`workflow/repo_data/config/config.california.yaml`. It pairs the footprint below with CPUC +SERVM demand, the RESOLVE CAISO interface limits, and enabled imports/exports: + +```yaml +scenario: + interconnect: [western] + planning_horizons: [2030, 2035, 2040, 2045] # CPUC SERVM forecast years + clusters: [4] # p8, p9, p10, p11 + simpl: [75] + +model_topology: + transmission_network: 'reeds' + topological_boundaries: 'reeds_zone' + interface_transmission_limits: true # RESOLVE CAISO interface caps + include: + reeds_state: ['CA'] +``` + +`clusters` is pinned to 4 because the ReEDS zonal backbone cannot be clustered below the +number of zones in the footprint, and California holds exactly four. + +To run the same footprint at **county resolution**, switch `topological_boundaries` to +`county` and raise `clusters` to 58 — the number of California counties, and the number of +`p06xxx` nodes in the county NARIS interface table. `simpl: ['county']` selects the +county-FIPS fast path in `cluster_simpl`, so the resource layer is built directly on county +boundaries (a numeric `simpl` of at least 58 also works if you want more resource zones than +transmission nodes): + +```yaml +scenario: + clusters: [58] # 58 California counties + simpl: ['county'] # county-FIPS fast path + +model_topology: + topological_boundaries: 'county' +``` + +`add_extra_components` switches from the balancing-area NARIS flowgate file to the county one +(`transmission_capacity_init_AC_county_NARIS2024.csv`) automatically when +`topological_boundaries` is `county` — no other key needs changing. `config.california.yaml` +carries this same block as a commented alternative. + In addition to filtering by `reeds_zone` and `reeds_state`, you can filter by `reeds_ba`, `trans_reg`, and `nerc_reg` shown graphically below. diff --git a/docs/source/configtables/electricity.csv b/docs/source/configtables/electricity.csv index 50eb34371..9cbee3fd0 100644 --- a/docs/source/configtables/electricity.csv +++ b/docs/source/configtables/electricity.csv @@ -24,7 +24,7 @@ erm:,,,Energy Reserve Margin settings (used when ERM opt is enabled). Ensures su regional_Co2_limits,--,path,"CSV of per-region CO2 caps in tCO2/yr (``config/policy_constraints/regional_Co2_limits.csv``). Enforced when the ``REM`` keyword is present in the ``{opts}`` wildcard." technology_capacity_targets,--,path,"CSV of forced minimum/maximum capacity builds by technology and region (``config/policy_constraints/technology_capacity_targets.csv``). Enforced when the ``TCT`` keyword is present in the ``{opts}`` wildcard." portfolio_standards,--,path,"CSV of RPS/CES clean-energy fractions by region (``config/policy_constraints/portfolio_standards.csv``). Enforced when the ``RPS`` keyword is present in the ``{opts}`` wildcard (covers both RPS and CES targets)." -transmission_interface_limits,--,path,"CSV of MW limits on flows across inter-regional transmission interfaces (``config/policy_constraints/transmission_interface_limits.csv``), paired with ``model_topology: interface_transmission_limits``. Reserved setting — not currently consumed by the workflow." +transmission_interface_limits,--,path,"CSV of MW limits on flows across inter-regional transmission interfaces (``config/policy_constraints/transmission_interface_limits.csv``, columns ``interface, region_1, region_2, flow_12, flow_21``). Applied by ``solve_network`` when ``model_topology: interface_transmission_limits`` is ``true``, as a per-snapshot cap on the **aggregate** flow across each interface rather than a path-by-path limit: ``flow_12`` caps exports out of ``region_1`` into ``region_2``, ``flow_21`` caps imports in the opposite direction. Only the import/export ``Link`` components added by ``add_extra_components`` are constrained, so the caps are inert when ``imports``/``exports`` are disabled." ,,, co2limit_enable,bool,true or false,"Switch to activate the system-wide CO2 cap below. Optional; defaults to false when unset. Can also be set via the ``Co2L`` keyword in the ``{opts}`` wildcard." co2limit,:math:`t_{CO_2}/a`,float,"System-wide cap on annual CO2 emissions, added as a global constraint in ``prepare_network``. Only applied when ``co2limit_enable`` is true." @@ -33,7 +33,7 @@ gaslimit,MWh thermal,float,"Cap on annual gas-fired primary energy from gas carr ,,, demand:,,, -- bus_allocation,--,"One of {``population``, ``breakthrough``}","How zone-level demand is distributed to individual buses. ``population`` (default) weights buses by 2020 Decennial Census county populations (split evenly across each county's substations, then each substation's buses). ``breakthrough`` uses the legacy nominal-demand column (``Pd``) from the 2016-vintage Breakthrough Energy grid model." --- profile,--,"One of {``efs``, ``eia``, ``eer``, ``servm``}","Datasource for electrical load data. ``EFS`` pulls future state level electrical demand data. ``EIA`` pulls historical balancing level electrical demand data. ``EER`` pulls future state-level profiles from the EER dataset; when selected, ``planning_horizons`` must be one of 2021, 2025, 2030, 2035, 2040, 2045, or 2050 and ``renewable_weather_years`` must contain exactly one year from 2007-2013 or 2016-2023. ``SERVM`` pulls CPUC SERVM hourly load for the six California load regions (California models only); when selected, ``planning_horizons`` must be one of 2026, 2028, 2030, 2032, 2035, 2037, 2040, 2042, or 2045." +-- profile,--,"One of {``efs``, ``eia``, ``eer``, ``servm``}","Datasource for electrical load data. ``EFS`` pulls future state level electrical demand data. ``EIA`` pulls historical balancing level electrical demand data. ``EER`` pulls future state-level profiles from the EER dataset; when selected, ``planning_horizons`` must be one of 2021, 2025, 2030, 2035, 2040, 2045, or 2050 and ``renewable_weather_years`` must contain exactly one year from 2007-2013 or 2016-2023. ``SERVM`` pulls CPUC SERVM hourly load for the six California load regions (California models only); when selected, ``planning_horizons`` must be one of 2026, 2028, 2030, 2032, 2035, 2037, 2040, 2042, or 2045, and each horizon must carry a full 8760-hour snapshot year. Only the ``Net Load`` component is dispatched against; the full component split is written to ``power_zonal_components_s{simpl}.parquet``. See the SERVM section of the demand data page." -- scenario:,,, -- -- efs_case,--,"One of {``reference``, ``medium``, ``high``}",(UNDER DEVELOPMENT) Extracts EFS data according to level of adoption -- -- efs_speed,--,"One of {``slow``, ``moderate``, ``fast``}",(UNDER DEVELOPMENT) Extracts EFS data according to speed of electrification diff --git a/docs/source/data-demand.md b/docs/source/data-demand.md index 89be90f81..36dc28fe5 100644 --- a/docs/source/data-demand.md +++ b/docs/source/data-demand.md @@ -21,6 +21,98 @@ the model years 2021, 2025, 2030, 2035, 2040, 2045, and 2050, and each profile i historical weather year, so `renewable_weather_years` must contain exactly one year from 2007-2013 or 2016-2023. +(servm-demand)= +### CPUC SERVM (California) + +`profile: servm` uses the hourly load forecast the California Public Utilities Commission +publishes for its 2026 Integrated Resource Planning cycle, produced with the SERVM +production-cost model. It is a **California-only** dataset — use it with a footprint scoped +to California (`model_topology: include: reeds_state: ['CA']`); the maintained entry point is +`workflow/repo_data/config/config.california.yaml`. + +The workflow retrieves one CSV per forecast year from + +``` +https://files.cpuc.ca.gov/energy/modeling/2026_servm_updates/HourlyLoad_CA_Regions_V2025E_2224_Mon_{year}.csv +``` + +(~118 MB each, via `retrieve_cpuc_servm_load`). **Nine forecast years are published: 2026, +2028, 2030, 2032, 2035, 2037, 2040, 2042, and 2045.** Unlike EFS, SERVM demand is *not* +interpolated or AEO-scaled between published years, so `scenario: planning_horizons` must be +drawn from that set — any other year raises in `ReadServm`. + +#### Regions + +SERVM reports six California load regions. Each maps onto the balancing areas PyPSA-USA +carries on its buses (`workflow/repo_data/CPUC/servm_region_map.csv`): + +| SERVM region | PyPSA-USA balancing area(s) | Notes | +| --- | --- | --- | +| `PGE` | `CISO-PGAE` | PG&E CAISO footprint | +| `SCE` | `CISO-SCE` | Includes Valley Electric Association's California load, per the CPUC data dictionary | +| `SDGE` | `CISO-SDGE` | San Diego Gas & Electric | +| `IID` | `IID` | Imperial Irrigation District | +| `LADWP` | `LDWP` | Los Angeles Department of Water and Power | +| `NCNC` | `BANC` + `TIDC` | Northern California non-CAISO: Balancing Authority of Northern California and Turlock Irrigation District | + +`CISO-VEA` — the Valley Electric Association balancing area — is a Nevada footprint and is +deliberately **excluded** from California-only networks. It carries an empty region in the +mapping file, so its (small) load share is dropped with a log message, while any *unknown* +balancing area introduced by upstream relabeling still hard-fails. + +Because the SERVM regions are balancing areas rather than states, the demand is disaggregated +with a purpose-built weights table (`build_servm_load_weights`) instead of the generic +state/BA path: a cluster bus can straddle two SERVM regions (Los Angeles County holds both +`LDWP` and `CISO-SCE` buses), so a bus receives the sum of its share of every region it +overlaps. The underlying per-bus weights are still the `bus_allocation` weights described +below. + +#### Components + +Each file publishes several load components per region (`Load`, `BTMPV`, `EV`, `DATA_CEN`, ...) +alongside `Net Load`. **Only `Net Load` is dispatched against by the model** — it is what +remains after behind-the-meter PV and other embedded resources. Every published component is +nonetheless carried through on the `subsector` index level and written to the zonal artifact +`resources//demand/{interconnect}/power_zonal_components_s{simpl}.parquet`, so the +component split stays available for reporting. Components that exist for only some regions +(`EV` and friends are published for PGE/SCE/SDGE only) align to `NaN` there. + +#### Weather years + +Each forecast-year file stacks 25 weather years (2000-2024) of a full hourly year. +`electricity: demand: scenario: servm_weather_years:` selects which one to use. It takes a +list with **exactly one** entry; multiple entries are reserved for stochastic scenarios +(phase 3) and currently raise `NotImplementedError`, because the demand output path is not +weather-year specific. + +**Set `servm_weather_years` equal to the top-level `renewable_weather_years`.** Drawing load +and wind/solar profiles from different weather years decorrelates them and will understate +both the peak-net-load and the flexibility need. A mismatch is permitted but logs a warning. + +#### Timezone and calendar caveats + +SERVM strips are in **fixed Pacific Standard Time (UTC−8) with no daylight-saving +transition**. This is not stated in the source files; it was verified empirically from the +behind-the-meter PV solar-noon centroid, which sits at hour 12.52 in December and 12.68 in +July — a DST-observing series would move by a full hour between the two. The strips are +rolled forward 8 hours to UTC before being attached. + +Two calendar misalignments are accepted, and are immaterial for an hourly +capacity-expansion model, but matter if you compare hour-for-hour against another source: + +1. **Monday-start synthetic calendar.** SERVM lays each year's 8760 hours on a synthetic + calendar that starts on a Monday, so weekday-versus-weekend hours do not line up with the + real weekdays of the planning horizon. +2. **Leap weather years.** For a leap *weather* year the SERVM strip contains February 29 and + omits December 31, while PyPSA-USA's snapshots do the opposite (`get_snapshots` drops + February 29 from leap planning horizons). Every hour after February therefore lands one + calendar day earlier than it sat in the source file. + +The strip is mapped **positionally** onto the network's own per-period snapshots rather than +onto a synthesised `date_range` — the latter would run a day short of December 31 for the +leap planning horizons (2028, 2032, 2040). As a consequence each planning horizon must carry +exactly 8760 snapshots; a truncated snapshot window cannot be used with `profile: servm`. + ## Demand Disaggregation All of the demand sources above arrive at a coarser resolution than the network: EIA930 @@ -68,8 +160,9 @@ horizons setting, and the electricity demand setting. If conducting historical s user must select a planning horizon in the past (2018-2023) and set `profile: eia`. If conducting forward-looking planning cases the user must set a future planning horizon — -2030, 2040, or 2050 with `profile: efs`, or any of 2021, 2025, 2030, 2035, 2040, 2045, and -2050 with `profile: eer`. +2030, 2040, or 2050 with `profile: efs`; any of 2021, 2025, 2030, 2035, 2040, 2045, and +2050 with `profile: eer`; or any of 2026, 2028, 2030, 2032, 2035, 2037, 2040, 2042, and 2045 +with `profile: servm` (California only). For planning horizons between the EFS data years, PyPSA-USA implements a scaling factor that interpolates between future years or scales historical demand using forecasts from the Annual @@ -81,11 +174,12 @@ scenario: electricity: demand: - profile: efs # efs, eia, eer + profile: efs # efs, eia, eer, servm scenario: efs_case: reference # reference, medium, high efs_speed: moderate # slow, moderate, rapid eer_file: demand_EER2025_100by2050.h5 # used when profile: eer + servm_weather_years: [2019] # used when profile: servm; exactly one year, 2000-2024 aeo: reference ``` diff --git a/docs/source/data-transmission.md b/docs/source/data-transmission.md index 4930c6df0..63761b9fb 100644 --- a/docs/source/data-transmission.md +++ b/docs/source/data-transmission.md @@ -38,6 +38,37 @@ While representative of the US electricity system, the TAMU network is synthetic See the [Spatial Configuration](./config-spatial.md) page for information on how to choose between networks. ``` +## Interface Transmission Limits + +The path-by-path ratings above are complemented by **interface** limits: aggregate MW caps on +the total simultaneous flow across a bundle of paths. PyPSA-USA ships the CPUC RESOLVE +interface table at `config/policy_constraints/transmission_interface_limits.csv`, which rates +the CAISO import/export capability against the rest of WECC: + +| Interface | `region_1` (inside) | `region_2` (outside) | `flow_12` (MW) | `flow_21` (MW) | +| --- | --- | --- | --- | --- | +| `CA_NW` | p9, p10, p11 | p2, p5, p6, p7, p8 | 3,592 | 9,269 | +| `CA_SW` | p9, p10, p11 | p12, p13, p25, p27, p28, p30 | 10,901 | 10,463 | +| `CAISO_Imports` | p9, p10, p11 | all of the above | 9,728 | 10,208 | + +**Flow orientation:** `flow_12` is the cap on flow *out of* `region_1` (exports), `flow_21` the +cap on flow *into* `region_1` (imports). Enable the table with +`model_topology: interface_transmission_limits: true`; the constraint formulation is described +in [Model Constraints](./model-constraints.md#interface-transmission-limits). + +```{warning} +The interface caps are applied to the virtual `imports` / `exports` links created by +`add_extra_components`, so they bind only when `electricity: imports` / `electricity: exports` +are enabled, and they only see flow that crosses the boundary of the modeled footprint. + +`p8` (northeastern California) appears in the `region_2` list of every RESOLVE row but is +itself a California zone. In a California-only model it is therefore *inside* the network, and +the internal `p8`-`p9` AC corridor — about 300 MW in the ReEDS/NARIS balancing-area table — +carries no trade links and escapes the `CAISO_Imports` cap. Simultaneous CAISO imports are +understated by roughly that amount. This gap is documented rather than corrected: closing it +would require constraining internal AC lines alongside the trade links. +``` + (transmission-data)= ### Data ```{eval-rst} diff --git a/docs/source/datatables/demand.csv b/docs/source/datatables/demand.csv index 18b33f814..ef55c509c 100644 --- a/docs/source/datatables/demand.csv +++ b/docs/source/datatables/demand.csv @@ -1,3 +1,4 @@ Characteristic,Data Source,Spatial Scale,Temporal Scale Historical Demand,GridEmissions (EIA930),Balancing Area,Hourly (2018- 2023) Future Demand,NREL Electrification Futures Study (EFS),States,"Hourly (2030, 2040, 2050)" +Future Demand (California),CPUC SERVM 2026 IRP Hourly Load,Six California load regions (PGE/SCE/SDGE/IID/LADWP/NCNC),"Hourly (2026, 2028, 2030, 2032, 2035, 2037, 2040, 2042, 2045; weather years 2000-2024)" diff --git a/docs/source/model-constraints.md b/docs/source/model-constraints.md index ad510003f..42443b67d 100644 --- a/docs/source/model-constraints.md +++ b/docs/source/model-constraints.md @@ -52,6 +52,7 @@ Regions used by the policy constraints may be specified as state codes, ReEDS zo | [Bidirectional link coupling](#bidirectional-link-coupling) | Equal capacity expansion of paired forward/reverse links | always active | [bidirectional_link.py](https://github.com/PyPSA/pypsa-usa/blob/master/workflow/scripts/opts/bidirectional_link.py) | | [Demand-response capacity](#demand-response-capacity) | Shifted load bounded by a fixed share of nominal load per bus and snapshot | `electricity: demand_response: shift` | [sector.py](https://github.com/PyPSA/pypsa-usa/blob/master/workflow/scripts/opts/sector.py) | | [Import/export volume limits](#import-and-export-volume-limits) | Traded energy bounded by a share of demand per balancing period | `electricity: imports/exports: volume_limit` | [interchange.py](https://github.com/PyPSA/pypsa-usa/blob/master/workflow/scripts/opts/interchange.py) | +| [Interface transmission limits](#interface-transmission-limits) | Aggregate MW cap on the total flow across a bundle of transmission paths | `model_topology: interface_transmission_limits`; `electricity: transmission_interface_limits` | [interfaces.py](https://github.com/PyPSA/pypsa-usa/blob/master/workflow/scripts/opts/interfaces.py) | | [National emission cap](#national-emission-cap-co2l) | System-wide CO2 cap via PyPSA `GlobalConstraint` | `Co2L` opts token; `electricity: co2limit` | [prepare_network.py](https://github.com/PyPSA/pypsa-usa/blob/master/workflow/scripts/prepare_network.py) | | [Natural gas limit](#natural-gas-limit-ch4l) | Cap on annual gas-fired primary energy | `CH4L` opts token; `electricity: gaslimit` | [prepare_network.py](https://github.com/PyPSA/pypsa-usa/blob/master/workflow/scripts/prepare_network.py) | | [Emission pricing](#emission-pricing-ep) | CO2 price added to marginal costs (objective, not a constraint) | `Ep` opts token; `costs: emission_prices` | [prepare_network.py](https://github.com/PyPSA/pypsa-usa/blob/master/workflow/scripts/prepare_network.py) | @@ -307,6 +308,41 @@ percent, and {math}`d_t` total AC load in period {math}`\tau`. In sector studies measured as the flow into the end-use sectors and the bound becomes a linear constraint in both trade and demand variables. +(interface-transmission-limits)= +## Interface transmission limits + +A transmission *interface* is a bundle of paths between two groups of regions that is rated in +aggregate rather than path-by-path — CAISO's simultaneous import capability being the canonical +example. Setting `model_topology: interface_transmission_limits: true` reads the interface table +at `electricity: transmission_interface_limits` (columns +`interface, region_1, region_2, flow_12, flow_21`) and adds one per-snapshot constraint per +interface and direction: + +**Trigger:** `model_topology: interface_transmission_limits: true`. + +\begin{align*} + &\ \hspace{1cm} \sum_{\ell \in I^{\rightarrow}} p_{\ell,t} \;\leq\; F_{12} + \hspace{0.5cm} \forall_t + \hspace{1cm} + \sum_{\ell \in I^{\leftarrow}} p_{\ell,t} \;\leq\; F_{21} + \hspace{0.5cm} \forall_t +\end{align*} + +where {math}`F_{12}` (`flow_12`) caps flow **out of** `region_1` into `region_2` and +{math}`F_{21}` (`flow_21`) caps flow in the opposite direction. {math}`I^{\rightarrow}` and +{math}`I^{\leftarrow}` are selected by bus membership and carrier, never by link name. + +```{important} +Only the `imports` / `exports` links added by `add_extra_components` are constrained, so the +limits are a **no-op when `electricity: imports` and `electricity: exports` are both disabled**. +A `region_2` entry that is itself inside the modeled footprint contributes no trade links, so +internal AC lines between it and `region_1` escape the cap. In the shipped +`CAISO_Imports` row this applies to `p8`, which is a California zone: in a California-only run +the internal `p8`-`p9` corridor (~300 MW in the ReEDS/NARIS balancing-area table) is not +counted against the CAISO import cap, understating simultaneous imports by roughly that +corridor's rating. This is documented, not corrected. +``` + (national-emission-cap-co2l)= ## National emission cap (Co2L) diff --git a/docs/source/release-notes.md b/docs/source/release-notes.md index 4e2715a0e..14dfe52bd 100644 --- a/docs/source/release-notes.md +++ b/docs/source/release-notes.md @@ -49,6 +49,36 @@ The full engineering change-log, including per-change expected effects on model results, is maintained in the repository at [`docs/CHANGELOG-v1-epic.md`](https://github.com/PyPSA/pypsa-usa/blob/master/docs/CHANGELOG-v1-epic.md). +### California / CPUC SERVM + +- **New demand source `electricity: demand: profile: servm`** — CPUC SERVM 2026 IRP hourly + load for the six California load regions (PGE, SCE, SDGE, IID, LADWP, NCNC), retrieved + per forecast year from files.cpuc.ca.gov. Nine forecast years are published (2026, 2028, + 2030, 2032, 2035, 2037, 2040, 2042, 2045) and `planning_horizons` is restricted to them. + `electricity: demand: scenario: servm_weather_years` picks one weather year out of the + stacked 2000-2024 record. Only `Net Load` is dispatched; the full component split is + written to a new component-resolved zonal artifact + (`power_zonal_components_s{simpl}.parquet`), which is now produced for every demand + profile. See {ref}`servm-demand`. +- **SERVM load-allocation weights** — a new `build_servm_load_weights` rule composes the + base→substation→cluster busmaps into a fractional `(SERVM region, bus)` table, so a cluster + that straddles two regions (Los Angeles County holds both LDWP and CISO-SCE buses) receives + the sum of its share of each. +- **Interface transmission limits are live.** `model_topology: interface_transmission_limits` + and `electricity: transmission_interface_limits` were previously dead keys. They now apply + the RESOLVE interface table as a per-snapshot cap on the *aggregate* flow across each + interface. The constraint scopes to the import/export links, so it is inert when trade is + disabled; the resulting understatement for `region_2` entries inside the footprint (notably + `p8` in California-only runs) is documented in {doc}`data-transmission`. +- **New maintained config `config.california.yaml`** — a runnable California-only model on + SERVM demand with the CAISO interface caps and imports/exports enabled, at REeDS-zone + resolution (`clusters: 4`) with a commented county-resolution alternative + (`clusters: 58`, `simpl: county`). +- **Phase-2 hook `conventional: ambient_derate`** — reserved for CPUC SERVM unit-specific + ambient-temperature derates. It is not implemented; enabling it raises `NotImplementedError` + in `add_electricity`. When it lands it replaces the EIA-860 seasonal derate rather than + stacking on it. + ### Documentation - New Model Description section ({doc}`model-workflow`, {doc}`model-components`, diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a2500105a..ec9c4429c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -119,6 +119,112 @@ def busmap_s(self) -> Path: return self.base / "busmaps" / self.interconnect / f"busmap_s{self.simpl}.csv" +@dataclass(frozen=True) +class ServmArtifacts: + """Paths to the CPUC SERVM demand artifacts produced by the Tier B build. + + The ``interconnect`` and ``simpl`` defaults MUST match + ``workflow/repo_data/config/config.test.california.yaml``'s ``scenario`` + section. If you change one, change both. + """ + + run_name: str + base: Path # resources/{run_name}/ + interconnect: str = "western" + simpl: str = "20" + + @property + def elec_s(self) -> Path: + """Simplified network the weights and demand were built against.""" + return self.base / "networks" / self.interconnect / f"elec_s{self.simpl}.nc" + + @property + def elec_s_dem(self) -> Path: + """Simplified network with SERVM demand attached.""" + return self.base / "networks" / self.interconnect / f"elec_s{self.simpl}_dem.nc" + + @property + def weights(self) -> Path: + """(bus, servm_region, laf) allocation table from build_servm_load_weights.""" + return self.base / "demand" / self.interconnect / f"servm_load_weights_s{self.simpl}.csv" + + @property + def demand(self) -> Path: + """Per-bus hourly demand CSV written by build_electrical_demand.""" + return self.base / "demand" / self.interconnect / f"power_electricity_s{self.simpl}.csv" + + @property + def zonal_components(self) -> Path: + """Component-resolved zonal demand parquet (pre-disaggregation).""" + return self.base / "demand" / self.interconnect / f"power_zonal_components_s{self.simpl}.parquet" + + +def _run_snakemake(configfile: str, until: str, run_name: str) -> None: + """Run one ``snakemake --until `` build against ``workflow/``.""" + cmd = [ + "snakemake", + "--until", + until, + "--configfile", + configfile, + "--config", + f"run={{name: '{run_name}', shared_cutouts: true}}", + "-j", + str(os.cpu_count() or 2), + # Force greedy scheduler to avoid the ILP scheduler's cbc dependency + # (cbc is shipped non-executable in some envs and causes PermissionError). + "--scheduler", + "greedy", + "--quiet", + ] + try: + result = subprocess.run( + cmd, + cwd=WORKFLOW_DIR, + capture_output=True, + text=True, + timeout=600, + ) + except subprocess.TimeoutExpired as e: + raw_stderr = e.stderr + if isinstance(raw_stderr, bytes): + raw_stderr = raw_stderr.decode("utf-8", errors="replace") + stderr_tail = "\n".join(raw_stderr.splitlines()[-100:]) if raw_stderr else "" + pytest.fail( + f"snakemake build timed out after {e.timeout}s\nstderr (last 100 lines):\n{stderr_tail}", + ) + if result.returncode != 0: + pytest.fail( + f"snakemake build failed (exit {result.returncode}):\n" + f"stderr (last 100 lines):\n" + "\n".join(result.stderr.splitlines()[-100:]), + ) + + +@pytest.fixture(scope="session") +def servm_built(tmp_path_factory) -> ServmArtifacts: + """Run ``snakemake --until add_demand`` on the SERVM test config once per session. + + Stops at ``add_demand`` rather than ``cluster_network``: the SERVM + artifacts under test (weights, per-bus demand CSV, zonal components) are all + produced at or before that stage, and the extra clustering work is already + covered by the ``built`` fixture. + + Skips like ``built`` when the data dirs are missing. This build also + downloads one ~118 MB CPUC SERVM load file the first time it runs. + """ + missing = [d for d in DATA_DIRS if not d.exists()] + if missing: + pytest.skip( + "Integration tests require populated data dirs; missing: " + ", ".join(str(m) for m in missing), + ) + run_name = f"pytest_servm_{tmp_path_factory.mktemp('servm_run').name}" + _run_snakemake("config/config.test.california.yaml", "add_demand", run_name) + return ServmArtifacts( + run_name=run_name, + base=WORKFLOW_DIR / "resources" / run_name, + ) + + @pytest.fixture(scope="session") def built(tmp_path_factory) -> BuiltArtifacts: """Run ``snakemake --until cluster_network`` once per session and expose artifact paths. diff --git a/tests/integration/test_servm_demand_artifacts.py b/tests/integration/test_servm_demand_artifacts.py new file mode 100644 index 000000000..73d45138f --- /dev/null +++ b/tests/integration/test_servm_demand_artifacts.py @@ -0,0 +1,146 @@ +"""Tier B — assert shape and orientation of the CPUC SERVM demand artifacts. + +Built from ``workflow/repo_data/config/config.test.california.yaml`` by the +``servm_built`` fixture (``snakemake --until add_demand``). These are structural +checks — conservation of the allocation weights, alignment of the per-bus demand +CSV with the network it was built against, and the PST->UTC roll. No numerical +regression against the CPUC reference case here; that is the (separate) +benchmark rule ``run.benchmark_cpuc`` reserves. +""" + +from __future__ import annotations + +import pandas as pd +import pypsa +import pytest + +pytestmark = pytest.mark.integration + +# The six CPUC SERVM California load regions (ReadServm.REGIONS). +SERVM_REGIONS = {"IID", "LADWP", "NCNC", "PGE", "SCE", "SDGE"} + + +class TestServmLoadWeights: + """Assertions on servm_load_weights_s{simpl}.csv (build_servm_load_weights).""" + + def test_file_exists(self, servm_built): + """The weights table was produced by snakemake.""" + assert servm_built.weights.exists(), f"missing {servm_built.weights}" + + def test_columns(self, servm_built): + """The table carries the three columns WriteServm reads.""" + weights = pd.read_csv(servm_built.weights) + assert {"bus", "servm_region", "laf"}.issubset(weights.columns) + + def test_laf_sums_to_one_per_region(self, servm_built): + """Allocation factors conserve each region's demand exactly.""" + weights = pd.read_csv(servm_built.weights) + totals = weights.groupby("servm_region")["laf"].sum() + assert not totals.empty, "no SERVM regions in the weights table" + pd.testing.assert_series_equal( + totals, + pd.Series(1.0, index=totals.index, name="laf"), + check_exact=False, + rtol=1e-6, + ) + + def test_regions_are_known(self, servm_built): + """Only the six published SERVM regions appear (CISO-VEA is excluded).""" + weights = pd.read_csv(servm_built.weights) + unknown = set(weights.servm_region.unique()) - SERVM_REGIONS + assert not unknown, f"unexpected SERVM region(s) {sorted(unknown)}" + + def test_buses_exist_in_network(self, servm_built): + """Every weighted bus is a bus of the network the weights were built for.""" + weights = pd.read_csv(servm_built.weights) + n = pypsa.Network(str(servm_built.elec_s)) + unknown = set(weights.bus.astype(str)) - set(n.buses.index.astype(str)) + assert not unknown, f"weights reference {len(unknown)} bus(es) not in elec_s{servm_built.simpl}.nc" + + +class TestServmDemand: + """Assertions on power_electricity_s{simpl}.csv (build_electrical_demand).""" + + def test_file_exists(self, servm_built): + """The per-bus demand CSV was produced by snakemake.""" + assert servm_built.demand.exists(), f"missing {servm_built.demand}" + + def test_columns_are_network_buses(self, servm_built): + """Demand columns are buses of the network, and there is at least one.""" + demand = pd.read_csv(servm_built.demand, index_col=0) + n = pypsa.Network(str(servm_built.elec_s)) + assert len(demand.columns) > 0 + unknown = set(demand.columns.astype(str)) - set(n.buses.index.astype(str)) + assert not unknown, f"demand columns are not network buses: {sorted(unknown)[:10]}" + + def test_row_count_matches_snapshots(self, servm_built): + """One row per network snapshot.""" + demand = pd.read_csv(servm_built.demand, index_col=0) + n = pypsa.Network(str(servm_built.elec_s)) + assert len(demand) == len(n.snapshots), ( + f"demand has {len(demand)} rows, network has {len(n.snapshots)} snapshots" + ) + + def test_demand_is_positive(self, servm_built): + """No NaNs, no negative load, and a non-trivial total.""" + demand = pd.read_csv(servm_built.demand, index_col=0) + assert not demand.isna().any().any() + assert (demand.to_numpy() >= 0).all(), "SERVM Net Load produced negative demand" + assert demand.to_numpy().sum() > 0 + + def test_attached_to_network(self, servm_built): + """add_demand attached the SERVM load to elec_s{simpl}_dem.nc.""" + n = pypsa.Network(str(servm_built.elec_s_dem)) + assert len(n.loads) > 0 + assert not n.loads_t.p_set.isna().any().any() + assert n.loads_t.p_set.to_numpy().sum() > 0 + + def test_peak_hour_consistent_with_pst(self, servm_built): + """The annual peak lands in the CA afternoon/evening once rolled to UTC. + + ``ReadServm`` rolls the fixed-PST strip forward by 8 hours, so the + snapshot index is effectively UTC. A California system peak sits in the + late afternoon local time; 15:00-20:00 PST maps to UTC hours 23-04. The + band is deliberately wide — it is testing the direction and magnitude of + the roll, not the exact peak hour. (Peak *date* is not asserted: SERVM + lays its hours on a synthetic Monday-start calendar, and a leap weather + year shifts every post-February hour by one calendar day.) + """ + demand = pd.read_csv(servm_built.demand, index_col=0, parse_dates=True) + peak_hour = demand.sum(axis=1).idxmax().hour + assert peak_hour in { + 23, + 0, + 1, + 2, + 3, + 4, + }, f"annual peak at UTC hour {peak_hour}; expected 23-04 for a PST-rolled California profile" + + +class TestServmZonalComponents: + """Assertions on power_zonal_components_s{simpl}.parquet.""" + + def test_file_exists(self, servm_built): + """The component-resolved zonal artifact was produced.""" + assert servm_built.zonal_components.exists(), f"missing {servm_built.zonal_components}" + + def test_regions_are_columns(self, servm_built): + """Columns are the SERVM regions; every region carries nonzero energy.""" + zonal = pd.read_parquet(servm_built.zonal_components) + assert set(zonal.columns) <= SERVM_REGIONS, f"unexpected column(s) {sorted(set(zonal.columns) - SERVM_REGIONS)}" + net_load = zonal.xs("Net Load", level="subsector") + assert (net_load.sum() > 0).all(), ( + f"zero annual energy in region(s) {sorted(net_load.columns[net_load.sum() <= 0])}" + ) + + def test_net_load_component_present(self, servm_built): + """``Net Load`` — the only component the model dispatches against — is kept.""" + zonal = pd.read_parquet(servm_built.zonal_components) + assert "Net Load" in zonal.index.get_level_values("subsector") + + def test_components_beyond_net_load_are_kept(self, servm_built): + """The zonal artifact stays component-resolved (BTMPV, EV, ... survive).""" + zonal = pd.read_parquet(servm_built.zonal_components) + components = set(zonal.index.get_level_values("subsector")) + assert components - {"Net Load"}, "zonal artifact collapsed to Net Load only" diff --git a/tests/static/test_dag_dryrun.py b/tests/static/test_dag_dryrun.py index 26310624f..964595809 100644 --- a/tests/static/test_dag_dryrun.py +++ b/tests/static/test_dag_dryrun.py @@ -44,33 +44,55 @@ def _seed_runtime_configs(): shutil.copy2(src, dst) +# County-resolution overrides for the California config. `config.california.yaml` +# ships this same block commented out; snakemake's ``--config`` performs a +# recursive dict update, so only the listed sub-keys are replaced. California has +# 58 counties, and ``simpl: county`` selects the county-FIPS fast path in +# ``cluster_simpl``. +CALIFORNIA_COUNTY_OVERRIDE = [ + "scenario={clusters: [58], simpl: ['county']}", + "model_topology={topological_boundaries: 'county'}", +] + + @pytest.mark.fast @pytest.mark.parametrize( - "configfile,target", + "configfile,target,overrides", [ - ("config/config.tutorial.yaml", "cluster_network"), - ("config/config.tutorial.yaml", "solve_network"), - ("config/config.default.yaml", "cluster_network"), + ("config/config.tutorial.yaml", "cluster_network", []), + ("config/config.tutorial.yaml", "solve_network", []), + ("config/config.default.yaml", "cluster_network", []), + ("config/config.california.yaml", "cluster_network", []), + ("config/config.california.yaml", "solve_network", []), + ( + "config/config.california.yaml", + "cluster_network", + CALIFORNIA_COUNTY_OVERRIDE, + ), ], + ids=lambda v: "+".join(v) if isinstance(v, list) else v, ) -def test_snakemake_dryrun_resolves(configfile, target): +def test_snakemake_dryrun_resolves(configfile, target, overrides): + cmd = [ + "snakemake", + "-n", + "--configfile", + configfile, + "--until", + target, + "--quiet", + ] + if overrides: + cmd += ["--config", *overrides] result = subprocess.run( - [ - "snakemake", - "-n", - "--configfile", - configfile, - "--until", - target, - "--quiet", - ], + cmd, cwd=WORKFLOW_DIR, capture_output=True, text=True, timeout=60, ) assert result.returncode == 0, ( - f"snakemake -n failed for {configfile} --until {target}\n" + f"snakemake -n failed for {configfile} --until {target} (overrides={overrides})\n" f"stderr:\n{result.stderr}\n" f"stdout (last 50 lines):\n" + "\n".join(result.stdout.splitlines()[-50:]) ) diff --git a/workflow/repo_data/config/config.california.yaml b/workflow/repo_data/config/config.california.yaml new file mode 100644 index 000000000..0a80ae248 --- /dev/null +++ b/workflow/repo_data/config/config.california.yaml @@ -0,0 +1,487 @@ +# ==================================================================== +# PyPSA-USA — California Configuration (CPUC SERVM demand) +# ==================================================================== +# A maintained, runnable California-only capacity-expansion model. +# +# What it is: +# * Footprint : the four California REeDS zones (p8, p9, p10, p11), +# carved out of the Western Interconnection. +# * Demand : CPUC SERVM 2026 IRP hourly load for the six California +# load regions (IID, LADWP, NCNC, PGE, SCE, SDGE), +# disaggregated onto buses with the population-derived +# SERVM allocation weights (`build_servm_load_weights`). +# * Trade : imports/exports to the rest of WECC are enabled and +# capped in aggregate by the RESOLVE CAISO interface +# limits (`model_topology.interface_transmission_limits`). +# * Horizons : 2030 / 2035 / 2040 / 2045, solved with perfect foresight. +# +# How to run it: +# cd workflow +# uv run snakemake -j1 --configfile config/config.california.yaml +# # data model only (no solve): +# uv run snakemake data_model -j1 --configfile config/config.california.yaml +# +# The `retrieve_cpuc_servm_load` rule pulls one ~118 MB CSV per planning +# horizon from files.cpuc.ca.gov the first time this config is built. +# +# Option reference: docs/source/config-configuration.md +# Demand reference: docs/source/data-demand.md (SERVM section) +# Spatial reference: docs/source/config-spatial.md +# ==================================================================== + + +# ==================================================================== +# RUN — run identity and shared-resource toggles +# ==================================================================== +run: + name: "california" # resources/california/ and results/california/ + disable_progressbar: false + shared_resources: false # isolate resources per run.name + shared_cutouts: true # cutouts are large; share them across runs + validation: false + # Marks this run as a CPUC benchmark case. The rule that consumes the flag + # (comparison of model output against the CPUC SERVM/RESOLVE reference case) + # lands in a separate PR; the key is inert until then and is safe to leave on. + benchmark_cpuc: true + + +# ==================================================================== +# RENEWABLE DATASET — capacity-factor source for wind & solar +# ==================================================================== +renewable: + dataset: godeeep # atlite | godeeep + + +# ==================================================================== +# SCENARIO — workflow wildcards and planning horizons +# ==================================================================== +# planning_horizons MUST be drawn from the nine CPUC SERVM forecast years +# {2026, 2028, 2030, 2032, 2035, 2037, 2040, 2042, 2045}; any other year +# raises in `ReadServm`. One SERVM load file is downloaded per horizon. +scenario: + interconnect: [western] # CA is carved out of the Western Interconnection + planning_horizons: [2030, 2035, 2040, 2045] # SERVM forecast years only + clusters: [4] # p8, p9, p10, p11 — the four CA REeDS zones + simpl: [75] # resource resolution before transmission clustering + ll: [v1.0] # line-limit scenario; today's transfer capacity + opts: [REM-3h] # REM = regional CO2 limits, 3h = 3-hour resolution + scope: "total" # urban | rural | total + sector: "" # electricity-only + +foresight: 'perfect' # all horizons solved in one monolithic problem + + +# ==================================================================== +# MODEL TOPOLOGY — transmission backbone and zonal aggregation +# ==================================================================== +# `clusters` is pinned to 4 because the ReEDS zonal backbone cannot be +# clustered below the number of zones in the footprint, and California +# holds exactly four (p8, p9, p10, p11). +model_topology: + transmission_network: 'reeds' # ReEDS/NARIS zonal backbone + topological_boundaries: 'reeds_zone' # county | reeds_zone | state + interface_transmission_limits: true # apply the RESOLVE CAISO interface caps + include: + reeds_state: ['CA'] # equivalent to reeds_zone: ['p8','p9','p10','p11'] + aggregate: {} + +# -------------------------------------------------------------------- +# ALTERNATIVE: county-resolution California +# -------------------------------------------------------------------- +# To run California at county resolution instead of REeDS-zone resolution, +# replace the `scenario` and `model_topology` values above with the block +# below. California has 58 counties, so `clusters` must be 58 — the county +# NARIS interface table (transmission_capacity_init_AC_county_NARIS2024.csv) +# carries exactly 58 `p06xxx` nodes. `simpl: ['county']` uses the county-FIPS +# fast path in `cluster_simpl` so the resource layer is built directly on +# county boundaries; a numeric `simpl` >= 58 also works if you want more +# resource zones than transmission nodes. +# +# `add_extra_components` swaps to the county NARIS flowgate file automatically +# when `topological_boundaries: 'county'` — no other key needs changing. +# +# scenario: +# interconnect: [western] +# planning_horizons: [2030, 2035, 2040, 2045] +# clusters: [58] # 58 California counties +# simpl: ['county'] # county-FIPS fast path in cluster_simpl +# ll: [v1.0] +# opts: [REM-3h] +# scope: "total" +# sector: "" +# +# model_topology: +# transmission_network: 'reeds' +# topological_boundaries: 'county' +# interface_transmission_limits: true +# include: +# reeds_state: ['CA'] +# aggregate: {} +# +# See docs/source/config-spatial.md for the county workflow and the +# minimum-cluster table. +# -------------------------------------------------------------------- + + +# ==================================================================== +# ENABLE — top-level feature flags +# ==================================================================== +enable: + build_cutout: false # consume the prebuilt atlite cutout + + +# ==================================================================== +# SNAPSHOTS & WEATHER YEARS — temporal scope +# ==================================================================== +# `renewable_weather_years` and `electricity.demand.scenario.servm_weather_years` +# are deliberately the same year: SERVM publishes 25 stacked weather years +# (2000-2024) per forecast year, and drawing load and renewable profiles from +# different weather years decorrelates load from wind/solar. A mismatch is +# allowed but logs a warning in `build_electrical_demand`. +renewable_weather_years: [2019] + +snapshots: + start: "2019-01-01" + end: "2020-01-01" + inclusive: "left" # a full 8760-hour year + +renewable_scenarios: ["rcp85cooler"] # GODEEEP climate scenario + +renewable_snapshots: + start_month: 1 + start_day: 1 + end_month: 12 + end_day: 31 + end_inclusive: true + + +# ==================================================================== +# ELECTRICITY — generators, storage, demand, reserves, trade +# ==================================================================== +electricity: + conventional_carriers: [nuclear, oil, OCGT, CCGT, coal, geothermal, biomass, waste] + renewable_carriers: [onwind, offwind_floating, solar, hydro] + retirement: economic + + extendable_carriers: + Generator: [solar, onwind, offwind_floating, OCGT, CCGT, CCGT-95CCS, nuclear, hydrogen_ct] + StorageUnit: [4hr_battery_storage, 8hr_battery_storage] + Store: [] + Link: [] + + # ---------------- Reserves & resource adequacy ---------------- + SAFE_reservemargin: 0.14 + SAFE_regional_reservemargins: 'config/policy_constraints/SAFE_regional_prm.csv' + + operational_reserve: + activate: false + epsilon_load: 0.02 + epsilon_vres: 0.02 + contingency: 4000 + + erm: + all: 0.15 + + # ---------------- Policy & emissions ---------------- + regional_Co2_limits: 'config/policy_constraints/regional_Co2_limits.csv' + technology_capacity_targets: 'config/policy_constraints/technology_capacity_targets.csv' + portfolio_standards: 'config/policy_constraints/portfolio_standards.csv' + # Aggregate MW caps on the CAISO import/export interfaces (RESOLVE), applied + # when model_topology.interface_transmission_limits is true. + transmission_interface_limits: 'config/policy_constraints/transmission_interface_limits.csv' + + # ---------------- Demand ---------------- + demand: + profile: servm # CPUC SERVM 2026 IRP hourly load (California only) + bus_allocation: population # 2020 Census county populations + scenario: + # Weather year drawn from the stacked 2000-2024 SERVM record. Exactly one + # entry: multiple entries are reserved for stochastic scenarios (phase 3) + # and currently raise NotImplementedError. + servm_weather_years: [2019] + aeo: reference # unused by SERVM (one file per forecast year) + + demand_response: + shift: 0 + marginal_cost: 999999 + + # ---------------- Inter-regional trade ---------------- + # California is not an island: the rest of WECC is represented as virtual + # import/export links whose aggregate flow is capped by the RESOLVE CAISO + # interfaces above. `volume_limit: 25` caps annual imported/exported energy + # at 25% of total demand, roughly CAISO's historical net-import share. + imports: + enable: true + costs: wholesale # priced at monthly wholesale hub prices + co2_emissions: 0.428 # tCO2/MWh assigned to imported energy + capacity_limit: true # cap import power at historical maxima + volume_limit: 25 # % of total demand per balancing period + balancing_period: year + + exports: + enable: true + costs: wholesale + capacity_limit: true + volume_limit: 25 + balancing_period: year + + +# ==================================================================== +# CONVENTIONAL — unit-commitment and fuel-price overrides +# ==================================================================== +conventional: + unit_commitment: false + must_run: false + dynamic_fuel_price: + enable: false + pudl: true + wholesale: true + + # ---- PHASE 2 (NOT IMPLEMENTED) ---------------------------------- + # Ambient-temperature capacity derates for thermal units, taken from the + # CPUC SERVM unit-specific hourly derate profiles. `enable: true` currently + # raises NotImplementedError in add_electricity — the CPUC derate profiles + # are not retrieved or applied yet (see the reserved + # `retrieve_cpuc_thermal_derate` rule in workflow/rules/retrieve.smk). + # + # When it lands it REPLACES the EIA-860 summer/winter seasonal derate + # (`replaces_seasonal_derate: true`) rather than stacking on top of it. + # Stacking an ambient derate on a seasonal derate, or on a UCAP-derated + # capacity credit, double-counts the same thermal deficiency and must never + # be done. + ambient_derate: + enable: false # PHASE 2 — leave false + source: cpuc_servm # CPUC SERVM unit-specific hourly derates + replaces_seasonal_derate: true # never stack with the EIA-860 seasonal derate + + +# ==================================================================== +# LINES — AC transmission lines +# ==================================================================== +lines: + s_max_pu: 0.7 + s_nom_max: .inf + max_extension: 20000 + length_factor: 1.25 + + +# ==================================================================== +# LINKS — HVDC and controllable links +# ==================================================================== +links: + p_max_pu: 1.0 + p_nom_max: .inf + max_extension: 20000 + + +# ==================================================================== +# CO2 — sequestration storage and pipeline transport +# ==================================================================== +co2: + storage: false + network: + enable: false + capital_cost: 2736000 + marginal_cost: 4 + lifetime: 40 + discount_rate: 0.07 + + +# ==================================================================== +# DAC — Direct Air Capture +# ==================================================================== +dac: + enable: false + granularity: "node" + capital_cost: 6000000 + electricity_input: 2.5 + lifetime: 20 + discount_rate: 0.07 + + +# ==================================================================== +# COSTS — capex/opex scenarios and policy incentives +# ==================================================================== +costs: + atb: + model_case: "Market" + scenario: "Moderate" + overrides: + + aeo: + scenario: "reference" + + social_discount_rate: 0.02 + ng_fuel_year: 2019 # CAISO NG price vintage year + + emission_prices: + enable: false + co2: 0. + co2_monthly_prices: false + + ptc_modifier: + onwind: 27.50 + biomass: 27.50 + + itc_modifier: + solar: 0.3 + offwind: 0.3 + offwind_floating: 0.3 + EGS: 0.3 + geothermal: 0.3 + SMR: 0.3 + nuclear: 0.3 + hydro: 0.3 + 2hr_battery_storage: 0.3 + 4hr_battery_storage: 0.3 + 6hr_battery_storage: 0.3 + 8hr_battery_storage: 0.3 + 10hr_battery_storage: 0.3 + 8hr_PHS: 0.3 + 10hr_PHS: 0.3 + 12hr_PHS: 0.3 + + min_year: + hydrogen_ct: 2040 + max_growth: + + +# ==================================================================== +# CLUSTERING — pre-cluster (simpl) and final (cluster) settings +# ==================================================================== +clustering: + simplify_network: + weighting_strategy: demand-capacity + algorithm: kmeans + cluster_network: + weighting_strategy: demand-capacity + algorithm: kmeans + exclude_carriers: [] + consider_efficiency_classes: false + + aggregation_strategies: + generators: + build_year: 'capacity_weighted_average' + lifetime: 'capacity_weighted_average' + start_up_cost: 'capacity_weighted_average' + min_up_time: 'capacity_weighted_average' + min_down_time: 'capacity_weighted_average' + ramp_limit_up: max + ramp_limit_down: max + committable: any + vom_cost: mean + fuel_cost: mean + heat_rate: mean + + temporal: + resolution_elec: false # the 3h in `opts` does the temporal reduction + resolution_sector: false + +focus_weights: + + +# ==================================================================== +# SOLVING — solver selection, options, and per-solver tunings +# ==================================================================== +solving: + options: + load_shedding: false + clip_p_max_pu: 1.e-2 + noisy_costs: true + skip_iterations: true + rolling_horizon: false + seed: 123 + track_iterations: false + min_iterations: 4 + max_iterations: 6 + transmission_losses: 2 + linearized_unit_commitment: true + horizon: 8760 + assign_all_duals: true + + solver: + name: gurobi + options: gurobi-default + + solver_options: + highs-default: + # https://ergo-code.github.io/HiGHS/options/definitions.html + threads: 4 + solver: "ipm" + run_crossover: "off" + small_matrix_value: 1e-6 + large_matrix_value: 1e9 + primal_feasibility_tolerance: 1e-5 + dual_feasibility_tolerance: 1e-5 + ipm_optimality_tolerance: 1e-4 + parallel: "on" + random_seed: 123 + gurobi-default: + threads: 8 + method: 2 + crossover: 0 + BarHomogeneous: 1 + BarConvTol: 1.e-5 + OptimalityTol: 1.e-4 + FeasibilityTol: 1.e-3 + ScaleFlag: 1 + Seed: 123 + AggFill: 0 + PreDual: 0 + GURO_PAR_BARDENSETHRESH: 200 + gurobi-numeric-focus: + name: gurobi + NumericFocus: 3 + method: 2 + crossover: 0 + BarHomogeneous: 1 + BarConvTol: 1.e-5 + FeasibilityTol: 1.e-4 + OptimalityTol: 1.e-4 + ObjScale: -0.5 + threads: 8 + Seed: 123 + gurobi-fallback: + name: gurobi + crossover: 0 + method: 2 + BarHomogeneous: 1 + BarConvTol: 1.e-5 + FeasibilityTol: 1.e-5 + OptimalityTol: 1.e-5 + Seed: 123 + threads: 8 + cplex-default: + threads: 4 + lpmethod: 4 + solutiontype: 2 + barrier.convergetol: 1.e-5 + feasopt.tolerance: 1.e-6 + cbc-default: {} + glpk-default: {} + + mem: 30000 + walltime: "12:00:00" + + +# ==================================================================== +# WALLTIME — per-rule HPC walltime overrides (only used by SLURM driver) +# ==================================================================== +walltime: + build_renewable_profiles: '04:00:00' + build_fuel_prices: '00:20:00' + add_demand: '02:00:00' + add_electricity: '04:00:00' + aggregate_to_substations: '02:00:00' + cluster_resources: '05:00:00' + cluster_network: '04:00:00' + solve_network: '20:00:00' + + +# ==================================================================== +# CUSTOM FILES — bring-your-own network or costs +# ==================================================================== +custom_files: + activate: false + files_path: '' + network_name: '' diff --git a/workflow/repo_data/config/config.default.yaml b/workflow/repo_data/config/config.default.yaml index 99fcc23d6..4af707e94 100644 --- a/workflow/repo_data/config/config.default.yaml +++ b/workflow/repo_data/config/config.default.yaml @@ -215,6 +215,20 @@ conventional: pudl: true # PUDL receipts-based fuel cost time series (monthly resolution) wholesale: true # CAISO/wholesale natural-gas hub prices (overrides PUDL for NG) + # PHASE 2 (NOT IMPLEMENTED): ambient-temperature capacity derates for thermal + # units, from the CPUC SERVM unit-specific hourly derate profiles. Setting + # `enable: true` raises NotImplementedError in add_electricity — the profiles + # are not retrieved or applied yet (see the reserved + # `retrieve_cpuc_thermal_derate` rule in workflow/rules/retrieve.smk). + # When implemented it REPLACES the EIA-860 summer/winter seasonal derate + # rather than stacking on it; stacking an ambient derate on a seasonal derate + # (or on a UCAP-derated capacity credit) double-counts the same thermal + # deficiency and must never be done. + ambient_derate: + enable: false # PHASE 2 — leave false + source: cpuc_servm # CPUC SERVM unit-specific hourly derates + replaces_seasonal_derate: true # never stack with the EIA-860 seasonal derate + # ==================================================================== # LINES — AC transmission lines diff --git a/workflow/repo_data/config/config.test.california.yaml b/workflow/repo_data/config/config.test.california.yaml new file mode 100644 index 000000000..666489310 --- /dev/null +++ b/workflow/repo_data/config/config.test.california.yaml @@ -0,0 +1,135 @@ +# PyPSA-USA SERVM test config — used by tests/integration to build the +# CPUC SERVM demand artifacts on a minimal CA-only Western slice. +# DO NOT use as a user-facing entry point; see config.california.yaml +# for the maintained California model. +# +# NOTE ON SNAPSHOTS: unlike config.test.yaml this config keeps a full +# 8760-hour year. `ReadServm._assign_snapshots` maps each SERVM hourly +# strip positionally onto the network's own per-period snapshots and +# raises unless the planning horizon carries exactly 8760 of them, so a +# truncated snapshot window cannot build SERVM demand. Runtime is kept +# down by the small `simpl`/`clusters` values instead. +# +# NOTE ON DATA: building this config downloads one ~118 MB CPUC SERVM +# load file (the 2030 forecast year) from files.cpuc.ca.gov. +run: + name: "test-california" + disable_progressbar: true + shared_resources: false + shared_cutouts: true + validation: false + +foresight: 'perfect' + +scenario: + interconnect: [western] + clusters: [4m] + simpl: [20] + opts: [REM-3h] + ll: [v1.0] + scope: "total" + sector: "" + planning_horizons: [2030] # must be a CPUC SERVM forecast year + +model_topology: + transmission_network: 'reeds' + topological_boundaries: 'reeds_zone' + interface_transmission_limits: true + include: + reeds_state: ['CA'] + aggregate: {} + +enable: + build_cutout: false + +renewable_weather_years: [2019] + +snapshots: + start: "2019-01-01" + end: "2020-01-01" + inclusive: "left" + +electricity: + conventional_carriers: [nuclear, oil, OCGT, CCGT, coal, geothermal, biomass, waste] + renewable_carriers: [onwind, solar, hydro] + retirement: economic + extendable_carriers: + Generator: [solar, onwind, OCGT, CCGT] + StorageUnit: [4hr_battery_storage] + Store: [] + Link: [] + transmission_interface_limits: 'config/policy_constraints/transmission_interface_limits.csv' + demand: + profile: servm + bus_allocation: population + scenario: + servm_weather_years: [2019] + aeo: reference + imports: + enable: true + costs: wholesale + co2_emissions: 0.428 + capacity_limit: true + volume_limit: 25 + balancing_period: year + exports: + enable: true + costs: wholesale + capacity_limit: true + volume_limit: 25 + balancing_period: year + +conventional: + unit_commitment: false + must_run: false + dynamic_fuel_price: + enable: false + pudl: true + wholesale: true + ambient_derate: + enable: false # PHASE 2 — raises NotImplementedError when true + +lines: + s_max_pu: 0.7 + s_nom_max: .inf + max_extension: 20000 + length_factor: 1.25 + +links: + p_max_pu: 1.0 + p_nom_max: .inf + max_extension: 20000 + +costs: + ng_fuel_year: 2019 + +clustering: + simplify_network: + algorithm: kmeans + cluster_network: + algorithm: kmeans + exclude_carriers: [] + consider_efficiency_classes: false + aggregation_strategies: + generators: + build_year: 'capacity_weighted_average' + lifetime: 'capacity_weighted_average' + start_up_cost: 'capacity_weighted_average' + min_up_time: 'capacity_weighted_average' + min_down_time: 'capacity_weighted_average' + ramp_limit_up: max + ramp_limit_down: max + committable: any + vom_cost: mean + fuel_cost: mean + heat_rate: mean + temporal: + resolution_elec: false + resolution_sector: false + +focus_weights: + +custom_files: + activate: false + files_path: '' + network_name: '' diff --git a/workflow/rules/retrieve.smk b/workflow/rules/retrieve.smk index 290fa2455..2e67902bd 100644 --- a/workflow/rules/retrieve.smk +++ b/workflow/rules/retrieve.smk @@ -137,6 +137,12 @@ rule retrieve_cpuc_baseline_generators: "../scripts/retrieve_cpuc_data.py" +# RESERVED (phase 2): `retrieve_cpuc_thermal_derate` will pull the CPUC SERVM +# unit-specific ambient-temperature derate profiles that back the +# `conventional.ambient_derate` config hook. Until it lands, enabling that key +# raises NotImplementedError in add_electricity. + + sector_datafiles = [ # heating sector "population/DECENNIALDHC2020.P1-Data.csv", diff --git a/workflow/rules/solve_electricity.smk b/workflow/rules/solve_electricity.smk index e9dacff97..86a2ea1f7 100644 --- a/workflow/rules/solve_electricity.smk +++ b/workflow/rules/solve_electricity.smk @@ -26,6 +26,7 @@ rule solve_network: safer_reeds="config/policy_constraints/reeds/prm_annual.csv", rps_reeds="config/policy_constraints/reeds/rps_fraction.csv", ces_reeds="config/policy_constraints/reeds/ces_fraction.csv", + interface_limits="config/policy_constraints/transmission_interface_limits.csv", pop_layout=pop_layout_input, ev_policy=ev_policy_input, output: diff --git a/workflow/rules/validate.smk b/workflow/rules/validate.smk index a6b968bbd..1c12a2d11 100644 --- a/workflow/rules/validate.smk +++ b/workflow/rules/validate.smk @@ -9,6 +9,7 @@ rule solve_network_validation: safer_reeds="config/policy_constraints/reeds/prm_annual.csv", rps_reeds="config/policy_constraints/reeds/rps_fraction.csv", ces_reeds="config/policy_constraints/reeds/ces_fraction.csv", + interface_limits="config/policy_constraints/transmission_interface_limits.csv", output: network=RESULTS + "{interconnect}/networks/elec_s{simpl}_c{clusters}_ec_l{ll}_{opts}_{sector}_operations.nc", diff --git a/workflow/scripts/add_electricity.py b/workflow/scripts/add_electricity.py index 563242519..c6aade7be 100755 --- a/workflow/scripts/add_electricity.py +++ b/workflow/scripts/add_electricity.py @@ -988,6 +988,22 @@ def broadcast_investment_horizons_index(n: pypsa.Network, df: pd.DataFrame): return df +def check_ambient_derate_not_enabled(conventional: dict) -> None: + """Guard the phase-2 ``conventional.ambient_derate`` hook. + + Ambient-temperature derates are meant to REPLACE the EIA-860 seasonal + derate applied by :func:`apply_seasonal_capacity_derates`, never to stack + on top of it (or on top of a UCAP-derated capacity credit) — stacking + double-counts the same thermal deficiency. Until the CPUC SERVM + unit-specific hourly derate profiles are retrieved and applied, enabling + the key is a hard error rather than a silent no-op. + """ + if (conventional or {}).get("ambient_derate", {}).get("enable", False): + raise NotImplementedError( + "conventional.ambient_derate is a phase-2 hook; CPUC unit-specific hourly derate profiles are not yet retrieved or applied.", + ) + + def apply_seasonal_capacity_derates( n: pypsa.Network, plants: pd.DataFrame, @@ -1255,6 +1271,7 @@ def main(snakemake): renewable_carriers, unit_commitment=params.conventional["unit_commitment"], ) + check_ambient_derate_not_enabled(params.conventional) apply_seasonal_capacity_derates( n, plants, diff --git a/workflow/scripts/opts/interfaces.py b/workflow/scripts/opts/interfaces.py new file mode 100644 index 000000000..4dd59c2fe --- /dev/null +++ b/workflow/scripts/opts/interfaces.py @@ -0,0 +1,103 @@ +"""Adds aggregate inter-regional transmission interface limits (RESOLVE/NARIS). + +An interface is a bundle of transmission paths between two groups of regions, +capped in aggregate rather than path-by-path. The limits are read from a CSV +with the columns ``interface, region_1, region_2, flow_12, flow_21``, where +``flow_12`` is the MW cap on flow out of ``region_1`` into ``region_2`` and +``flow_21`` the cap on flow in the opposite direction, for example:: + + interface,region_1,region_2,flow_12,flow_21,Notes + CAISO_Imports,"p9, p10, p11","p2, p5, p6, ...",9728,10208,RESOLVE + +The caps are applied to the import/export ``Link`` components created by +``add_extra_components.add_elec_imports_exports`` and are therefore a no-op +when ``electricity.imports``/``electricity.exports`` are disabled. +""" + +import logging + +import pandas as pd +import pypsa +from opts._helpers import get_region_buses + +logger = logging.getLogger(__name__) + +TRADE_CARRIERS = ("imports", "exports") + + +def _parse_regions(cell: str) -> list[str]: + """Split a comma separated region cell into a list of region names.""" + return [region.strip() for region in str(cell).split(",") if region.strip()] + + +def _boundary_links( + n: pypsa.Network, + inside_regions: list[str], + outside_regions: list[str], + direction: str, +) -> pd.Index: + """Get the trade links crossing an interface. + + Links are selected by bus membership and carrier, never by parsing link + names. Imports run from an external ``{zone}_imports`` bus into a bus inside + ``inside_regions``; exports run the other way into a ``{zone}_exports`` bus. + """ + if direction not in TRADE_CARRIERS: + raise ValueError(f"direction must be either imports or exports; received: {direction}") + + links = n.links[n.links.carrier == direction] + if links.empty: + return links.index + + # The external trade buses carry the *outside* zone name in their `country` + # field, which `get_region_buses` also matches on, so drop them here. + inside_buses = get_region_buses(n, inside_regions) + inside_buses = inside_buses[~inside_buses.carrier.isin(TRADE_CARRIERS)] + + external_names = {f"{zone}_{direction}" for zone in outside_regions} + external_buses = n.buses[ + (n.buses.carrier == direction) & (n.buses.index.isin(external_names) | n.buses.country.isin(outside_regions)) + ] + + if direction == "imports": + crossing = links.bus0.isin(external_buses.index) & links.bus1.isin(inside_buses.index) + else: + crossing = links.bus0.isin(inside_buses.index) & links.bus1.isin(external_buses.index) + + return links[crossing].index + + +def add_interface_transmission_limits(n: pypsa.Network, limits_csv_path: str) -> None: + """Cap the aggregate per-snapshot flow across each transmission interface. + + ``flow_21`` limits total imports into ``region_1``, ``flow_12`` total + exports out of it. Rows without any matching link are skipped. + + Note that only the import/export links are constrained. Region_2 entries + that are inside the network (e.g. `p8` in a California-only run is itself a + California zone) contribute no trade links, so internal AC lines such as + p8-p9 escape the cap. The resulting understatement is documented, not + corrected here. + """ + limits = pd.read_csv(limits_csv_path) + + for _, row in limits.iterrows(): + region_1 = _parse_regions(row.region_1) + region_2 = _parse_regions(row.region_2) + + for direction, cap in (("imports", row.flow_21), ("exports", row.flow_12)): + if pd.isna(cap): + continue + + links = _boundary_links(n, region_1, region_2, direction) + if links.empty: + logger.info(f"No {direction} links cross interface {row.interface}; skipping limit") + continue + + lhs = n.model["Link-p"].sel(name=links.tolist()).sum("name") + + n.model.add_constraints( + lhs <= float(cap), + name=f"interface_limit-{row.interface}-{direction}", + ) + logger.info(f"Added {direction} limit of {cap} MW on interface {row.interface} over {len(links)} links") diff --git a/workflow/scripts/solve_network.py b/workflow/scripts/solve_network.py index b28538c56..9858d6656 100644 --- a/workflow/scripts/solve_network.py +++ b/workflow/scripts/solve_network.py @@ -37,6 +37,7 @@ from constants import HOURS_PER_YEAR from opts.bidirectional_link import add_bidirectional_link_constraints from opts.interchange import add_interchange_constraints +from opts.interfaces import add_interface_transmission_limits from opts.land import add_land_use_constraints from opts.policy import ( add_regional_co2limit, @@ -202,6 +203,10 @@ def extra_functionality(n, snapshots): if config["electricity"].get("exports", {}).get("volume_limit", False): add_interchange_constraints(n, config, "exports", sector_enabled) + # Apply aggregate interface transmission limits if configured + if config["model_topology"].get("interface_transmission_limits", False): + add_interface_transmission_limits(n, global_snakemake.input.interface_limits) + # Apply sector-specific constraints if sector is enabled if sector_enabled: # Heat pump constraints diff --git a/workflow/scripts/test/test_interfaces.py b/workflow/scripts/test/test_interfaces.py new file mode 100644 index 000000000..91b0f9c56 --- /dev/null +++ b/workflow/scripts/test/test_interfaces.py @@ -0,0 +1,287 @@ +""" +Test the aggregate transmission interface limits. + +This module contains tests for the RESOLVE/NARIS style interface constraints +applied to the electricity import/export links in PyPSA-USA. +""" + +import os +import sys + +import pandas as pd +import pypsa +import pytest + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from _helpers import get_multiindex_snapshots +from opts.interfaces import ( + _boundary_links, + _parse_regions, + add_interface_transmission_limits, +) + +TOL = 1e-4 + +SHIPPED_LIMITS = os.path.join( + os.path.dirname(__file__), + "../../repo_data/config/policy_constraints/transmission_interface_limits.csv", +) + +# Fixtures + + +@pytest.fixture +def interface_network(): + """ + Build a small network with electricity import and export links. + + Mirrors the conventions of ``add_extra_components.add_elec_imports_exports``: + external buses are named ``{zone}_imports`` / ``{zone}_exports`` with the + matching carrier, import links run from the external bus into the model and + export links run the other way. + """ + n = pypsa.Network() + + n.snapshots = get_multiindex_snapshots( + sns_config={"start": "2030-01-01 00:00", "end": "2030-01-01 03:00", "inclusive": "both"}, + invest_periods=[2030], + ) + n.set_investment_periods(periods=[2030]) + + for carrier in ("AC", "gas", "imports", "exports"): + n.add("Carrier", carrier, co2_emissions=0) + + # Buses inside the model + n.add( + "Bus", + ["CA_Z1", "TX_Z1"], + carrier="AC", + country="US", + interconnect="western", + nerc_reg=["WECC", "WECC"], + reeds_state=["CA", "TX"], + reeds_zone=["CA_Z1", "TX_Z1"], + ) + + # External trade buses. `_add_import_export_buses` stamps the outside zone + # name onto `country`, which is what makes the carrier filter necessary. + n.add( + "Bus", + ["p2_imports", "p5_imports"], + carrier="imports", + country=["p2", "p5"], + interconnect="western", + ) + n.add( + "Bus", + "p2_exports", + carrier="exports", + country="p2", + interconnect="western", + ) + + # Trade links + n.add( + "Link", + "CA_Z1_p2_imports", + bus0="p2_imports", + bus1="CA_Z1", + carrier="imports", + p_nom=500, + marginal_cost=0, + ) + n.add( + "Link", + "CA_Z1_p5_imports", + bus0="p5_imports", + bus1="CA_Z1", + carrier="imports", + p_nom=500, + marginal_cost=0, + ) + # Decoy: an import link landing outside region_1 + n.add( + "Link", + "TX_Z1_p2_imports", + bus0="p2_imports", + bus1="TX_Z1", + carrier="imports", + p_nom=500, + marginal_cost=0, + ) + n.add( + "Link", + "CA_Z1_p2_exports", + bus0="CA_Z1", + bus1="p2_exports", + carrier="exports", + p_nom=500, + marginal_cost=0, + ) + # Decoy: an internal AC link between two in-model buses + n.add( + "Link", + "CA_Z1_TX_Z1", + bus0="CA_Z1", + bus1="TX_Z1", + carrier="AC", + p_nom=500, + ) + + # Generation and demand: imports are cheap, local gas is not + n.add("Generator", "import_p2", bus="p2_imports", carrier="imports", p_nom=1000, marginal_cost=1) + n.add("Generator", "import_p5", bus="p5_imports", carrier="imports", p_nom=1000, marginal_cost=1) + n.add("Generator", "gas_ca", bus="CA_Z1", carrier="gas", p_nom=1000, marginal_cost=50) + n.add("Generator", "gas_tx", bus="TX_Z1", carrier="gas", p_nom=1000, marginal_cost=50) + # Expensive backup behind the export bus, so an export cap stays feasible + n.add("Generator", "gas_p2", bus="p2_exports", carrier="gas", p_nom=1000, marginal_cost=100) + + n.add("Load", "load_ca", bus="CA_Z1", carrier="AC", p_set=pd.Series(300.0, index=n.snapshots)) + n.add("Load", "load_tx", bus="TX_Z1", carrier="AC", p_set=pd.Series(200.0, index=n.snapshots)) + n.add("Load", "load_p2", bus="p2_exports", carrier="exports", p_set=pd.Series(400.0, index=n.snapshots)) + + return n + + +def write_limits(tmp_path, rows): + """Write an interface limits CSV and return its path.""" + path = tmp_path / "transmission_interface_limits.csv" + pd.DataFrame(rows).to_csv(path, index=False) + return str(path) + + +# Tests + + +def test_parse_regions_handles_whitespace(): + assert _parse_regions("p9, p10,p11 ") == ["p9", "p10", "p11"] + assert _parse_regions("p9") == ["p9"] + assert _parse_regions("") == [] + + +def test_boundary_links_selects_only_crossing_links(interface_network): + n = interface_network + + imports = _boundary_links(n, ["CA_Z1"], ["p2", "p5"], "imports") + assert sorted(imports) == ["CA_Z1_p2_imports", "CA_Z1_p5_imports"] + + exports = _boundary_links(n, ["CA_Z1"], ["p2", "p5"], "exports") + assert sorted(exports) == ["CA_Z1_p2_exports"] + + # Only the named outside zones count + assert sorted(_boundary_links(n, ["CA_Z1"], ["p5"], "imports")) == ["CA_Z1_p5_imports"] + + # Region_1 can be given as any of the labels get_region_buses matches on + assert sorted(_boundary_links(n, ["CA"], ["p2", "p5"], "imports")) == [ + "CA_Z1_p2_imports", + "CA_Z1_p5_imports", + ] + + with pytest.raises(ValueError): + _boundary_links(n, ["CA_Z1"], ["p2"], "both") + + +def test_no_matching_links_is_a_noop_not_an_error(interface_network, tmp_path): + n = interface_network + limits = write_limits( + tmp_path, + [ + # Neither region exists in this network + {"interface": "NW_SW", "region_1": "p30", "region_2": "p33", "flow_12": 100, "flow_21": 100}, + ], + ) + + def extra_functionality(n, sns): + add_interface_transmission_limits(n, limits) + # the shipped RESOLVE table names ReEDS zones absent from this network + add_interface_transmission_limits(n, SHIPPED_LIMITS) + + n.optimize(solver_name="glpk", multi_investment_periods=True, extra_functionality=extra_functionality) + + assert not [c for c in n.model.constraints if c.startswith("interface_limit-")] + + +def test_import_cap_binds(interface_network, tmp_path): + n = interface_network + cap = 100.0 + limits = write_limits( + tmp_path, + [ + { + "interface": "CAISO_Imports", + "region_1": "CA_Z1", + "region_2": "p2, p5", + "flow_12": 1e6, + "flow_21": cap, + }, + ], + ) + + def extra_functionality(n, sns): + add_interface_transmission_limits(n, limits) + + n.optimize(solver_name="glpk", multi_investment_periods=True, extra_functionality=extra_functionality) + + assert "interface_limit-CAISO_Imports-imports" in n.model.constraints + + flow = n.links_t.p0[["CA_Z1_p2_imports", "CA_Z1_p5_imports"]].sum(axis=1) + assert (flow <= cap + TOL).all() + assert flow.max() >= cap - TOL, "import cap should bind in at least one snapshot" + + # The decoy import link into TX is outside the interface and stays free + assert n.links_t.p0["TX_Z1_p2_imports"].max() > cap + TOL + + +def test_export_cap_uses_flow_12(interface_network, tmp_path): + n = interface_network + cap = 150.0 + limits = write_limits( + tmp_path, + [ + { + "interface": "CAISO_Exports", + "region_1": "CA_Z1", + "region_2": "p2, p5", + "flow_12": cap, + "flow_21": 1e6, + }, + ], + ) + + def extra_functionality(n, sns): + add_interface_transmission_limits(n, limits) + + n.optimize(solver_name="glpk", multi_investment_periods=True, extra_functionality=extra_functionality) + + assert "interface_limit-CAISO_Exports-exports" in n.model.constraints + + flow = n.links_t.p0["CA_Z1_p2_exports"] + assert (flow <= cap + TOL).all() + assert flow.max() >= cap - TOL, "export cap should bind in at least one snapshot" + + +def test_disabled_flag_adds_no_constraints(interface_network, tmp_path): + n = interface_network + limits = write_limits( + tmp_path, + [ + { + "interface": "CAISO_Imports", + "region_1": "CA_Z1", + "region_2": "p2, p5", + "flow_12": 100, + "flow_21": 100, + }, + ], + ) + config = {"model_topology": {"interface_transmission_limits": False}} + + def extra_functionality(n, sns): + # mirrors the gate in solve_network.extra_functionality + if config["model_topology"].get("interface_transmission_limits", False): + add_interface_transmission_limits(n, limits) + + n.optimize(solver_name="glpk", multi_investment_periods=True, extra_functionality=extra_functionality) + + assert not [c for c in n.model.constraints if c.startswith("interface_limit-")]