diff --git a/docs/source/data-sectors.md b/docs/source/data-sectors.md
index 1ed7fec27..bf3b3d1e5 100644
--- a/docs/source/data-sectors.md
+++ b/docs/source/data-sectors.md
@@ -89,7 +89,7 @@ Within the transport sector, demand response is applied to the aggregation bus d
The diagram below illustrates the workflow of PyPSA-USA Sector. Many rules overlap with the electricity sector workflow; however, several additional rules are also present.
-:::{figure-md} workflow
+:::{figure-md} sector-workflow
Snakemake DAG for sector coupled studies
diff --git a/docs/source/data-transmission.md b/docs/source/data-transmission.md
index 83eb5d4ca..58233075b 100644
--- a/docs/source/data-transmission.md
+++ b/docs/source/data-transmission.md
@@ -34,7 +34,7 @@ While representative of the US electricity system, the TAMU network is synthetic
-```{info}
+```{note}
See the [Spatial Configuration](./config-spatial.md) page for information on how to choose between networks.
```
diff --git a/docs/source/publications.bib b/docs/source/publications.bib
index 5ec42875a..37c94f69d 100644
--- a/docs/source/publications.bib
+++ b/docs/source/publications.bib
@@ -1,3 +1,33 @@
+@misc{barnes2026b,
+ title = {Near-Term Emission Targets Need Immediate Attention in the USA},
+ author = {Trevor Barnes and Kamran Tehranchi and Brad Reinholz and Malcolm Metcalfe and Taco Niet},
+ year = {2026},
+ archivePrefix = {arXiv},
+ url = {https://arxiv.org/abs/2607.01471},
+ doi = {10.48550/arXiv.2607.01471},
+}
+
+@misc{ai2026,
+ title = {Economic Valuation and Optimal Deployment of Static Synchronous Series Compensators for U.S. Power System Expansion},
+ author = {Wei Ai and Vladimir Dvorkin and Michael T. Craig},
+ year = {2026},
+ archivePrefix = {arXiv},
+ url = {https://arxiv.org/abs/2605.00734},
+ doi = {10.48550/arXiv.2605.00734},
+}
+
+@article{barnes_2026a,
+ doi = {10.1371/journal.pclm.0000918},
+ url = {https://journals.plos.org/climate/article?id=10.1371/journal.pclm.0000918},
+ author = {Barnes, Trevor and Tehranchi, Kamran and Reinholz, Bradley and Metcalfe, Malcolm and Niet, Taco},
+ title = {Multi-sector demand response for cost optimal energy transitions},
+ year = {2026},
+ volume = {5},
+ issue = {5},
+ pages = {e0000918},
+ journal = {PLOS Climate}
+}
+
@misc{tehranchi_2024,
doi = {10.2139/ssrn.5029120},
url = {https://www.ssrn.com/abstract=5029120},
diff --git a/workflow/repo_data/policy_constraints/small_scale_solar.csv b/workflow/repo_data/policy_constraints/small_scale_solar.csv
new file mode 100644
index 000000000..93816fe2d
--- /dev/null
+++ b/workflow/repo_data/policy_constraints/small_scale_solar.csv
@@ -0,0 +1,49 @@
+state,year,generation_mwh
+AL,2023,460000
+AZ,2023,5890000
+AR,2023,190000
+CA,2023,39800000
+CO,2023,2640000
+CT,2023,1530000
+DE,2023,390000
+FL,2023,9620000
+GA,2023,2180000
+ID,2023,480000
+IL,2023,1130000
+IN,2023,560000
+IA,2023,290000
+KS,2023,270000
+KY,2023,200000
+LA,2023,460000
+ME,2023,820000
+MD,2023,2050000
+MA,2023,4870000
+MI,2023,1800000
+MN,2023,1010000
+MS,2023,150000
+MO,2023,470000
+MT,2023,170000
+NE,2023,200000
+NV,2023,2100000
+NH,2023,680000
+NJ,2023,4280000
+NM,2023,700000
+NY,2023,4550000
+NC,2023,2060000
+ND,2023,50000
+OH,2023,1280000
+OK,2023,340000
+OR,2023,1460000
+PA,2023,1870000
+RI,2023,520000
+SC,2023,680000
+SD,2023,70000
+TN,2023,480000
+TX,2023,8700000
+UT,2023,1090000
+VT,2023,610000
+VA,2023,1740000
+WA,2023,1110000
+WV,2023,80000
+WI,2023,760000
+WY,2023,120000
diff --git a/workflow/rules/retrieve.smk b/workflow/rules/retrieve.smk
index 4c647a7f2..f4e445b56 100644
--- a/workflow/rules/retrieve.smk
+++ b/workflow/rules/retrieve.smk
@@ -232,8 +232,7 @@ rule retrieve_caiso_data:
fuel_prices=DATA + "costs/caiso_ng_power_prices.csv",
log:
LOGS + "retrieve_caiso_data.log",
- shadow:
- "minimal"
+ # shadow: "minimal" # disabled on Windows (symlink creation requires Developer Mode)
resources:
walltime="00:10:00",
mem_mb=2000,
@@ -254,6 +253,23 @@ rule retrieve_pudl:
"../scripts/retrieve_pudl.py"
+rule retrieve_small_scale_solar:
+ params:
+ eia_api=config.get("api", {}).get("eia", None),
+ planning_horizons=config["scenario"]["planning_horizons"],
+ input:
+ fallback="repo_data/policy_constraints/small_scale_solar.csv",
+ output:
+ small_scale_solar=DATA + "eia/small_scale_solar.csv",
+ log:
+ LOGS + "retrieve_small_scale_solar.log",
+ resources:
+ walltime="00:10:00",
+ mem_mb=1000,
+ script:
+ "../scripts/retrieve_small_scale_solar.py"
+
+
if "EGS" in config["electricity"]["extendable_carriers"]["Generator"]:
rule retrieve_egs:
diff --git a/workflow/rules/solve_electricity.smk b/workflow/rules/solve_electricity.smk
index 5b5b3610e..6c83abb1b 100644
--- a/workflow/rules/solve_electricity.smk
+++ b/workflow/rules/solve_electricity.smk
@@ -29,6 +29,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",
+ small_scale_solar=DATA + "eia/small_scale_solar.csv",
pop_layout=pop_layout_input,
ev_policy=ev_policy_input,
output:
diff --git a/workflow/scripts/eia.py b/workflow/scripts/eia.py
index 5d27c9145..a44a9f4db 100644
--- a/workflow/scripts/eia.py
+++ b/workflow/scripts/eia.py
@@ -490,6 +490,43 @@ def data_creator(self):
return _ElectricPowerOperationalData(self.sector, self.year, self.api_key)
+class SmallScaleSolar(EiaData):
+ """State-level small-scale (behind-the-meter) solar PV annual generation.
+
+ Fetches sector-98 (below 1 MW nameplate) solar generation from the EIA
+ Electric Power Operational Data API for a range of years.
+
+ Parameters
+ ----------
+ start_year : int
+ First year to retrieve (EIA data available from ~2014).
+ end_year : int
+ Last year to retrieve. Capped at the latest available year.
+ api : str
+ EIA API v2 key.
+
+ Examples
+ --------
+ >>> df = SmallScaleSolar(2014, 2023, "YOUR_API_KEY").get_data()
+ """
+
+ LAST_AVAILABLE_YEAR: ClassVar[int] = 2023
+
+ def __init__(self, start_year: int, end_year: int, api: str) -> None:
+ self.start_year = start_year
+ self.end_year = min(end_year, self.LAST_AVAILABLE_YEAR)
+ self.api = api
+ if end_year > self.LAST_AVAILABLE_YEAR:
+ logger.warning(
+ f"Small-scale solar data only available through {self.LAST_AVAILABLE_YEAR}. "
+ f"Capping end_year at {self.LAST_AVAILABLE_YEAR}.",
+ )
+
+ def data_creator(self):
+ """Initializes data extractor."""
+ return _SmallScaleSolarData(self.start_year, self.end_year, self.api)
+
+
# product
class DataExtractor(ABC):
"""Extracts and formats data."""
@@ -1839,6 +1876,52 @@ def format_data(self, df: pd.DataFrame) -> pd.DataFrame:
return self._assign_dtypes(df)
+class _SmallScaleSolarData(DataExtractor):
+ """Fetches annual small-scale (behind-the-meter) solar generation by state.
+
+ Uses the EIA Electric Power Operational Data endpoint with sector 98
+ (small-scale photovoltaic, below 1 MW nameplate) and fuel type SUN.
+ Generation values are returned in MWh (converted from the API's thousand MWh).
+ """
+
+ SECTOR_ID: ClassVar[int] = 98
+ FUEL_TYPE: ClassVar[str] = "SUN"
+
+ def __init__(self, start_year: int, end_year: int, api_key: str) -> None:
+ super().__init__(end_year, api_key) # self.year = end_year
+ self.start_year = start_year
+
+ def build_url(self) -> str:
+ base_url = "electricity/electric-power-operational-data/data/"
+ facets = (
+ f"frequency=annual&data[0]=generation"
+ f"&facets[fueltypeid][]={self.FUEL_TYPE}"
+ f"&facets[sectorid][]={self.SECTOR_ID}"
+ f"&start={self.start_year}&end={self.year}"
+ f"&sort[0][column]=period&sort[0][direction]=asc"
+ f"&offset=0&length=5000"
+ )
+ return f"{API_BASE}{base_url}?api_key={self.api_key}&{facets}"
+
+ def format_data(self, df: pd.DataFrame) -> pd.DataFrame:
+ df = df.rename(
+ columns={
+ "location": "state",
+ "period": "year",
+ "generation": "generation_mwh",
+ },
+ )
+ df = df[["state", "year", "generation_mwh"]].copy()
+ df["year"] = df["year"].astype(int)
+ # EIA reports generation in thousand MWh; convert to MWh
+ df["generation_mwh"] = pd.to_numeric(df["generation_mwh"], errors="coerce") * 1_000
+ df = df.dropna(subset=["generation_mwh"])
+ df = df[df["generation_mwh"] > 0]
+ # Exclude national aggregates (e.g., "US") and sub-state numeric codes
+ df = df[(df["state"].str.len() == 2) & (df["state"] != "US")]
+ return df.sort_values(["state", "year"]).reset_index(drop=True)
+
+
if __name__ == "__main__":
with open("./../config/config.api.yaml") as file:
yaml_data = yaml.safe_load(file)
diff --git a/workflow/scripts/opts/policy.py b/workflow/scripts/opts/policy.py
index 420ec7959..4cb3e7236 100644
--- a/workflow/scripts/opts/policy.py
+++ b/workflow/scripts/opts/policy.py
@@ -19,6 +19,7 @@
"offwind",
"offwind_floating",
"solar",
+ "solar-rooftop",
"hydro",
"geothermal",
"biomass",
@@ -247,7 +248,22 @@ def _collapse_portfolio_standards(n: pypsa.Network, planning_horizons: list[int]
return portfolio_standards
-def add_RPS_constraints(n, config, snakemake=None):
+def _load_small_scale_solar(snakemake) -> pd.Series:
+ """
+ Load state-level small-scale (behind-the-meter) solar generation.
+
+ Returns a Series indexed by (state, year) with generation in MWh, or an
+ empty Series if the input is not available on the snakemake object.
+ """
+ path = getattr(snakemake.input, "small_scale_solar", None)
+ if path is None:
+ return pd.Series(dtype=float)
+
+ df = pd.read_csv(path, dtype={"state": str, "year": int, "generation_mwh": float})
+ return df.set_index(["state", "year"])["generation_mwh"]
+
+
+def add_RPS_constraints(n, config, snakemake=None, sector=False):
"""
Add Renewable Portfolio Standards (RPS) constraints to the network.
@@ -255,10 +271,31 @@ def add_RPS_constraints(n, config, snakemake=None):
from renewable energy sources for specific regions and planning horizons.
It reads the necessary data from configuration files and the network.
- The differenct between electrical and sector implementation is:
+ The difference between electrical and sector implementation is:
- Electrical applies RPS against exogenously defined demand
- Sector applies RPS against endogenously solved power sector generation
+ When ``snakemake.input.small_scale_solar`` is provided, the demand basis for
+ each constraint is adjusted from net load to gross load by adding back the
+ behind-the-meter (rooftop) solar generation that is embedded as a demand
+ reduction in the EIA 930 input data. This ensures that existing rooftop
+ solar receives credit toward the RPS target even when it is not explicitly
+ modelled as a Generator in the network.
+
+ The adjusted RHS is:
+ rhs = pct * net_load - (1 - pct) * rooftop_gen
+ = pct * gross_load - rooftop_gen
+
+ **Demand source compatibility:** The BTM credit is only appropriate when
+ the network load time-series is derived from EIA 930 *net* generation data
+ (``demand.profile: eia``), in which case behind-the-meter solar is already
+ subtracted from the reported load. If the demand profile is sourced from
+ EFS or AEO projections — which typically report *gross* electricity sales
+ and do not subtract BTM generation — the BTM credit should not be applied.
+ To disable it, simply omit the ``small_scale_solar`` input from the
+ ``solve_network`` rule (or leave ``api.eia`` unconfigured so the fallback
+ CSV is not forward-filled beyond its data year).
+
Parameters
----------
n : pypsa.Network
@@ -299,21 +336,58 @@ def add_RPS_constraints(n, config, snakemake=None):
ces_reeds,
)
+ # Small-scale solar generation by (state, year) in MWh — may be empty if
+ # the input file was not provided.
+ small_scale_solar = _load_small_scale_solar(snakemake)
+ using_btm_credit = not small_scale_solar.empty
+
for _, constraint_row in portfolio_standards.iterrows():
region_list = [region.strip() for region in constraint_row.region.split(",")]
region_buses = get_region_buses(n, region_list)
if region_buses.empty:
continue
+ # Net load from the grid (EIA 930 data already has BTM solar subtracted)
region_demand = (
n.loads_t.p_set.loc[constraint_row.planning_horizon]
.loc[:, n.loads.bus.isin(region_buses.index)]
.sum()
.sum()
)
- region_rps_rhs = int(constraint_row.pct * region_demand)
+
+ # Credit existing behind-the-meter rooftop solar toward the RPS target.
+ #
+ # Derivation:
+ # statutory target: utility_renewables + rooftop >= pct * gross_load
+ # gross_load = net_load + rooftop
+ # rearranged: utility_renewables >= pct * net_load - (1 - pct) * rooftop
+ #
+ # When rooftop solar is explicitly modelled as a Generator (carrier
+ # "solar-rooftop") in the network, it already appears on the LHS and
+ # n.loads_t.p_set is the gross load, so no adjustment is needed.
+ # The BTM credit here only applies to the residual rooftop generation
+ # that is embedded as a demand reduction and has no generator in the
+ # network.
+ rooftop_gen = 0.0
+ if using_btm_credit:
+ for state in region_list:
+ key = (state.strip(), constraint_row.planning_horizon)
+ if key in small_scale_solar.index:
+ rooftop_gen += small_scale_solar[key]
+
+ pct = constraint_row.pct
+ region_rps_rhs = max(int(pct * region_demand - (1 - pct) * rooftop_gen), 0)
+
portfolio_standards.loc[constraint_row.name, "rps_rhs"] = region_rps_rhs
+ if using_btm_credit and rooftop_gen > 0:
+ logger.info(
+ f"RPS demand basis for {constraint_row.region} ({constraint_row.planning_horizon}): "
+ f"net_load={region_demand / 1e6:.1f} TWh, "
+ f"btm_rooftop={rooftop_gen / 1e6:.2f} TWh, "
+ f"adjusted_rhs={region_rps_rhs / 1e6:.1f} TWh",
+ )
+
# Iterate through constraints and add RPS constraints to the model
for (rec_trading_zone, planning_horizon, policy_carriers), zone_constraints in portfolio_standards.groupby(
["rec_trading_zone", "planning_horizon", "carrier"],
@@ -328,7 +402,7 @@ def add_RPS_constraints(n, config, snakemake=None):
region_gens_eligible = region_gens[region_gens.carrier.isin(carriers)]
if region_gens_eligible.empty:
- return
+ continue # skip this constraint group; do not exit the whole function
# Eligible generation
p_eligible = n.model["Generator-p"].sel(
diff --git a/workflow/scripts/retrieve_small_scale_solar.py b/workflow/scripts/retrieve_small_scale_solar.py
new file mode 100644
index 000000000..f56007819
--- /dev/null
+++ b/workflow/scripts/retrieve_small_scale_solar.py
@@ -0,0 +1,98 @@
+"""
+**Description**
+
+State-level small-scale (behind-the-meter / rooftop) solar generation is
+retrieved from the U.S. Energy Information Administration (EIA) API v2.
+
+The EIA reports small-scale solar PV generation separately from utility-scale
+plants. This generation is already embedded as a demand reduction in the EIA
+930 net-load data used elsewhere in the model. Having it as an explicit input
+allows the RPS constraint to credit it properly (see opts/policy.py).
+
+**EIA series used**
+
+- ``electricity/electric-power-operational-data``
+ - ``fueltypeid = SUN``
+ - ``sectorid = 98`` (small-scale, i.e. below 1 MW threshold)
+ - ``frequency = annual``
+
+**Outputs**
+
+- ``data/eia/small_scale_solar.csv``
+
+ Columns: ``state``, ``year``, ``generation_mwh``
+
+ Values are annual generation in MWh, disaggregated by state. Rows with
+ zero or missing generation are dropped.
+
+**API key**
+
+Set ``api: eia: `` in ``config/config.api.yaml``. A free key can be
+obtained at https://www.eia.gov/opendata/. Without a key the script falls back
+to the bundled historical CSV stored in ``repo_data/policy_constraints/``.
+"""
+
+import logging
+from pathlib import Path
+
+import pandas as pd
+from eia import SmallScaleSolar
+
+logger = logging.getLogger(__name__)
+
+
+def _load_fallback(fallback_path: str) -> pd.DataFrame:
+ """Load a pre-bundled CSV when no EIA API key is available."""
+ logger.warning(
+ "No EIA API key provided. Loading bundled small-scale solar data from "
+ f"{fallback_path}. This data may not match your planning horizons exactly; "
+ "the most recent available year will be used for future years.",
+ )
+ df = pd.read_csv(fallback_path, dtype={"state": str, "year": int, "generation_mwh": float})
+ return df
+
+
+if __name__ == "__main__":
+ if "snakemake" not in globals():
+ from _helpers import mock_snakemake
+
+ snakemake = mock_snakemake("retrieve_small_scale_solar")
+
+ logging.basicConfig(level=logging.INFO)
+
+ api_key = snakemake.params.get("eia_api", None)
+ planning_horizons = snakemake.params.planning_horizons
+ fallback_path = snakemake.input.fallback
+
+ if api_key:
+ # Fetch years from the earliest data year through the last planning horizon.
+ # EIA small-scale solar data starts around 2014.
+ start_year = 2014
+ end_year = max(planning_horizons)
+ df = SmallScaleSolar(start_year, end_year, api_key).get_data()
+ logger.info(
+ f"Retrieved {len(df)} state-year observations of small-scale solar from EIA API ({start_year}–{end_year}).",
+ )
+ else:
+ df = _load_fallback(fallback_path)
+
+ # For planning horizons beyond the latest data year, forward-fill with the
+ # most recent observed value for each state.
+ latest_year = df["year"].max()
+ for horizon in planning_horizons:
+ if horizon > latest_year:
+ latest = df[df["year"] == latest_year][["state", "generation_mwh"]].copy()
+ latest["year"] = horizon
+ df = pd.concat([df, latest], ignore_index=True)
+
+ # Keep only relevant years
+ df = df[df["year"].isin(planning_horizons)]
+ df = df.sort_values(["state", "year"]).reset_index(drop=True)
+
+ output_path = Path(snakemake.output.small_scale_solar)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ df.to_csv(output_path, index=False)
+
+ logger.info(
+ f"Small-scale solar data written to {output_path} ({len(df)} state-year rows).",
+ )
diff --git a/workflow/scripts/test/conftest.py b/workflow/scripts/test/conftest.py
index 0a41ddeee..d502f764f 100644
--- a/workflow/scripts/test/conftest.py
+++ b/workflow/scripts/test/conftest.py
@@ -77,6 +77,12 @@ def base_network():
n.buses.loc["z1", "reeds_zone"] = "CA_Z1"
n.buses.loc["z2", "reeds_zone"] = "TX_Z1"
n.buses.loc["z3", "reeds_zone"] = "TX_Z1"
+ # rec_trading_zone mirrors reeds_state for the test network (real networks
+ # use constants.REC_TRADING_ZONE_MAPPER; states not in the mapper fall back
+ # to their own state abbreviation, which is what we replicate here).
+ n.buses.loc["z1", "rec_trading_zone"] = "CA"
+ n.buses.loc["z2", "rec_trading_zone"] = "TX"
+ n.buses.loc["z3", "rec_trading_zone"] = "TX"
# Add versatile generators for different test scenarios
# Wind generators
@@ -339,6 +345,9 @@ def multi_period_base_network():
n.buses.loc["z1", "reeds_zone"] = "CA_Z1"
n.buses.loc["z2", "reeds_zone"] = "TX_Z1"
n.buses.loc["z3", "reeds_zone"] = "TX_Z1"
+ n.buses.loc["z1", "rec_trading_zone"] = "CA"
+ n.buses.loc["z2", "rec_trading_zone"] = "TX"
+ n.buses.loc["z3", "rec_trading_zone"] = "TX"
# Wind generators (extendable, active in both periods)
n.add(
diff --git a/workflow/scripts/test/fixtures/small_scale_solar.csv b/workflow/scripts/test/fixtures/small_scale_solar.csv
new file mode 100644
index 000000000..e70eea8f6
--- /dev/null
+++ b/workflow/scripts/test/fixtures/small_scale_solar.csv
@@ -0,0 +1,3 @@
+state,year,generation_mwh
+CA,2030,720
+TX,2030,1200
diff --git a/workflow/scripts/test/test_policy.py b/workflow/scripts/test/test_policy.py
index 351e32fe9..a9dea7091 100644
--- a/workflow/scripts/test/test_policy.py
+++ b/workflow/scripts/test/test_policy.py
@@ -126,6 +126,38 @@ def __init__(self):
return config, snakemake
+@pytest.fixture
+def rps_config_with_btm():
+ """Create a config dict for RPS constraints that includes BTM solar credit data."""
+ config = {
+ "electricity": {
+ "portfolio_standards": os.path.join(os.path.dirname(__file__), "fixtures/portfolio_standards.csv"),
+ },
+ }
+
+ class MockSnakemakeWithBTM:
+ def __init__(self):
+ self.input = type(
+ "obj",
+ (object,),
+ {
+ "rps_reeds": os.path.join(os.path.dirname(__file__), "fixtures/rps_reeds.csv"),
+ "ces_reeds": os.path.join(os.path.dirname(__file__), "fixtures/ces_reeds.csv"),
+ "small_scale_solar": os.path.join(os.path.dirname(__file__), "fixtures/small_scale_solar.csv"),
+ },
+ )
+ self.params = type(
+ "obj",
+ (object,),
+ {
+ "planning_horizons": [2030],
+ },
+ )
+
+ snakemake = MockSnakemakeWithBTM()
+ return config, snakemake
+
+
@pytest.fixture
def tct_config():
"""Create a config dictionary for TCT constraints."""
@@ -305,6 +337,133 @@ def extra_functionality(n, _):
)
+def test_rooftop_solar_counts_toward_rps(policy_network, rps_config):
+ """Rooftop solar (carrier 'solar-rooftop') must be credited toward the RPS.
+
+ This is a regression test for the bug where 'solar-rooftop' was absent from
+ RPS_CARRIERS, causing rooftop solar generators to be silently ignored when the
+ optimizer evaluates RPS compliance.
+
+ The test adds a fixed-output rooftop solar generator to the CA bus and sets an
+ RPS percentage that can only be satisfied if rooftop solar counts. If
+ 'solar-rooftop' is not in RPS_CARRIERS the constraint would be infeasible or
+ the model would need to build significantly more utility-scale capacity.
+ """
+ from opts.policy import RPS_CARRIERS, add_RPS_constraints
+
+ # Verify the fix is in place before running the optimisation
+ assert "solar-rooftop" in RPS_CARRIERS, (
+ "'solar-rooftop' is missing from RPS_CARRIERS — rooftop solar will not count toward RPS"
+ )
+
+ n = policy_network.copy()
+ config, snakemake = rps_config
+
+ solar_profile = pd.Series(0.5, index=n.snapshots)
+
+ # Add a rooftop solar generator on the CA bus (z1)
+ n.add(
+ "Generator",
+ "rooftop_solar_z1",
+ bus="z1",
+ p_nom=200,
+ p_nom_extendable=False,
+ carrier="solar-rooftop",
+ capital_cost=0,
+ marginal_cost=0,
+ p_max_pu=solar_profile,
+ )
+ n.add("Carrier", "solar-rooftop", co2_emissions=0)
+
+ def extra_functionality(n, _):
+ add_RPS_constraints(n, config, sector=False, snakemake=snakemake)
+
+ n.optimize(solver_name="glpk", multi_investment_periods=True, extra_functionality=extra_functionality)
+
+ assert any("rps_limit" in c for c in n.model.constraints), "No RPS limit constraints were added"
+
+ # Verify that rooftop solar generation is counted on the LHS of the constraint
+ region_buses = get_region_buses(n, ["CA"])
+ rooftop_gens = n.generators[(n.generators.bus.isin(region_buses.index)) & (n.generators.carrier == "solar-rooftop")]
+ assert not rooftop_gens.empty, "Rooftop solar generator missing from CA region"
+
+ rooftop_gen = n.generators_t.p[rooftop_gens.index].sum().sum()
+ assert rooftop_gen > 0, "Rooftop solar generator produced no energy — check p_max_pu"
+
+ # Confirm that total eligible generation (including rooftop) meets the RPS target
+ eligible_carriers = ["solar", "solar-rooftop", "onwind"]
+ eligible_gens = n.generators[
+ (n.generators.bus.isin(region_buses.index)) & (n.generators.carrier.isin(eligible_carriers))
+ ]
+ eligible_gen = n.generators_t.p[eligible_gens.index].sum().sum()
+ region_demand = n.loads_t.p_set.loc[:, n.loads.bus.isin(region_buses.index)].sum().sum()
+
+ rps_pct = 0.9 # matches fixtures/portfolio_standards.csv for CA
+ epsilon = 1e-3
+ assert eligible_gen >= rps_pct * region_demand - epsilon, (
+ f"RPS not met even with rooftop solar: {eligible_gen:.1f} MWh < {rps_pct * region_demand:.1f} MWh"
+ )
+
+
+def test_btm_solar_credit_reduces_rps_rhs(policy_network, rps_config, rps_config_with_btm):
+ """BTM solar credit should reduce the required utility-scale renewable generation.
+
+ The RPS constraint RHS is adjusted from ``pct * net_load`` to
+ ``pct * net_load - (1 - pct) * rooftop_gen``. This means when small-scale
+ (behind-the-meter) solar data is provided, the optimizer needs to build
+ *less* utility-scale renewable capacity to satisfy the same statutory target.
+ The test confirms this by comparing solutions with and without the BTM data.
+
+ Test network (from fixtures/small_scale_solar.csv):
+ CA demand = 300 MW × 24 h = 7 200 MWh, btm_solar = 720 MWh
+ CA pct = 0.90 (from fixtures/portfolio_standards.csv)
+ Without BTM: rhs = 0.90 × 7200 = 6 480 MWh
+ With BTM: rhs = 0.90 × 7200 - 0.10 × 720 = 6 408 MWh (72 MWh less)
+ """
+ from opts.policy import add_RPS_constraints
+
+ # --- Solve WITHOUT BTM credit ---
+ n_no_btm = policy_network.copy()
+ config_no_btm, snakemake_no_btm = rps_config
+
+ def extra_no_btm(n, _):
+ add_RPS_constraints(n, config_no_btm, sector=False, snakemake=snakemake_no_btm)
+
+ n_no_btm.optimize(solver_name="glpk", multi_investment_periods=True, extra_functionality=extra_no_btm)
+
+ # --- Solve WITH BTM credit ---
+ n_with_btm = policy_network.copy()
+ config_with_btm, snakemake_with_btm = rps_config_with_btm
+
+ def extra_with_btm(n, _):
+ add_RPS_constraints(n, config_with_btm, sector=False, snakemake=snakemake_with_btm)
+
+ n_with_btm.optimize(solver_name="glpk", multi_investment_periods=True, extra_functionality=extra_with_btm)
+
+ # Measure CA utility-scale eligible generation in each solution
+ region_buses_ca = get_region_buses(n_no_btm, ["CA"])
+ eligible_carriers = ["solar", "onwind"]
+
+ def _eligible_gen(n):
+ gens = n.generators[
+ (n.generators.bus.isin(region_buses_ca.index)) & (n.generators.carrier.isin(eligible_carriers))
+ ]
+ return n.generators_t.p[gens.index].sum().sum()
+
+ gen_no_btm = _eligible_gen(n_no_btm)
+ gen_with_btm = _eligible_gen(n_with_btm)
+
+ logger.info(
+ f"BTM credit test: CA eligible gen without BTM = {gen_no_btm:.1f} MWh, "
+ f"with BTM = {gen_with_btm:.1f} MWh (reduction = {gen_no_btm - gen_with_btm:.1f} MWh)",
+ )
+
+ assert gen_with_btm <= gen_no_btm + 1e-3, (
+ f"BTM credit should reduce or not increase required renewable generation: "
+ f"with_btm={gen_with_btm:.1f} > no_btm={gen_no_btm:.1f}"
+ )
+
+
def test_add_technology_capacity_target_constraints(policy_network, tct_config):
"""Test that technology capacity target constraints are correctly added to the network."""
from opts.policy import add_technology_capacity_target_constraints