Skip to content

Fix multistage cost discounting and reporting - #922

Draft
lbonaldo wants to merge 80 commits into
GenXProject:developfrom
lbonaldo:lb/fix-multistage_costs
Draft

lbonaldo wants to merge 80 commits into
GenXProject:developfrom
lbonaldo:lb/fix-multistage_costs

Conversation

@lbonaldo

Copy link
Copy Markdown
Collaborator

Note: created as a draft because it's built on top of #915.

Description

This PR fixes multi-stage cost discounting and reporting, which was internally inconsistent in ways that made costs.csv unstable for perfect-foresight runs and not comparable between myopic and perfect foresight.

Two symptoms motivated the work:

  1. Zone columns did not sum to the Total column.
  2. cTotal did not equal the sum of its own rows.

In addition to these fixes, this PR changes the convention adopted for when capacity is retired and comes online (see section below).

Important conventions adopted

  • Operating costs are now discounted end-of-year, matching how investment annuities were already discounted: OPEXMULT changes from Σ 1/(1+DR)^(j-1) to Σ 1/(1+DR)^j. Previously an operating dollar and an annuity dollar in the same year differed in weight by a factor of (1+DR), which biased the model slightly toward capital-intensive builds.

  • Investment annuities are now discounted at the general discount rate rather than at each technology's or line's own WACC.

  • Each stage now reports on two explicit bases: costs.csv is the present value at the start of the horizon (what the objective weighs), and a new costs_undiscounted.csv is the cash flow over the stage with no time value. costs_multi_stage.csv and the new costs_undiscounted_multi_stage.csv stack the per-stage columns with no further scaling, so summing a row across stages gives the horizon cost.

  • Myopic reports add back the investment annuities falling beyond the stage being solved, since the myopic objective charges only one year of each annuity; this makes myopic and perfect-foresight output comparable.

  • Age-based retirement now measures a technology's life from the start of the stage it was built in to the start of the current stage. Capacity reaching end of life part way through a stage stays available for that whole stage. This only differs when stage lengths are unequal; equal-length runs are unchanged.

  • The standing warning that "the cost calculation of the multi-stage GenX is approximate currently" is removed, since the approximation it described is what this PR fixes.

Breaking changes

  • multi_stage_settings.yml: WACC is renamed DiscountRate. The old name collided with the per-resource WACC column, which is a different quantity. WACC is still accepted and translated with a deprecation warning; setting both keys is an error.
  • Rows with no zonal attribution are now empty cells rather than "-". The string placeholder forced the entire zone column to be read back as text, so arithmetic on it failed. Scripts testing for the literal "-" need updating.
  • Perfect-foresight objective values change — from the end-of-year OPEXMULT, and for any resource whose WACC differed from the discount rate. Myopic objectives are unchanged (OPEXMULT = 1 there).
  • Myopic runs now reject a resource with positive investment cost and Capital_Recovery_Period = 0, as perfect-foresight runs already did.

A note on cTotal and zone columns

Three rows are system-wide and have no zone (cUnmetRsv, cNetworkExp, cUnmetPolicyPenalty). cTotal is therefore the one row whose zone columns deliberately do not sum to its Total; the difference is exactly the sum of those three rows. This is documented in model_output.md. Every other row sums exactly, by construction.

What type of PR is this? (check all applicable)

  • Feature
  • Bug Fix
  • Documentation Update
  • Code Refactor

Related Tickets & Documents

Fixes #862, #838, #660
Addresses #841

Checklist

  • Code changes are sufficiently documented; i.e. new functions contain docstrings and .md files under /docs/src have been updated if necessary.
  • The latest changes on the target branch have been incorporated, so that any conflicts are taken care of before merging. This can be accomplished either by merging in the target branch (e.g. 'git merge develop') or by rebasing on top of the target branch (e.g. 'git rebase develop'). Please do not hesitate to reach out to the GenX development team if you need help with this.
  • Code has been tested to ensure all functionality works as intended.
  • CHANGELOG.md has been updated (if this is a 'notable' change).
  • I consent to the release of this PR's code under the GNU General Public license.

