Skip to content

Add class set to model outputs - #217

Merged
wesleyjcole merged 31 commits into
wjc/dev/separate_classfrom
wjc/outputs_class
Sep 11, 2026
Merged

wesleyjcole merged 31 commits into
wjc/dev/separate_classfrom
wjc/outputs_class

Conversation

@wesleyjcole

@wesleyjcole wesleyjcole commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

(Note: this is getting merged into a dev branch and not into main)

This is the 5th PR toward #185, and adds the resource class index c to the model outputs and downstream consumers. The class index is still redundant, so each technology maps to only one class.

Two other meaningful changes:

  • INV_REFURB gained a class index because it was necessary for several output parameters.
  • csp-ns is no longer hard-coded to upv_5, but is converted dynamically. This revealed a bug where some of the the csp-ns was getting dropped in the input processing (this PR fixes that bug).

Technical details

Implementation notes

INV_REFURB.l was added to the Sw_RemoveSmallNumbers rounding block in 5_varfix.gms. It was previously the only investment variable absent from that block.

The previous approach for dealing with csp-ns was both fragile and inaccurate: it subtracted all csp-ns fromupv_5, so it would silently do nothing if upv_5 did not exist in a run. writecapdat.py now assigns each csp-ns unit to a upv resource class from its capacity factor, using the same get_class_cf_bounds/assign_class helpers used for upv itself, and writes cap_cspns by (r,c,t). Because the csp-ns resource is in the best-class areas, the reassignment draws from the best class first and moves into the remaining classes only as needed. report.gms then takes csp-ns capacity and generation back out of those same classes,
in proportion to the upv capacity present in each. The csp-ns assignment is also added to the error_check parameter.

Additional changes

The excess error check calculation is added to compare_cases.py so that it will display properly if it has a non-zero value.

Issues resolved

Partially addresses #185.

Known incompatibilities

The PR breaks R2X, so fixes for that will be needed before this dev branch is merged into main. I've added that information to #185 to track it.

Validation, testing, and comparison report(s)

I verified that before making the csp-ns fix, the outputs were all the same to within rounding noise. A national USA_defaults case returned bascially identical outputs:

image

The csp-ns changes model outputs because some of the csp-ns generation was missing from the model. The change is pretty small because the missing csp-ns generation was small:

image

Compare report after csp-ns fix: results-Main,CSP-nsFix.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 (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

Yes. I used Claude Opus 5 to implement the changes in this PR and to do many of the output comparisons. I also used it to diagnose and fix the csp-ns bug found during this PR.

Tag points of contact here if you would like additional review of the relevant parts of the model

@cavraam cavraam left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No major concerns in this one, especially given results. A few suggestions to either collapse the 'c' index in reporting or potentially rename some of the output parameters to reflect the specific outputs. No strong feelings either way!

A couple of sanity checks included, prompts to double-check a couple of seemingly trickier code, but the respective parts should be fine.

PR looks good to go!

Comment thread hourlize/reeds_to_rev.py
# Concatenate existing and inv_rsc
df_inv = pd.concat([df_bin_exist, df_inv_rsc], sort=False, ignore_index=True)
# Split tech from class
df_inv[["tech_cat", "class"]] = df_inv["tech"].str.rsplit("_", n=1, expand=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

got it, because 'class' is already part of the data upstream, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's right.

Comment thread hourlize/reeds_to_rev.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(Not related to this PR) I am also getting a suggested edit on line 1610: 'rcby' seems to be defined in line 1647... I wonder if it needs to be renamed to 'rcy' defined earlier, or move some code around?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

reeds_to_rev.py isn't working right now, but this is a quick fix, so I can add it in here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks great programmatically. Conceptually, it might make sense to include the class in cap_energy_ivrt and/or cap_firm and cap_firm_iter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think it's necessary in cap_energy_ivrt because there are no class-relevant technologies for energy in the initial scope. It would make sense if PSH gets a class at some point.

cap_firm, though, should get a class designation. We'll want to know exactly the capacity contribution of a technology. That will require making m_cc_mar adopt a class, which also needs to happen at some point. I've been focusing on stress periods, but I'll note this for follow-on work once I have a working stress-period formulation with collapsed classes. The output rename would also go along with that.

Comment thread postprocessing/compare_cases.py Outdated
'RPS': {'title': 'Non-RecMap\nRECS\n[GWh]', 'scale':1e-3},
'OpRes': {'title': 'Non-valgen\nopres\n[MWh]', 'scale':1},
'm_rsc_dat': {'title': 'Supply curve\ntweaks [GW]', 'scale':1e-3},
'cspns': {'title': 'Unreassigned\ncsp-ns [MW]', 'scale':1},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could this tag be 'unassigned'?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is capacity that was not re-assigned to csp-ns, but that's likely not to be intuitive. I'll update to unassigned.

if items_column == 'tech':
df['tech'] = df['tech'].str.lower().map(lambda x: items_map.get(x, x))
df = df.groupby(['tech', 'r', 'year']).sum().reset_index()
valcols = df.select_dtypes('number').columns.difference(['year'])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

a little less familiar with this part of ReEDS, so commenting just as a point to double-check... post-processing passed the checks, so we should be good

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I took another look and decided that summing over the resource would also help here. Reviewing these and reproducing outputs for ancillary scripts like this will be a key part of testing after the i set is collapsed.

cap_exist_i(i)$valcap_i_filt(i) = sum{(r,v), cap_exist(i,v,r) } ;

cap_ivrt(i,v,r,t)$([not (upv(i) or wind(i))]$valcap(i,v,r,t)$trange(t)) = CAP.l(i,v,r,t) ;
cap_ivrt(i,c,v,r,t)$[i_c(i,c)$(not (upv(i) or wind(i)))$valcap(i,v,r,t)$trange(t)] = CAP.l(i,v,r,t) ;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nitpick: do we actually need to track the class? I wonder if it would make sense to just sum over 'c'... otherwise, do we want to potentially rename this to cap_icvrt? I understand that it might be a little bit of a pain to update downstream in reeds.py etc...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think we want to track the class. That way this parameter holds the equivalent information as it does now. Otherwise you wouldn't have the information about which wind/pv/etc. class is getting built.

And I agree with the rename and that it will be a bit of a pain. I'm noting that for a follow-up change as well.

cap_init(i,v,r)$(distpv(i)$valcap_ivr(i,v,r)) = sum{(c,t)$[i_c(i,c)$tfirst(t)], cap_ivrt(i,c,v,r,t) } ;
inv_ivrt(i,c,v,r,t)$[i_c(i,c)$valcap(i,v,r,t)$trange(t)] =
[INV.l(i,v,r,t)$(not rsc_i(i))
+ sum{rscbin$m_rscfeas(r,i,c,rscbin), INV_RSC.l(i,c,v,r,rscbin,t) }$rsc_i(i)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this line prompted me to double-check if $i_c(i,c) is accounted for in m_rscfeas in reeds/core/setup/b_inputs.gms. I think the edits in b_inputs.gms below are optional, given that rscfeas is based on rsc_dat (which only has the relevant i-c pairs), but flagging just in case:

  • Lines 1443 -- 1453 (related to 'rscfeas'): Do we need sum{ii$i_c(ii,c)$...], ...}?
  • Line 1751, 1753: Do we need sum{ii$[i_c(ii,c)$...], ...}?
  • Lines 5258 --5260: I see a few sum{ii, ...}, would we need sum{ii**$i_c(ii,c)**, ...}?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I checked these, and I think they are ok. There may be some redundancy (e.g., some $ conditionals can be removed without impacting the calculation), but I'd rather not clean those up until the classes are collapsed becaused then it will be easier to see if removing a conditional actually does change things.


ret_ivrt(i,v,r,t)$([trange(t)$(not tfirst(t))$newv(v)]$valcap(i,v,r,t)) = sum{tt$tprev(t,tt), cap_ivrt(i,v,r,tt)} - cap_ivrt(i,v,r,t) + inv_ivrt(i,v,r,t) ;
ret_ivrt(i,v,r,t)$([abs(ret_ivrt(i,v,r,t) < 1e-6)]$valcap(i,v,r,t)) = 0 ;
ret_ivrt(i,c,v,r,t)$[i_c(i,c)$trange(t)$(not tfirst(t))$newv(v)$valcap(i,v,r,t)] = sum{tt$tprev(t,tt), cap_ivrt(i,c,v,r,tt)} - cap_ivrt(i,c,v,r,t) + inv_ivrt(i,c,v,r,t) ;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

similar nitpick to 'cap_ivrt' here: Do we need to explicitly track 'c' in the outputs? If so, would it make sense to rename into ret_icvrt here or later on?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed - and saving for a follow-up PR.


gen_new_uncurt(i,r,h,t)$[(vre(i) or storage_hybrid(i)$(not csp(i)))$valcap_irt(i,r,t)] =
sum{(v,c)$[valinv(i,v,r,t)$i_c(i,c)], (INV.l(i,v,r,t) + INV_REFURB.l(i,v,r,t)) * m_cf(i,c,v,r,h,t) * hours(h) }
sum{(v,c)$[i_c(i,c)$valinv(i,v,r,t)], (INV.l(i,v,r,t) + INV_REFURB.l(i,c,v,r,t)) * m_cf(i,c,v,r,h,t) * hours(h) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

are we potentially double-counting the INV values by having the sum applied to both INV and INV_REFURB instead of the latter only (like above)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No - these are unique, so we will need both.

INV.l(i,v,r,tt) + INV_REFURB.l(i,v,r,tt)$[refurbtech(i)$Sw_Refurb]}) / ilr(i) ;

cap_out(i,r,t)$[valcap_irt(i,r,t)$tmodel_new(t)] = sum{v$valcap(i,v,r,t), cap_ivrt(i,v,r,t) } ;
cap_out(i,c,r,t)$[i_c(i,c)$valcap_irt(i,r,t)$tmodel_new(t)] = sum{v$valcap(i,v,r,t), cap_ivrt(i,c,v,r,t) } ;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could sum over 'c' as well and not worry about it in reeds2.py?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I tried to keep the same information as was in the original data where feasible. Since class information was already available and can be calculation directly, I maintained class in the output.

@wesleyjcole
wesleyjcole merged commit e8c2c15 into wjc/dev/separate_class Sep 11, 2026
1 check passed
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