Add multi-metrics for RA#99
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends the Resource Adequacy (RA) stress-period selection workflow from a single-metric approach to a configurable multi-metric approach (EUE/LOLE/NEUE), with new switches to control which metrics and thresholds are applied.
Changes:
- Replaces
GSw_PRM_StressThresholdwithGSw_PRM_StressThresholdMetricsplus per-metric threshold switches (...EUE,...LOLE,...NEUE) and validates these inrunreeds.py. - Generalizes stress-period selection and annual metric writing logic to operate across multiple stress metrics in
reeds/resource_adequacy/stress_periods.py. - Updates plotting and postprocessing utilities to consume the new NEUE output filenames/columns and new threshold switches.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| runreeds.py | Validates new multi-metric stress threshold switch configuration. |
| reeds/resource_adequacy/stress_periods.py | Implements multi-metric annual metric output and stress-period selection; updates PRM update gating. |
| reeds/resource_adequacy/run_pras.jl | Updates PRAS summary logging to include NEUE via PRAS API. |
| reeds/reedsplots.py | Updates plotting utilities to read NEUE_*.csv and new switch names/structures. |
| postprocessing/single_case_plots.py | Updates stress-period evolution plot parameter parsing for new switches. |
| postprocessing/compare_cases.py | Updates NEUE file discovery/parsing to match new output filenames. |
| cases.csv | Adds new metric/threshhold switches and updates stress-iteration description. |
Comments suppressed due to low confidence (1)
reeds/resource_adequacy/stress_periods.py:675
- After introducing
failed_regions_neue(NEUE-only) for PRAS-informed updates, the PRAS-informed branch should passfailed_regions_neueinto prm_increment_pras(), and should fall back to fixed-increment (or raise) when there is no NEUE criterion available. Otherwise LOLE/EUE thresholds can be incorrectly treated as NEUE ppm targets inside prm_increment_pras().
## Fixed-increment update
if int(sw.GSw_PRM_UpdateMethod) == 1 or 'EUE' not in stress_metrics:
if int(sw.GSw_PRM_UpdateMethod) > 1:
print(
f"Warning: GSw_PRM_UpdateMethod is set to {sw.GSw_PRM_UpdateMethod}, "
"but EUE is not included in GSw_PRM_StressThresholdMetrics, so defaulting to fixed increment. "
"Add EUE to GSw_PRM_StressThresholdMetrics to use PRAS-informed update method."
)
prm_increment = failed_regions.copy()
prm_increment['fraction'] = float(sw['GSw_PRM_UpdateFraction'])
## PRAS-informed PRM update
else:
prm_increment = prm_increment_pras(
sw,
t,
iteration,
combined_periods_write,
failed_regions,
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| cutofftype, cutoff = sw.GSw_PRM_StressStorageCutoff.lower().split('_') | ||
| periodhours = {'day':24, 'wek':24*5, 'year':24}[sw.GSw_HourlyType] | ||
| (hierarchy_level, ppm, stress_metric, period_agg_method) = criterion.split('_') | ||
| (hierarchy_level, stress_level, stress_metric, period_agg_method) = criterion.split('_') | ||
|
|
||
| ## Aggregate storage energy to hierarchy_level | ||
| dfenergy_agg = ( | ||
| dfenergy_r.rename(columns=hierarchy[hierarchy_level]) | ||
| .groupby(axis=1, level=0).sum() | ||
| ) | ||
| dfheadspace_MWh = dfenergy_agg.max() - dfenergy_agg | ||
| dfheadspace_frac = dfheadspace_MWh / dfenergy_agg.max() | ||
|
|
||
| shoulder_periods = {} | ||
| for i, row in high_eue_periods[criterion, f'high_{stress_metric}'].iterrows(): | ||
| if row.r not in dfheadspace_MWh: | ||
| continue | ||
|
|
||
| day = pd.Timestamp('-'.join(row[['y','m','d']].astype(str).tolist())) | ||
|
|
||
| start_headspace_MWh = dfheadspace_MWh.loc[day.strftime('%Y-%m-%d'),row.r].iloc[0] | ||
| end_headspace_MWh = dfheadspace_MWh.loc[day.strftime('%Y-%m-%d'),row.r].iloc[-1] | ||
|
|
||
| start_headspace_frac = dfheadspace_frac.loc[day.strftime('%Y-%m-%d'),row.r].iloc[0] | ||
| end_headspace_frac = dfheadspace_frac.loc[day.strftime('%Y-%m-%d'),row.r].iloc[-1] | ||
|
|
||
| day_eue = high_eue_periods[criterion, f'high_{stress_metric}'].loc[i,'EUE'] | ||
| day_index = np.where( | ||
| timeindex == dfenergy_agg.loc[day.strftime('%Y-%m-%d')].iloc[0].name | ||
| )[0][0] | ||
|
|
||
| day_before = timeindex[day_index - periodhours] | ||
| day_after = timeindex[(day_index + periodhours) % len(timeindex)] | ||
|
|
||
| if ( | ||
| ((cutofftype == 'eue') and (end_headspace_MWh / day_eue >= float(cutoff))) | ||
| or ((cutofftype[:3] == 'cap') and (end_headspace_frac >= float(cutoff))) | ||
| (cutofftype[:3] == 'cap') and (end_headspace_frac >= float(cutoff)) | ||
| or (cutofftype[:3] == 'abs') | ||
| ): |
| # Validation check: Display any GSw_PRM_StressThreshold{metric} | ||
| # that is not specified in GSw_PRM_StressThresholdMetrics | ||
| stress_metric_switches = sw.GSw_PRM_StressThresholdMetrics.split('/') | ||
|
|
||
| # stress periods column names for writing outputs | ||
| stress_metrics_units = {'EUE':'MWh', 'NEUE':'ppm', 'LOLE':'days'} | ||
| stress_metrics_col_names = {m:f'{m}_{stress_metrics_units[m]}' for m in | ||
| stress_metric_switches} |
| stress_metrics = sw.GSw_PRM_StressThresholdMetrics.split('/') | ||
| ## TODO: check if need to refactor or remove | ||
| # Include NEUE if not already specified, used for plots - only used for writing files | ||
| if 'NEUE' not in stress_metrics: | ||
| stress_metrics.append('NEUE') |
| @@ -616,7 +655,13 @@ | |||
| ) | |||
| year2iteration = ( | ||
| pd.DataFrame([ | ||
| os.path.basename(i).strip('neue_.csv').split('i') | ||
| for i in sorted(glob(os.path.join(case, 'outputs', 'neue_*.csv'))) | ||
| os.path.basename(i).strip('NEUE_.csv').split('i') | ||
| for i in sorted(glob(os.path.join(case, 'outputs', 'NEUE_*.csv'))) | ||
| ], columns=['year','iteration']).astype(int) |
| year2iteration = ( | ||
| pd.DataFrame([ | ||
| os.path.basename(i).strip('neue_.csv').split('i') | ||
| for i in sorted(glob(os.path.join(case, 'outputs', 'neue_*.csv'))) | ||
| os.path.basename(i).strip('NEUE_.csv').split('i') | ||
| for i in sorted(glob(os.path.join(case, 'outputs', 'NEUE_*.csv'))) | ||
| ], columns=['year','iteration']).astype(int) |
patrickbrown4
left a comment
There was a problem hiding this comment.
This is cool, thanks! I haven't gone all the way through stress_periods.py yet, but adding some interim comments. My main thoughts are:
- I would call it LOLH instead of LOLE if the units are event-hours
- I think it would simplify the processing to drop the third argument of the compound
GSw_PRM_StressThreshold{LOLE|EUE|NEUE}switches; instead of leaving it up to the user, NEUE- and EUE-based thresholds would always add stress periods with the highest EUE, and the LOLH-based threshold would always add stress periods with the highest LOLH. (When we add the depth-based metric, it would always add stress periods with the highest peak outage MW.)
| #%% Write consolidated NEUE so far | ||
| #%% Write consolidated stress metrics so far | ||
| try: | ||
| ## TODO: Check if get_and_write_neue() is still needed |
There was a problem hiding this comment.
We don't use the national NEUE within the model so it can be removed (sorry for all the cruft). You would also remove the following blocks. It's kind of a lot though, and it's not directly related to this PR, so it might be best to save for a followup cleanup PR.
ReEDS/postprocessing/bokehpivot/reeds2.py
Lines 2764 to 2775 in ce7c0d5
ReEDS/postprocessing/compare_cases.py
Line 196 in ce7c0d5
All the 'NEUE (ppm)' keys in reedsplots.plot_diff() like
Line 117 in ce7c0d5
Lines 486 to 490 in ce7c0d5
patrickbrown4
left a comment
There was a problem hiding this comment.
Thanks, happy to discuss any of these if helpful (particularly the one about dropping GSw_PRM_StressThresholdEUE). Could you re-request a review when I should take another look?
| @@ -288,8 +263,7 @@ | |||
| day_after = timeindex[(day_index + periodhours) % len(timeindex)] | |||
|
|
|||
| if ( | |||
| ((cutofftype == 'eue') and (end_headspace_MWh / day_eue >= float(cutoff))) | |||
| or ((cutofftype[:3] == 'cap') and (end_headspace_frac >= float(cutoff))) | |||
There was a problem hiding this comment.
The default GSw_PRM_StressStorageCutoff is still EUE_0.1, which looks like it means the default shoulder-period functionality would be removed with this change.
I think we should keep the original EUE formulation in get_shoulder_periods(). It should apply for both GSw_PRM_StressThresholdEUE and GSw_PRM_StressThresholdNEUE (both of which use EUE to select stress periods).
I'm not sure if it should apply to GSw_PRM_StressThresholdLOLH-identified periods. If it does, I think it's fine to still use EUE to identify those shoulder periods. If that complicates things, you can just drop the shoulder functionality for LOLH-identified periods (but keep it for the NEUE-/EUE-identified periods).
| GSw_PRM_StressThreshold,/-delimited list of annual NEUE level [ppm] above which to re-solve the latest model year with new stress periods; formulated as HierarchyLevel_NEUEppm_StressMetric_PeriodAggMethod where HierarchyLevel is a column in hierarchy.csv; NEUEppm is normalized expected unserved energy in parts per million; StressMetric is EUE or NEUE (only used in period selection); PeriodAggMethod is 'sum' or 'max' over the hours in each period (only used in period selection) (see README.md for detailed notes),N/A,transgrp_1_EUE_sum, | ||
| GSw_PRM_StressThresholdMetrics,"/-delimited list of metrics for identifying stress periods (supported options are: NEUE, EUE, LOLH)",N/A,NEUE, | ||
| GSw_PRM_StressThresholdLOLH,LOLH threshold [hours/year] above which to re-solve the latest model year with new stress periods; formulated as HierarchyLevel_LOLH_StressMetric_PeriodAggMethod where HierarchyLevel is a column in hierarchy.csv; LOLH is number of loss of load event-hours; StressMetric is LOLH; PeriodAggMethod is 'sum' or 'max' over the hours in each period (only used in period selection) (see README.md for detailed notes),N/A,transgrp_36_sum, | ||
| GSw_PRM_StressThresholdEUE,Annual EUE level [MWh] threshold above which to re-solve the latest model year with new stress periods; formulated as HierarchyLevel_EUE_StressMetric_PeriodAggMethod where HierarchyLevel is a column in hierarchy.csv; EUE is expected unserved energy; StressMetric EUE (only used in period selection); PeriodAggMethod is 'sum' or 'max' over the hours in each period (only used in period selection) (see README.md for detailed notes),N/A,transgrp_10000_sum, |
There was a problem hiding this comment.
Can you remind me, do any of the regions you reviewed formulate their RA target as EUE [MWh] instead of NEUE [ppm]? EUE MWh would be a bit of a weird threshold in the context of load growth, and a single number wouldn't really make sense to apply across different transgrp regions with different loads (as is specified in the default value).
So I guess my feeling is that it might be simpler just to drop GSw_PRM_StressThresholdEUE and stick with NEUE and LOLH as the two options. What do you think?
There was a problem hiding this comment.
Some regions (e.g., SPP had EUE seasonal targets and The Tri-State Agency had <0.5 GWh, and ERCOT (magnitude) of 19 GW). Nevertheless, I agree that NEUE makes it easier to implement across multiple regions with different load profiles etc. I'll remove the EUE for now and we can always return it in a future update.
Summary
Technical details
Implementation notes
EUEis no longer used for determining the shoulder periods in functionget_houlder_periods(), in caseEUEis not defined as a stress metricEUEis not included as a stress metric, PRAS-informed PRM update is blocked, and fixed-increment update is used, similar to settingGSW_PRM_UpdateMethod=1Switches added/removed/changed
GSw_PRM_StressThreshold->GSw_PRM_StressThresholdMetrics: This switch now defines the/delimited stress metric switches to be used for evaluation. For eachMetric, a dedicated switch is added to define theHierarchyLevel,Criterion, and thePeriodAggMethod. Current tested metrics areEUE,LOLH, andNEUE.GSw_PRM_StressThresholdEUE: Switch to define theEUEstress metric hierarchy, threshold, and PeriodAggMethod.GSw_PRM_StressThresholdLOLH: Switch to define theLOLHstress metric hierarchy, threshold, and PeriodAggMethod.GSw_PRM_StressThresholdNEUE: Switch to define theNEUEstress metric hierarchy, threshold, and PeriodAggMethod.LOLHGSw_PRM_StressThresholdLOLHtransgrp_2.4_sum: TheLOLHthreshold is set at 2.4 events-hour/year, aggregated usingsumovertransgrpEUEGSw_PRM_StressThresholdEUEtransgrp_10000_sum: TheEUEthreshold is set at 10000 MWh over the study period, aggregated usingsumovertransgrpNEUEGSw_PRM_StressThresholdNEUEtransgrp_1_sum: TheNEUEthreshold is set at 1 ppm aggregated usingsumovertransgrpRelevant sources or documentation
Slides deck reviewing Resource Adequacy multi-metrics:
RA multi metrics slides deck
Validation, testing, and comparison report(s)
Comparison report
To check
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
runreeds.pyandstress_periods.py