Skip to content

fix(rps): credit behind-the-meter rooftop solar against RPS targets - #773

Open
WilsonHM18 wants to merge 9 commits into
PyPSA:developfrom
WilsonHM18:fix/rps-btm-rooftop-solar
Open

fix(rps): credit behind-the-meter rooftop solar against RPS targets#773
WilsonHM18 wants to merge 9 commits into
PyPSA:developfrom
WilsonHM18:fix/rps-btm-rooftop-solar

Conversation

@WilsonHM18

Copy link
Copy Markdown
Collaborator

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:

  • 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-penalized
  • 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 confirmed:
net_load=97.2 TWh, btm_rooftop=39.80 TWh, adjusted_rhs=21.5 TWh
Solver: optimal

Checklist

  • [X ] I tested my contribution locally and it seems to work fine.
  • Code and workflow changes are sufficiently documented.
  • [NA] Changed dependencies are added to envs/environment.yaml.
  • [NA] Changes in configuration options are added in all of config.default.yaml.
  • [NA] Changes in configuration options are also documented in doc/configtables/*.csv.

trevorb1 and others added 7 commits February 10, 2026 11:17
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>

@trevorb1 trevorb1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reuse the STATE_2_CODE or CODE_2_STATE constant here for this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. The hardcoded list is removed, state filtering is now in _SmallScaleSolarData.format_data() using a 2-character string length check consistent with _ElectricPowerOperationalData

Comment on lines +104 to +171
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +189 to +191
start_year = 2014
end_year = max(planning_horizons)
df = _fetch_from_eia(api_key, start_year, end_year)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment on lines +278 to +288
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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! 😅

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

WilsonHM18 and others added 2 commits August 14, 2026 11:02
…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>
@ktehranchi
ktehranchi changed the base branch from master to develop August 23, 2026 19:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants