Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
45b2ffd
Add CVAR and NCVAR resource adequacy reporting
Jul 24, 2026
3b05710
revert runreeds.py
bsergi Aug 10, 2026
8e761fc
Merge remote-tracking branch 'origin/main' into bs/cvar
bsergi Aug 10, 2026
7e560cd
revert cases_test.csv
bsergi Aug 10, 2026
3a7626c
remove CVAR from GSw_PRM_StressThresholdMetrics
bsergi Aug 10, 2026
48f41eb
Always calculate CVAR metric
bsergi Aug 10, 2026
c5995a9
revert back to new groupby syntax
bsergi Aug 10, 2026
17089a7
move up CVAR switch compatability check
bsergi Aug 10, 2026
3735a61
change to GSw_PRM_CVARalpha
bsergi Aug 10, 2026
91730d2
Missed a change to GSw_PRM_CVARalpha
bsergi Aug 10, 2026
081086f
Remove Julia-side CVAR dependency
Aug 27, 2026
fad7283
Merge remote-tracking branch 'origin/main' into bc_cvar_review_fixes
Aug 27, 2026
6a95ef2
Restore pandas 3 hourly frequency syntax
Aug 29, 2026
aed6bb4
Merge remote-tracking branch 'origin/main' into bc_cvar_review_fixes
Sep 10, 2026
393be97
Remove trailing whitespace in runreeds.py
Sep 10, 2026
72d5e9a
Merge remote-tracking branch 'origin/main' into bc_cvar_review_fixes
bcakire Oct 19, 2026
9ec3c00
Update PRAS, PRASCore, PRASFiles compat to 0.9
bcakire Oct 19, 2026
24ddcd7
Read per-sample shortfall totals from PRAS 0.9 Shortfall; request Sho…
bcakire Oct 19, 2026
9a14001
Apply suggestion from @patrickbrown4
bcakire Oct 19, 2026
aa67d4d
Merge remote-tracking branch 'origin/main' into bc_cvar_review_fixes
bcakire Oct 21, 2026
c516865
Merge remote-tracking branch 'origin/main' into bc_cvar_review_fixes
bcakire Oct 25, 2026
e86cc9e
Merge remote-tracking branch 'origin/main' into bc_cvar_review_fixes
bcakire Oct 27, 2026
905c90d
Revert unrelated whitespace and hourly shortfall writer changes
bcakire Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cases.csv
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ GSw_PRM_StressThresholdLOLD,LOLD threshold [event-days/year]; formulated as Hier
GSw_PRM_StressThresholdLOLE,LOLE threshold [events/year]; formulated as HierarchyLevel_LOLE where HierarchyLevel is a column in hierarchy.csv; events is loss-of-load events per year,N/A,transgrp_0.1,
GSw_PRM_StressThresholdLOLH,LOLH threshold [event-hours/year]; formulated as HierarchyLevel_LOLH where HierarchyLevel is a column in hierarchy.csv; LOLH is loss-of-load event-hours per year,N/A,transgrp_2.4,
GSw_PRM_StressThresholdNEUE,NEUE threshold [ppm]; formulated as HierarchyLevel_NEUE where HierarchyLevel is a column in hierarchy.csv; NEUEppm is normalized expected unserved energy in parts per million,N/A,transgrp_1,
GSw_PRM_CVARalpha,Alpha value used for CVAR/NCVAR calculations,float,0.95,

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
GSw_PRM_CVARalpha,Alpha value used for CVAR/NCVAR calculations,float,0.95,
GSw_PRM_CVARalpha,[fraction] Alpha value (percentile (expressed as a fraction) above which to average the tail; 0.95 means the 95th percentile) used for CVAR/NCVAR calculations,float,0.95,