How this can be tested

Automated. The full suite passes (unit tests plus end-to-end consistency checks on both the DDP and myopic paths)

Manual. Run example_systems/6_three_zones_w_multistage with Myopic: 1 and Myopic: 0

This branch was verified against the pre-branch commit on 1_three_zones, 4_three_zones_w_policies_slack, 6_three_zones_w_multistage (myopic and DDP), 7_three_zones_w_colocated_VRE_storage, 8_three_zones_w_colocated_VRE_storage_electrolyzers and 11_three_zones_w_allam_cycle_lox, under Gurobi, with time-domain-reduction clustering held fixed across runs so the comparison isolates code changes.

Post-approval checklist for GenX core developers

After the PR is approved

  • Check that the latest changes on the target branch are incorporated, either via merge or rebase
  • Remember to squash and merge if incorporating into develop

dlcole3 added 30 commits August 6, 2026 17:58
lbonaldo and others added 30 commits September 15, 2026 18:37
`write_costs` built the `Total` column of `cFix` from the co-located VRE+storage
inverter, solar and wind terms but omitted `eTotalCFixElec`, while the per-zone
columns did include `eCFixElec`. Zones therefore did not sum to the total for any
case with an electrolyzer component.

Verified on example_systems/8_three_zones_w_colocated_VRE_storage_electrolyzers,
the only case in the repo with VS_ELEC resources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`write_costs` built the Total column from the aggregate eTotal* expressions and
the zone columns from per-resource ones, in two independent passes. Nothing tied
the two together, so they could and did disagree.

Split into two functions:

  cost_breakdown   attributes every cost term to a resource id, returning
                   vectors indexed by resource (plus per-zone NSE and the
                   system-wide terms that have no resource attribution)
  assemble_costs   sums those vectors for the Total column and over each zone's
                   resources for the zone columns, then applies ParameterScale
                   once to every numeric cell

Because both columns now come from the same attribution, the zone columns sum to
the Total column by construction rather than by coincidence. That is the property
the multi-stage discounting work needs, since it has to scale investment and
operating costs by different factors without the two halves drifting apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cTotal exceeded the sum of its rows whenever these were active:

  capacity-reserve virtual charge/discharge  -> cVar
  hourly matching slack                      -> cUnmetPolicyPenalty
  retrofit lifetime slack                    -> cUnmetPolicyPenalty
  LDES slacks (eObjSlack, eObjSlackVreStor)  -> cUnmetPolicyPenalty

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds stage_opex_multiplier, stage_discount_factor, payment_years_remaining and
overnight_capital_cost_factor, and routes compute_overnight_capital_cost,
initialize_cost_to_go and write_multi_stage_costs through them. The formulas are
unchanged; the discount factor was previously computed by an inline loop in two
separate places.

Unit tests assert each helper reproduces the expression it replaced, under both
equal and uneven stage lengths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
run_myopic_multistage never updated CurStage, so it held whatever model
generation left behind, the final stage, for every stage it wrote. The
post-solve loop in run_genx_case_multistage! does set it, but that loop is
skipped precisely when Myopic and WriteIntermittentOutputs are both on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tech_wacc defaults to zero when the WACC column is absent, which left investment
annuities undiscounted rather than discounted at the social rate. Network.csv
was worse: a missing WACC column raised an error, so the column was effectively
mandatory.

annuity_discount_rate now substitutes the general discount rate from
multi_stage_settings.yml wherever a WACC is missing, blank or non-positive, and
overnight_capital_cost_factor discounts at that rate. An explicit zero is
treated as absent; a genuinely undiscounted annuity needs a zero general rate.

No behaviour change for the oracle or test cases: all supply positive WACCs
(resources 0.017-0.039, lines 0.062), so obj_true is unchanged.

Both column descriptions in multi_stage_input.md now mark WACC optional and
state the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OPEXMULT placed each year's operating cost at the start of the year
(1/(1+DR)^(j-1)) while investment annuities are discounted from the end of year
one (1/(1+WACC)^j), so the two were inconsistent by a factor of (1+DR).

