fix(rps): credit behind-the-meter rooftop solar against RPS targets - #773
fix(rps): credit behind-the-meter rooftop solar against RPS targets#773WilsonHM18 wants to merge 9 commits into
Conversation
`v0.8.0` Release Candidate
Adds publication to docs
Rooftop solar (solar-rooftop) was excluded from RPS-eligible carriers and its generation was embedded in net-load data, causing the RPS constraint to be over-tightened relative to the policy intent. Changes: - Add "solar-rooftop" to RPS_CARRIERS so BTM generation counts toward the portfolio standard - Fix a return→continue bug that silently skipped states with no matching portfolio-standard row - Implement BTM credit adjustment: rhs = pct*net_load - (1-pct)*btm_rooftop so that rooftop generation already embedded in load is not double-penalised - Add retrieve_small_scale_solar rule (EIA API v2 with bundled CSV fallback) and wire small_scale_solar.csv into solve_network inputs - Add rec_trading_zone to test network fixtures, restore sector=False parameter for call-site compatibility, and add test_btm_solar_credit_reduces_rps_rhs Full-pipeline test (CA-only myopic 2025) confirmed: net_load=97.2 TWh, btm_rooftop=39.80 TWh, adjusted_rhs=21.5 TWh Solver: optimal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
for more information, see https://pre-commit.ci
trevorb1
left a comment
There was a problem hiding this comment.
This looks cool, @WilsonHM18! Thanks for the contribution! Also, thanks for adding tests with your contribution! 🎉 Just a couple comments below
| _SECTOR_ID = "98" | ||
| _FUEL_TYPE = "SUN" | ||
|
|
||
| # All CONUS state abbreviations covered by pypsa-usa |
There was a problem hiding this comment.
Can we reuse the STATE_2_CODE or CODE_2_STATE constant here for this?
There was a problem hiding this comment.
Addressed. The hardcoded list is removed, state filtering is now in _SmallScaleSolarData.format_data() using a 2-character string length check consistent with _ElectricPowerOperationalData
| def _fetch_from_eia(api_key: str, start_year: int, end_year: int) -> pd.DataFrame: | ||
| """Download small-scale solar annual generation from the EIA API v2.""" | ||
| records = [] | ||
| offset = 0 | ||
| page_size = 5000 | ||
|
|
||
| while True: | ||
| params = { | ||
| "api_key": api_key, | ||
| "frequency": "annual", | ||
| "data[0]": "generation", | ||
| "facets[fueltypeid][]": _FUEL_TYPE, | ||
| "facets[sectorid][]": _SECTOR_ID, | ||
| "start": str(start_year), | ||
| "end": str(end_year), | ||
| "sort[0][column]": "period", | ||
| "sort[0][direction]": "asc", | ||
| "offset": offset, | ||
| "length": page_size, | ||
| } | ||
|
|
||
| response = requests.get(_EIA_URL, params=params, timeout=60) | ||
| response.raise_for_status() | ||
| payload = response.json() | ||
|
|
||
| data = payload.get("response", {}).get("data", []) | ||
| if not data: | ||
| break | ||
|
|
||
| records.extend(data) | ||
|
|
||
| total = payload.get("response", {}).get("total", 0) | ||
| offset += page_size | ||
| if offset >= int(total): | ||
| break | ||
|
|
||
| if not records: | ||
| raise ValueError( | ||
| "EIA API returned no small-scale solar data. Check your API key and the date range.", | ||
| ) | ||
|
|
||
| df = pd.DataFrame(records) | ||
| # API returns location as state abbreviation, period as "YYYY" | ||
| 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] | ||
| df = df[df["state"].isin(_STATES)] | ||
| df = df.sort_values(["state", "year"]).reset_index(drop=True) | ||
|
|
||
| logger.info( | ||
| f"Retrieved {len(df)} state-year observations of small-scale solar from EIA API ({start_year}–{end_year}).", | ||
| ) | ||
| return df | ||
|
|
||
|
|
||
| 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 |
There was a problem hiding this comment.
There is a dedicated EIA module for interfacing with its API. Would be great to consolidate EIA API logic there. It is set up so you should just be able to create a new DataExtractor instance and call it under the Production class. If the EIA module logic is not clear though, please let me know!
There was a problem hiding this comment.
To get multiple years you will need to instantiate the class multiple times, which I guess isn't fantastic. Alternatively, maybe implementing something similar to how we get multiple years from the AEO would work here?
There was a problem hiding this comment.
Addressed. Added SmallScaleSolar / _SmallScaleSolarData to eia.py following the EiaData/DataExtractor factory pattern. The year range (start_year, end_year) is fetched in a single API call using build_url, avoiding multiple instantiations.
| start_year = 2014 | ||
| end_year = max(planning_horizons) | ||
| df = _fetch_from_eia(api_key, start_year, end_year) |
There was a problem hiding this comment.
Probably replace with a call to the EIA module with something along the lines of:
import eia
production = eia.Production("rooftop_solar", "", 2014, api_key)
df = production.get_data()But, also open to suggestions!
| 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 | ||
|
|
There was a problem hiding this comment.
The implementation looks good to me (although I haven't tested it)! Im just wondering if this should be a flag option from the config? Like have we checked the different demand sources (EFS, AEO, etc) to ensure rooftop solar isn't accounted for in their projections? If we have, we should ensure that is clear. to the user. Else, just changing this to an optional flag would be good, I think? Or I may just be misunderstanding the implementation! 😅
There was a problem hiding this comment.
Thanks for flagging this. I just added a docstring note clarifying the assumption. Do you know whether EFS and AEO demand profiles in pypsa-usa represent net or gross load? My understanding is that they're all calibrated to metered grid data (net of BTM), which would make the credit appropriate across all demand sources. Happy to add a config flag if you think that's safer given the uncertainty.
…and scope Per review feedback: - Add SmallScaleSolar / _SmallScaleSolarData to eia.py, following the existing EiaData / DataExtractor factory pattern used by all other EIA data classes (ElectricPowerData, EnergyDemand, etc.) - Simplify retrieve_small_scale_solar.py to delegate API calls to the new class; removes duplicated requests logic and the hardcoded _STATES list (state filtering is now handled in _SmallScaleSolarData.format_data, consistent with _ElectricPowerOperationalData) - Expand add_RPS_constraints docstring to explain that the BTM solar credit is only appropriate for EIA 930 net-load profiles (demand.profile: eia) and should be disabled for EFS / AEO gross-load projections Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Rooftop solar (solar-rooftop) was excluded from RPS-eligible carriers and its generation was embedded in net-load data, causing the RPS constraint to be over-tightened.
Changes:
Full-pipeline test confirmed:
net_load=97.2 TWh, btm_rooftop=39.80 TWh, adjusted_rhs=21.5 TWh
Solver: optimal
Checklist
envs/environment.yaml.config.default.yaml.doc/configtables/*.csv.