GSw_PRM_UpdateFraction,Fraction to add to the PRM if a region fails RA threshold (only used if GSw_PRM_UpdateMethod = 1),float,0.02,
GSw_PRM_UpdateMethod,Option to update PRM: (0) no update; (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,0; 1; 2; 3,0,
GSw_PRMTRADE_level,hierarchy level within which to allow PRM trading,r; nercr; transreg; transgrp; cendiv; st; interconnect; country; usda_region,country,
Expand Down
2 changes: 2 additions & 0 deletions reeds/resource_adequacy/ra_calcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def run_pras(
write_surplus=False,
write_energy=False,
write_shortfall_samples=False,
write_shortfall_samples_totals=True,

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.

It sounds like PRAS.ShortfallSamples() increases memory use within PRAS, but NatLabRockies/PRAS#109 fixes it. Can we check the PRAS memory use on a full-US run on this branch and on main before merging (I think Kodi has done this before)? If it goes up a lot, we should probably wait to merge this PR until we can switch to a new PRAS release that includes this PRAS PR.

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.

It seems like that PRAS PR has been merged, but I'd still be interested in a check on the memory usage before merging.

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.

CVAR no longer uses ShortfallSamples; see the memory comparison above

write_availability_samples=False,
**kwargs,
):
Expand Down Expand Up @@ -68,6 +69,7 @@ def run_pras(
f"--write_surplus={int(write_surplus)}",
f"--write_energy={int(write_energy)}",
f"--write_shortfall_samples={int(write_shortfall_samples)}",
f"--write_shortfall_samples_totals={int(write_shortfall_samples_totals)}",
f"--write_availability_samples={int(write_availability_samples)}",
f"--iteration={iteration}",
f"--samples={sw['pras_samples']}",
Expand Down
29 changes: 28 additions & 1 deletion reeds/resource_adequacy/run_pras.jl
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ function parse_commandline()
default = 0
required = false
"--write_shortfall_samples"
help = "Write the sample-level shortfall"
help = "Write per-sample hourly shortfall by region"
arg_type = Int
default = 0
required = false
"--write_shortfall_samples_totals"
help = "Write per-sample total shortfall by region over the full PRAS time period"
arg_type = Int
default = 0

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.

Since the default in ra_calcs.py is True, let's set this default to True as well (it won't change the behavior so you don't need to rerun the tests, but it makes it marginally clearer what the actual default behavior is)

Suggested change
default = 0
default = 1

required = false
Expand Down Expand Up @@ -182,6 +187,9 @@ function run_pras(pras_system_path::String, args::Dict)
if args["write_energy"] == 1
resultspec["energy"] = PRAS.StorageEnergy()
end
## Hourly per-sample shortfall (region x hour x sample) is memory-heavy, so only
## request it when the hourly samples are written. Per-sample totals (used for CVaR)
## come from the regular Shortfall result (PRAS >= 0.9).
if args["write_shortfall_samples"] == 1
resultspec["short_samples"] = PRAS.ShortfallSamples()
end
Expand Down Expand Up @@ -327,6 +335,24 @@ function run_pras(pras_system_path::String, args::Dict)
@info("Wrote PRAS shortfall by sample to $(shortfile)")
end

### Per-sample total shortfall by region over the full PRAS time period, needed for CVaR
if args["write_shortfall_samples_totals"] == 1
## Read the per-sample totals (MWh) from the regular Shortfall result (PRAS >= 0.9).
sf = results["short"]
## Use filtered system regions to avoid DC converter pseudo-regions without load.
region_names = sf.regions.names
totalsfile = replace(outfile, ".h5"=>"-shortfall_totals_by_sample.h5")
HDF5.h5open(totalsfile, "w") do f
f["sample", compress=4] = collect(1:args["samples"])
f["USA", compress=4] = Float64.(sf.shortfall_samples)

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.

You drop the USA column below, and it would make more sense to just drop it from the EUE and LOLP profiles too, so I'd suggest just not writing it here and then dropping the .drop(columns='USA') below

Suggested change
f["USA", compress=4] = Float64.(sf.shortfall_samples)

for r in regions
i_r = findfirst(==(r), region_names)
f["$r", compress=4] = Float64.(sf.shortfall_region_samples[i_r, :])
end
end
@info("Wrote PRAS shortfall totals by sample to $(totalsfile)")
end

### Sample-level generator and storage availability
if args["write_availability_samples"] == 1
dictavail = Dict(s => DF.DataFrame() for s = 1:args["samples"])
Expand Down Expand Up @@ -458,6 +484,7 @@ if abspath(PROGRAM_FILE) == @__FILE__
# "write_surplus" => 0,
# "write_energy" => 0,
# "write_shortfall_samples" => 1,
# "write_shortfall_samples_totals" => 1,
# "write_availability_samples" => 0,
# "overwrite" => 1,
# "debug" => 0,
Expand Down
63 changes: 63 additions & 0 deletions reeds/resource_adequacy/stress_periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,51 @@ def calc_neue(dfeue_agg, dfload_agg):
return neue


def get_shortfall_totals_by_sample(case, t, iteration=0):

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.

Sorry but just for consistent organization, can you move this up after the other get_ functions, between get_events() and calc_lold()?

filepath = os.path.join(case, 'handoff', 'PRAS', f'PRAS_{t}i{iteration}-shortfall_totals_by_sample.h5')
if not os.path.isfile(filepath):
raise FileNotFoundError(f"{filepath} not found. Re-run PRAS with --write_shortfall_samples_totals 1.")
df = reeds.io.read_pras_results(filepath)
df.columns = df.columns.astype(str)
if 'sample' in df.columns:
df = df.set_index('sample')
elif df.index.name != 'sample':
df.index = pd.RangeIndex(1, len(df) + 1, name='sample')
return df.apply(pd.to_numeric, errors='coerce').clip(lower=0)

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.

Why is .apply(pd.to_numeric, errors='coerce').clip(lower=0) required here? We don't do it when reading other PRAS results; I tried it both ways and it works the same. If it works both ways I would remove it to streamline.

Suggested change
return df.apply(pd.to_numeric, errors='coerce').clip(lower=0)
return df



def _sample_cvar(samples, alpha=0.95):
x = pd.Series(samples).dropna().astype(float)
if x.empty:
return np.nan

# Round before applying ceil to remove floating-point noise.
# For example, a mathematically exact tail size of 50 may be
# represented as 50.00000000000004, which would otherwise select 51 samples.
tail_size = round((1 - alpha) * len(x), 12)
n_tail = max(1, int(np.ceil(tail_size)))

return x.nlargest(n_tail).mean()

def calc_cvar(shortfall_samples_agg, alpha=0.95):
"""
CVAR from total shortfall by PRAS sample.
"""
cvar = shortfall_samples_agg.apply(
lambda s: _sample_cvar(s, alpha=alpha),
axis=0,
)
return cvar


def calc_ncvar(cvar, dfload_agg):
"""
NCVAR = CVAR / total load, in ppm.
"""
ncvar = cvar / dfload_agg.sum().reindex(cvar.index) * 1e6
return ncvar


def calc_peak_eue(dfeue_agg, dfload_agg, norm:Literal['peak','hourly','absolute']='peak'):
"""
Get the peak hourly outage magnitude
Expand Down Expand Up @@ -192,6 +237,12 @@ def calc_ra_metrics(
sw = reeds.io.get_switches(case)
numyears = len(sw.resource_adequacy_years_list)

### Get total shortfall samples for CVAR calculation
shortfall_samples = (
get_shortfall_totals_by_sample(case=case, t=t, iteration=iteration)
.drop(columns=['USA'], errors='ignore')
)
Comment on lines +241 to +244

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.

See above, if you don't write it you can drop it from here too

Suggested change
shortfall_samples = (
get_shortfall_totals_by_sample(case=case, t=t, iteration=iteration)
.drop(columns=['USA'], errors='ignore')
)
shortfall_samples = get_shortfall_totals_by_sample(case, t, iteration)


### Loop over aggregation levels and calculate all metrics
ra_metrics = {}
for level in levels:
Expand All @@ -212,6 +263,18 @@ def calc_ra_metrics(
ra_metrics[level, 'euemax_peakloadfrac'] = calc_peak_eue(dfeue_agg, dfload_agg, 'peak')
ra_metrics[level, 'euemax_hourlyloadfrac'] = calc_peak_eue(dfeue_agg, dfload_agg, 'hourly')
ra_metrics[level, 'euemax_mw'] = calc_peak_eue(dfeue_agg, dfload_agg, 'absolute')
## Calculate tail-based metrics (CVAR and NCVAR)
regions = [c for c in shortfall_samples.columns if c in rmap.index]
if len(regions):
shortfall_samples_agg = (
shortfall_samples[regions]
.rename(columns=rmap)
.T.groupby(level=0)
.sum().T
)
cvar = calc_cvar(shortfall_samples_agg, alpha=float(sw.GSw_PRM_CVARalpha))
ra_metrics[level, 'cvar_mwh_peryear'] = cvar / numyears
ra_metrics[level, 'ncvar_ppm'] = calc_ncvar(cvar, dfload_agg)

### Combine it
dfout = pd.concat(ra_metrics, names=['level','metric','region']).rename('value')
Expand Down
5 changes: 5 additions & 0 deletions runreeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ def check_compatibility(sw):
f"but '{stress_value}' was provided"
)

## CVAR value in [0,1)
alpha = float(sw['GSw_PRM_CVARalpha'])
if not (0 <= alpha < 1):
raise ValueError(f"GSw_PRM_CVARalpha must be in [0, 1). Got {alpha}")

### GSw_PRM_UpdateMethod 1-3 (static or PRAS-informed PRM update) is computed from the
### NEUE-based shortfall, so it requires NEUE to be an active stress metric
if int(sw['GSw_PRM_UpdateMethod']) in [1, 2, 3] and 'neue' not in used_metrics:
Expand Down
Loading