OPEXMULT now uses 1/(1+DR)^j.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_retirement_stage measured a technology's life from the end of the current
stage, so capacity had to be gone by the time that stage finished. It now
measures to the start of the current stage, matching the documented convention
that a unit reaching end of life within a stage stays available for that whole
stage and retires at the beginning of the next.

The return value is the latest build-stage whose capacity must already have
retired, used as the bound of `for p in 1:get_retirement_stage(...)`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds MultiStageCostFactors and ComponentAnnuity, built during input
configuration and stored under inputs["MULTISTAGE_COST_FACTORS"]: the stage
discount factor, the operating multiplier, the stage length, and per-component
annuity factors with their payment counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each stage's costs.csv becomes the present value at the start of the horizon,
what the objective weighs, with a companion costs_undiscounted.csv giving the
cash flow over the stage. The summaries stack both without further scaling, and
the cTotal row returns.

A fixed-cost expression is investment plus fixed O&M and the two scale
differently, so the writer separates them: O&M is a rate times capacity, and
investment is the remainder. Both are recovered to annual terms and re-scaled.

Under myopic the investment field holds one year's annuity, so multiplying by
the annuity factor adds back the payments the objective never charged. That is
what makes the two foresight modes comparable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
model_output.md gains sections for costs_undiscounted.csv and the two
multi-stage summaries, plus notes on which costs.csv rows carry no zonal
attribution and why myopic reported totals exceed the stage objective.

multi_stage_input.md gains a discounting section with the formulas actually
used: the stage discount factor, the operating multiplier, the annuity factor
and its truncation at the horizon, the age-based retirement rule, and the
myopic add-back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Empty cells read back as missing while the column stays numeric, and are
distinct from a genuine zero.

Breaking for anything testing the literal "-".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The check rejecting a zero Capital_Recovery_Period alongside a positive
investment cost lived inside compute_overnight_capital_cost, which only runs
under perfect foresight. Myopic runs skip that path, so they never validated it.

That became harmful once reporting started using annuity factors: a myopic
resource with a zero recovery period gets an annuity factor of zero, so its
investment vanished from both the discounted and undiscounted views while the
objective charged it in full. Self-consistent and wrong, which is the worst
combination.

Extracted the check into validate_capital_recovery_period and call it for every
component as the reporting factors are built, so both foresight modes validate
the same way. Each component is checked against the investment field it prices.

Found in review by a second pair of eyes, not by the tests or the oracle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two silent-skip paths of the same shape as the recovery-period bug, both found
in review:

- _accumulate_fixed! skipped the investment/O&M split whenever a component's
  annuity was absent. For a single-stage run that is correct, but in multi-stage
  it would drop the component from the discounted and undiscounted views while
  leaving it in the model view, so the file would look self-consistent and
  understate costs. The lookup now errors in multi-stage instead.

- write_multi_stage_costs wrote nothing when a stage file was missing. That is
  right when cost output is switched off entirely, but not when only some stages
  have the file; it now warns and names the stages.

The new guard immediately caught a real inconsistency in the caller: the
transmission component was looked up unconditionally even though it is
legitimately absent without network expansion, which would have tripped on every
single-zone multi-stage case. Now looked up only when the model has it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixed-cost and transmission paths each carried the same three lines turning
a model-held investment cost into its discounted and undiscounted views. Pulled
into _investment_views, so there is one place to read and one place to test.

That matters because the transmission path had no direct coverage: the
multi-stage test fixture is single-zone with no network, so only example 6
exercises it, and only as a whole-file diff. The arithmetic is now tested on its
own, including the case where both modes must agree on the reported value, and
the zero-payment-years guard that would otherwise divide by zero.

Also corrects two docstrings flagged in review: get_retirement_stage listed a
fourth argument that does not exist, and initialize_cost_to_go claimed
investment costs are scaled by 1/WACC when the code divides by 1/OPEXMULT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Before, the overnight capital cost summed each resource's annuities as its own tech WACC, falling back to the dr ony when none was give.

Co-Authored-By: Claude <noreply@anthropic.com>
Every existing cost test compares the implementation against itself: the
objective identity, the zone sums, the ratios between views. All would pass
under a uniformly wrong discount factor. This one compares against values
derived by hand from the input CSVs and the documented conventions.

The case forces its own solution, so no optimisation is needed to know the
answer: one zone, one thermal generator with no existing capacity, flat demand
stepping up each stage, and nothing else that could substitute for it. Capacity
equals demand, generation equals capacity times 8760, new build is 100/50/50.

Stage lengths are uneven (10, 5, 8) so annuity truncation differs by stage: with
a 20-year recovery period the payments inside the horizon are 20, 13 and 8. Equal
stages would have left that untested.

Checks each component separately so a failure localises, not just the totals.
Verified to have teeth: flipping the operating-cost convention to start-of-year
fails 12 of 37 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setting held the planner's time value of money, but shared its name with
the per-resource WACC column of Resource_multistage_data.csv, which is a
technology's cost of capital. Two different quantities, one name, in two input
files a user reads side by side. That confusion cost real time this week.

WACC is still read and translated, with a warning naming the file, so existing
cases keep working. Supplying both keys is an error rather than a guess.

Local variables and docstrings follow the new name; the per-resource and
per-line WACC columns keep theirs, since those genuinely are costs of capital.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three rows without per-resource attribution were already documented,
but not the consequence: cTotal is the one row whose zone columns do not
sum to its Total, by exactly those rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three autodoc'd docstrings still pointed users at the WACC field of
multi_stage_settings.yml. The code reads DiscountRate; only the prose
was stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rtol=1e-8 was a default, not a derived bound. Measured error is
~1e-16 for checks that only re-sum the writer's own output, so those
move to 1e-12. Comparisons against objective_value cross the solver
boundary and the two investment checks lose digits to cancellation;
those move to 1e-10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
For a co-located resource eCFix is entirely the grid-connection cost,
every other component having its own expression. The memo read eCGrid,
which matches eCFix except that eCFix scales the investment term by
Cap_Size for a resource in CAP_SIZE_SCALED. A co-located resource can
reach that set through Discrete_Build, in which case the memo
understated the cFix it quotes. cTotal excludes the memo, so totals and
the objective were never affected.

Reading eCFix makes the two agree by construction and leaves the :grid
annuity component unused, so it is dropped; it duplicated :discharge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The single-stage branch no longer quotes the objective because rows are
missing; since every objective term has a row, the two agree and the
objective serves as a check on the rows. The multi-stage branch sums
rows because eObj is not in the rescaled units, not because no objective
exists: the sum equals objective_value - vALPHA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Single-stage cTotal is written from the objective rather than from the
rows, so comparing the two tests that every objective term reached a
row. Nothing asserted this before; the multi-stage path already did.
Covers both the continuous and the DiscreteInvestments run.

Tolerances measured on this case: cTotal against the row sum crosses
the objective-evaluation boundary at 7e-15, zone sums are a pure
re-sum at 2e-16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DEMAND stood for three quantities at once: demand, total installed
capacity and the generation level. They coincide only because the
generator builds exactly peak demand and nothing retires. FOM is
charged on capacity, so derive it as cumsum(NEWCAP) and assert that it
equals DEMAND rather than leaving the reader to reconstruct it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cTotal and objective_value - vALPHA come from the same solution, so
solver error cancels; measured 3.8e-16. The remaining rtol=1e-6 checks
compare against hand-computed values and stay as they are: the fixture
sets Feasib_Tol 1e-5, so a conforming solver may return capacity off by
1e-7 relative, and the convention errors this test exists to catch are
percent-scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old blanket warning that multi-stage costs were approximate is gone,
since they no longer are. One caveat it covered still stands: run_ddp
returns whatever models it has at its iteration limit, and the costs are
then reported exactly as for a converged run. Check the final bounds
against ConvergenceTolerance and say so when the gap is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants