Skip to content

Derate thermal capacity reserve margin contribution with FOR#85

Open
clairehalloran wants to merge 13 commits into
mainfrom
ch/firm-derate
Open

Derate thermal capacity reserve margin contribution with FOR#85
clairehalloran wants to merge 13 commits into
mainfrom
ch/firm-derate

Conversation

@clairehalloran
Copy link
Copy Markdown
Collaborator

@clairehalloran clairehalloran commented May 8, 2026

Summary

This PR derates the contribution of thermal capacity to the reserve margin based on technology-specific mean forced outage rates (FORs) during the top net peak load hours during each ccseason. This change only applies when the capacity credit resource adequacy method is used, i.e. GSw_PRM_CapCredit=1. This update does not change capacity credit for VRE, hydro, or storage.

Technical details

Implementation notes

For each thermal technology, the mean FOR during the top net peak load hours is calculated for each ccseason in resource_adequacy/capacity_credit.py based on the same hourly FORs used in PRAS. The mean FOR for each technology is written to the handoff_{t}.gdx file for each year that resource adequacy calculations are run. The capacity of thermal generation is multiplied by (1 - FOR) in eq_reserve_margin.

Additional changes

  • Added ValueError in runreeds.py to disallow GSw_PRM_UpdateMethod=0,GSw_PRM_CapCredit=1, and GSw_PRM_StressIterateMax>0, which iterates to reach an NEUE threshold without updating the PRM values between iterations.
  • In the Pacific_CC test case, updated GSw_PRM_UpdateMethod from the default of 0 to 1 to avoid raising this ValueError.

Validation, testing, and comparison report(s)

I ran the USA_fast test case with the following switches:

  • GSw_PRM_CapCredit=1
  • GSw_PRM_UpdateMethod=1
  • GSw_PRM_scenario=0.1
  • GSw_PRM_UpdateFraction=0.05
  • GSw_PRM_StressIterateMax=20

Derating thermal capacity increased total capacity in most years, especially gas-CT:
image

Derating thermal capacity leads to lower national NEUE values (3.3 ppm vs. 1.2 ppm in 2050):
image

Derating also led to lower PRM values except in PJM-East, which had a 80% PRM with derating in 2050:

PRM with derate:
image

PRM without derate:
image

This feature increased runtime by 4 hours due to increased capacity_credit.py runtime:
image

Full USA_fast_CC comparison here: results-main,derate.pptx

No change in capacity for USA_defaults:
image

Full USA_defaults comparison here: results-USA_defaults_main,USA_defaults_firm-FOR-derate.pptx

Checklist for author

Details to double-check

  • Charge code provided to reviewers
  • Included comparison reports for appropriate test cases
  • Documentation updated if necessary
  • Code formatting standardized
  • Reusable functions used where possible instead of copy/pasted code

General information to guide review

  • Zero impact on results of default case
  • No large data file(s) added/modified
  • No substantive impact on runtime for full-US reference case
  • No substantive impact on folder size for full-US reference case
  • No change to process flow (runbatch.py, d_solve_iterate.py)
  • No change to code organization
  • No change to package requirements (environment.yml or Project.toml)

Did you use LLM tools (chatbot or copilot) in the preparation of this PR? If so, describe how

Yes, as an alternative to looking up pandas documentation and for minor line completion.

@clairehalloran clairehalloran added enhancement New feature or request and removed docs labels May 8, 2026
Copy link
Copy Markdown
Contributor

@patrickbrown4 patrickbrown4 left a comment

Choose a reason for hiding this comment

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

This is a nice addition, thanks!

My main comment is that I think the capacity_credit.py logic could be sped up by avoiding the reshaping of the hourly outage-rate dataframe. I added some snippets to get started but happy to chat if easier.

# drop techs that won't use this capacity credit before stacking
techs_to_drop = ['battery_li','can-imports','distpv', 'electrolyzer',
'hydd', 'hyded', 'hydend', 'hydnd', 'hydnpd', 'hydnpnd',
'hydro', 'hydsd', 'hydsnd', 'hydud', 'hydund', 'pumped-hydro']
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Whenever possible, we'd like to avoid hard-coding technology names within Python/GAMS scripts, as it increases the number of places that need to be modified when technologies are added/changed. (I know we do it now but we'd at least like to avoid adding it in new places.) Is there a tech group (or groups) in inputs/tech-subset-table.csv (available from reeds.techs.get_tech_subset_table() or like this if techs need to be expanded) you could use instead? If not, you could add a tech group and use it here.

Comment on lines +165 to +175
forced_outage_rate = (forced_outage_rate
.stack(level=1)
.reset_index()
.rename(columns={"level_0": "timestamp"})
)

forced_outage_rate = pd.melt(
forced_outage_rate,
id_vars=['timestamp','r'],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In general it's best to avoid reshaping hourly dataframes with thousands of columns and multiple weather years, as it can be super slow/memory-intensive. It looks like you could instead:

  1. Keep forced_outage_rate in its shape after the .drop()
  2. Turn top_net_load_hours into a dictionary of timestamps:
    hours = top_net_load_hours.groupby(['ccseason', 'r'])['*timestamp'].agg(list)
    mean_forced_outage_rate = pd.concat({
        (ccseason, r): forced_outage_rate.loc[h].xs(r, 1, 'r').mean()
        for (ccseason, r), h in hours.items()
    }, names=('ccseason', 'r'))

There might need to be a few other adjustments but the general idea is to keep the forced outage rates in the original hours x (techs, regions) format since it's much faster to query the data you need than to reshape the whole thing.

Could you give that a shot and see if it speeds up capacity_credit.py? Happy to chat about it if I overlooked something.

'hydd', 'hyded', 'hydend', 'hydnd', 'hydnpd', 'hydnpnd',
'hydro', 'hydsd', 'hydsnd', 'hydud', 'hydund', 'pumped-hydro']

forced_outage_rate = forced_outage_rate.drop(columns=techs_to_drop, errors='ignore')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
forced_outage_rate = forced_outage_rate.drop(columns=techs_to_drop, errors='ignore')
forced_outage_rate = forced_outage_rate.drop(columns=techs_to_drop, level=0, errors='ignore')

top_net_load_hours['h'] = top_net_load_hours['actual_h']

# join top hours to outage rates based on timestamp and region
forced_outage_rate_top_hours = top_net_load_hours.merge(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you explain the reasoning for using net load instead of load (since net load is a VRE concept but here we're looking at non-VRE)?

For context, the RHS of eq_reserve_margin is peak rather than net peak. (Of course, that's one of the weird things about capacity credit, that we use peak for some things and net peak for others.)

Comment on lines +169 to +180
if (
int(sw['GSw_PRM_CapCredit']) == 0
and ('user' not in sw['GSw_PRM_StressModel'].lower())
):
print('identifying new stress periods...')
tic = datetime.datetime.now()
elif (
int(sw['GSw_PRM_CapCredit']) and (int(sw['GSw_PRM_StressIterateMax']) > 0)
):
print('updating PRM values...')
tic = datetime.datetime.now()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Kind of a lot of logic just for a status message, and stress_periods.py prints its own messages, so personally I would just cut it:

Suggested change
if (
int(sw['GSw_PRM_CapCredit']) == 0
and ('user' not in sw['GSw_PRM_StressModel'].lower())
):
print('identifying new stress periods...')
tic = datetime.datetime.now()
elif (
int(sw['GSw_PRM_CapCredit']) and (int(sw['GSw_PRM_StressIterateMax']) > 0)
):
print('updating PRM values...')
tic = datetime.datetime.now()
tic = datetime.datetime.now()

Comment thread cases_test.csv
GSw_FakeData,,,,,,1,1,1,,,,,,,,,,,,,,,,,,,,,,
GSw_PRM_CapCredit,,,,,,,,1,1,,,,,,,,1,,,,,,,,,,,,,
GSw_PRM_scenario,,,,,,,,,static,,,,,,,,static,,,,,,,,,,,,,
GSw_PRM_UpdateMethod,,,,,,,,,1,,,,,,,,,,,,,,,,,,,,,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is for Pacific_CC, right? It will slow down the test but I agree it's good to have to demonstrate best practice with capacity credit

Comment thread runreeds.py
Comment on lines +391 to +396
"The combination of GSw_PRM_UpdateMethod=0, GSw_PRM_CapCredit=1, "
"and GSw_PRM_StressIterateMax>0 is not supported."
"To iteratively update the PRM, set GSw_PRM_UpdateMethod to one of: "
"(1) static update set by GSw_PRM_UpdateFraction; "
"(2) dynamic update informed by PRAS; "
"(3) dynamic update but only after all new stress periods have been added"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
"The combination of GSw_PRM_UpdateMethod=0, GSw_PRM_CapCredit=1, "
"and GSw_PRM_StressIterateMax>0 is not supported."
"To iteratively update the PRM, set GSw_PRM_UpdateMethod to one of: "
"(1) static update set by GSw_PRM_UpdateFraction; "
"(2) dynamic update informed by PRAS; "
"(3) dynamic update but only after all new stress periods have been added"
"The combination of GSw_PRM_UpdateMethod=0, GSw_PRM_CapCredit=1, "
"and GSw_PRM_StressIterateMax>0 is not supported.\n"
"To iteratively update the PRM, set GSw_PRM_UpdateMethod to an integer between 1-3:"
"\n1: static update set by GSw_PRM_UpdateFraction; "
"\n2: dynamic update informed by PRAS; "
"\n3: dynamic update but only after all new stress periods have been added"


#### Thermal generation capacity credit

For thermal generators (i.e. combined cycle, combustion turbine, nuclear (conventional and SMR), and steam (coal)), ReEDS estimates a seasonal capacity credit for each region/technology combination based on temperature-dependent forced outage rates (described in detail in [outage rates](#outage-rates)). To calculate each technology's contribution to the seasonal reserve margin, its nameplate capacity for each technology is multiplied by $(1 - \bar{FOR})$, where $\bar{FOR}$ is the mean forced outage rate during the top 20 net load hours cross all modeled [weather years](#weather-years) for each season.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
For thermal generators (i.e. combined cycle, combustion turbine, nuclear (conventional and SMR), and steam (coal)), ReEDS estimates a seasonal capacity credit for each region/technology combination based on temperature-dependent forced outage rates (described in detail in [outage rates](#outage-rates)). To calculate each technology's contribution to the seasonal reserve margin, its nameplate capacity for each technology is multiplied by $(1 - \bar{FOR})$, where $\bar{FOR}$ is the mean forced outage rate during the top 20 net load hours cross all modeled [weather years](#weather-years) for each season.
For thermal generators (i.e. combined cycle, combustion turbine, nuclear (conventional and SMR), and steam (coal)), ReEDS estimates a seasonal capacity credit for each region/technology combination based on temperature-dependent forced outage rates (described in detail in [outage rates](#outage-rates)). To calculate each technology's contribution to the seasonal reserve margin, its nameplate capacity is multiplied by $(1 - \bar{FOR})$, where $\bar{FOR}$ is the mean forced outage rate during the top 20 net load hours cross all modeled [weather years](#weather-years) for each season.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants