Skip to content

Add multi-metrics for RA#99

Open
abdelrahman-ayad wants to merge 57 commits into
mainfrom
aa/multi_metrics
Open

Add multi-metrics for RA#99
abdelrahman-ayad wants to merge 57 commits into
mainfrom
aa/multi_metrics

Conversation

@abdelrahman-ayad

@abdelrahman-ayad abdelrahman-ayad commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • This PR expands the use of Resource Adequacy metrics to use a multi-metrics approach. Users can select one or more metric (EUE, LOLH, NEUE, or a combination) with a corresponding threshold to be used in stress periods selection.
  • An upcoming PR will extend the available stress metrics to assess duration, frequency, and magnitude of shortfalls.

Technical details

Implementation notes

  • EUE is no longer used for determining the shoulder periods in function get_houlder_periods(), in case EUE is not defined as a stress metric
  • If EUE is not included as a stress metric, PRAS-informed PRM update is blocked, and fixed-increment update is used, similar to setting GSW_PRM_UpdateMethod=1

Switches added/removed/changed

  • GSw_PRM_StressThreshold -> GSw_PRM_StressThresholdMetrics: This switch now defines the / delimited stress metric switches to be used for evaluation. For each Metric, a dedicated switch is added to define the HierarchyLevel, Criterion, and the PeriodAggMethod. Current tested metrics are EUE, LOLH, and NEUE.

  • GSw_PRM_StressThresholdEUE: Switch to define the EUE stress metric hierarchy, threshold, and PeriodAggMethod.

  • GSw_PRM_StressThresholdLOLH: Switch to define the LOLH stress metric hierarchy, threshold, and PeriodAggMethod.

  • GSw_PRM_StressThresholdNEUE: Switch to define the NEUE stress metric hierarchy, threshold, and PeriodAggMethod.

Metric Switch Definition RA dimension Example
LOLH GSw_PRM_StressThresholdLOLH Expected (average) count of event-hours/year experiencing shortfall. It is expressed in terms of event-hours Frequency transgrp_2.4_sum: The LOLH threshold is set at 2.4 events-hour/year, aggregated using sum over transgrp
EUE GSw_PRM_StressThresholdEUE Expected (average) total energy shortfall over the study period, expressed in energy units (MWh per year) Energy transgrp_10000_sum: The EUE threshold is set at 10000 MWh over the study period, aggregated using sum over transgrp
NEUE GSw_PRM_StressThresholdNEUE Normalized expected (average) total energy shortfall over the study period expressed in parts-per-million (ppm) Energy transgrp_1_sum: The NEUE threshold is set at 1 ppm aggregated using sum over transgrp

Relevant 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

  • Pacific (EUE, LOLH, NEUE, EUE/NEUE/LOLH)
  • USA_defaults (EUE, LOLH, NEUE, EUE/NEUE/LOLH)

Checklist for author

Details to double-check

  • 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 (runreeds.py, reeds/core/solve/solve.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

  • I used Claude to plan the changes in runreeds.py and stress_periods.py

@abdelrahman-ayad abdelrahman-ayad self-assigned this May 20, 2026

Copilot AI left a comment

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.

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_StressThreshold with GSw_PRM_StressThresholdMetrics plus per-metric threshold switches (...EUE, ...LOLE, ...NEUE) and validates these in runreeds.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 pass failed_regions_neue into 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.

Comment thread reeds/resource_adequacy/stress_periods.py
Comment on lines 267 to 299
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')
):
Comment on lines +418 to +425
# 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}
Comment on lines +699 to +703
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')
Comment on lines 637 to 655
@@ -616,7 +655,13 @@
)
Comment thread postprocessing/single_case_plots.py Outdated
Comment thread runreeds.py Outdated
Comment thread postprocessing/compare_cases.py Outdated
Comment thread reeds/reedsplots.py
Comment on lines 4221 to 4225
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)
Comment thread reeds/reedsplots.py
Comment on lines 6636 to 6640
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 patrickbrown4 left a comment

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 cool, thanks! I haven't gone all the way through stress_periods.py yet, but adding some interim comments. My main thoughts are:

  1. I would call it LOLH instead of LOLE if the units are event-hours
  2. 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.)

Comment thread cases.csv Outdated
Comment thread cases.csv Outdated
Comment thread cases.csv Outdated
Comment thread cases.csv Outdated
Comment thread runreeds.py Outdated
Comment thread reeds/resource_adequacy/stress_periods.py Outdated
#%% Write consolidated NEUE so far
#%% Write consolidated stress metrics so far
try:
## TODO: Check if get_and_write_neue() is still needed

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.

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.

('Resource adequacy',
{'file':'neue.csv',
'columns': ['year', 'iteration', 'neue'],
'index': ['year', 'iteration'],
'preprocess': [
{'func': pre_neue, 'args': {}},
],
'presets': collections.OrderedDict((
('Normalized expected unserved energy [%]', {'x':'year', 'y':'neue', 'chart_type':'Bar', 'explode':'scenario', 'bar_width':'1.75'}),
)),
}
),

{'name': 'NEUE (ppm)', 'result': 'Resource adequacy', 'preset': 'Normalized expected unserved energy [%]'},

All the 'NEUE (ppm)' keys in reedsplots.plot_diff() like

'NEUE (ppm)': 'NEUE',

ReEDS/reeds/reedsplots.py

Lines 486 to 490 in ce7c0d5

if val == 'NEUE (ppm)':
neue_threshold = float(sw.GSw_PRM_StressThresholdNEUE.split('_')[1])
ymax = max(ymax, 10, neue_threshold*1.05)
ax[0].axhline(neue_threshold, c='C7', ls='--', lw=0.75)
ax[1].axhline(neue_threshold, c='C7', ls='--', lw=0.75)

Comment thread postprocessing/compare_cases.py Outdated
Comment thread reeds/resource_adequacy/stress_periods.py Outdated
Comment thread reeds/resource_adequacy/stress_periods.py Outdated

@patrickbrown4 patrickbrown4 left a comment

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.

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?

Comment thread cases.csv Outdated
Comment thread reeds/resource_adequacy/stress_periods.py
Comment thread reeds/resource_adequacy/stress_periods.py Outdated
Comment thread reeds/resource_adequacy/stress_periods.py Outdated
Comment on lines 276 to -292
@@ -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)))

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.

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).

Comment thread cases.csv Outdated
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,

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.

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?

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.

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.

Comment thread reeds/resource_adequacy/stress_periods.py Outdated
Comment thread postprocessing/single_case_plots.py Outdated
Comment thread reeds/reedsplots.py Outdated
Comment thread reeds/reedsplots.py Outdated
@abdelrahman-ayad abdelrahman-ayad mentioned this pull request Jun 18, 2026
13 tasks
@patrickbrown4 patrickbrown4 self-requested a review June 18, 2026 15:31
@bsergi bsergi mentioned this pull request Jun 18, 2026
20 tasks
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.

3 participants