Derate thermal capacity reserve margin contribution with FOR#85
Derate thermal capacity reserve margin contribution with FOR#85clairehalloran wants to merge 13 commits into
Conversation
patrickbrown4
left a comment
There was a problem hiding this comment.
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'] |
There was a problem hiding this comment.
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.
| 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'], | ||
| ) | ||
|
|
There was a problem hiding this comment.
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:
- Keep
forced_outage_ratein its shape after the.drop() - Turn
top_net_load_hoursinto 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') |
There was a problem hiding this comment.
| 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( |
There was a problem hiding this comment.
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.)
| 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() | ||
|
|
There was a problem hiding this comment.
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:
| 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() |
| GSw_FakeData,,,,,,1,1,1,,,,,,,,,,,,,,,,,,,,,, | ||
| GSw_PRM_CapCredit,,,,,,,,1,1,,,,,,,,1,,,,,,,,,,,,, | ||
| GSw_PRM_scenario,,,,,,,,,static,,,,,,,,static,,,,,,,,,,,,, | ||
| GSw_PRM_UpdateMethod,,,,,,,,,1,,,,,,,,,,,,,,,,,,,,, |
There was a problem hiding this comment.
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
| "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" |
There was a problem hiding this comment.
| "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. |
There was a problem hiding this comment.
| 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. |
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.pybased on the same hourly FORs used in PRAS. The mean FOR for each technology is written to thehandoff_{t}.gdxfile for each year that resource adequacy calculations are run. The capacity of thermal generation is multiplied by(1 - FOR)ineq_reserve_margin.Additional changes
runreeds.pyto disallowGSw_PRM_UpdateMethod=0,GSw_PRM_CapCredit=1, andGSw_PRM_StressIterateMax>0, which iterates to reach an NEUE threshold without updating the PRM values between iterations.GSw_PRM_UpdateMethodfrom the default of0to1to avoid raising this ValueError.Validation, testing, and comparison report(s)
I ran the USA_fast test case with the following switches:
GSw_PRM_CapCredit=1GSw_PRM_UpdateMethod=1GSw_PRM_scenario=0.1GSw_PRM_UpdateFraction=0.05GSw_PRM_StressIterateMax=20Derating thermal capacity increased total capacity in most years, especially gas-CT:

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

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

PRM without derate:

This feature increased runtime by 4 hours due to increased

capacity_credit.pyruntime:Full USA_fast_CC comparison here: results-main,derate.pptx
No change in capacity for USA_defaults:

Full USA_defaults comparison here: results-USA_defaults_main,USA_defaults_firm-FOR-derate.pptx
Checklist for author
Details to double-check
General information to guide review
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.