Change LCOX to use same optimisation as NPV#1319
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the LCOX investment appraisal so that it uses the same optimisation formulation as the NPV appraisal (maximise activity surplus with fixed capacity equal to the supplied max_capacity), instead of the previous LCOX-specific formulation that minimised annualised cost with capacity as a decision variable and a VoLL-priced unmet-demand term. LCOX-specific per-time-slice costs are now computed separately and used only for the final LCOX metric calculation. This addresses issue #1199 where the old LCOX approach overinvested capacity and relied on VoLL to clear demand.
Changes:
- Removed the capacity decision variable, the unmet-demand objective penalty, and the LCOX-specific
Sense::Minimisepath from the appraisal optimisation; both LCOX and NPV now call a single sharedperform_optimisationthat maximises activity surplus. - Restructured
ObjectiveCoefficientsto hold a sharedactivity_coefficientsmap plus an optionallcox_costsmap;calculate_coefficients_for_assetsnow always builds the activity coefficients via a single helper and fillslcox_costsonly when the objective is LCOX. - Propagated removal of capacity/unmet-demand coefficients through callers (
appraisal.rs,output.rs,fixture.rs), soAppraisalOutput.capacityis now simply the suppliedmax_capacityand the LCOX metric usesannual_fixed_cost(asset)directly.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/simulation/investment/appraisal/optimisation.rs | Drops capacity variable, hard-codes Sense::Maximise, and remaps solution columns to activity + unmet-demand only. |
| src/simulation/investment/appraisal/constraints.rs | Removes add_capacity_constraint and candidate-specific activity constraints; activity bounds are now derived from max_capacity.total_capacity(). |
| src/simulation/investment/appraisal/coefficients.rs | Unifies coefficient calculation; adds lcox_costs and removes capacity_coefficient/unmet_demand_coefficient. |
| src/simulation/investment/appraisal.rs | calculate_lcox/calculate_npv now share the same optimisation call and use max_capacity directly for metrics. |
| src/output.rs | Removes capacity_coefficient column from appraisal debug output. |
| src/fixture.rs | Updates test fixture to the new ObjectiveCoefficients shape. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
There's a few problems here:
Going forward:
|
b3c1582 to
c46f693
Compare
|
I've figured out the problem with the When it comes to investment appraisal, assets that were active in the previous year dispatch choose not to be active in the appraisal because they're not profitable with the price of heat that's given to them. I've rebased on top of #1324 and these two models now work. The circularity model still fails, possibly because it's still using shadow prices for some commodities, although hopefully that will be fixed up with #1322.* Overall, I think this reveals an inherent issue with mixing shadow pricing and cost-based pricing. I think we have to either use shadow pricing throughout or cost-based pricing throughout, but not mix and match (although it would probably be fine if all the shadow-priced commodities were upstream of the cost-based commodities in the price calculation order). *I still have doubts about this though. I think what this shows is the importance of commodity prices being consistent with one another, and in the current approach of iterating just once, rather than aiming for an equilibrium, it's possible that prices won't be consistent with one another. |
c46f693 to
2686a3e
Compare
|
I've tried to come up with a simpler/more generic example to demonstrate the point above about tranching. Consider the following scenario: Together, these imply:
If demand = 100 in each timeslice (total annual demand = 300), we can calculate the demand limiting capacity (the capacity required to meet the full demands) as the max of:
In other words, we can calculate in advance that 450 capacity is sufficient to meet the full demand profile, based on the activity limits of the process. Now, suppose we split this into three tranche assets of 150 capacity. Treating each as an independent asset, it would have a production limit of 50 in each timeslice (based on each timeslice being 1/3 of the year) and an annual limit of 100 (based on 2/3 utilisation) Tranche 1 discovers that TS1 and TS2 are most profitable (e.g. if the output commodity price is highest in these timeslices) so it runs at full utilisation there, giving remaining demand of:
Tranche 2 does the same, giving remaining demand of:
By the time of tranche 3, there's 100 units remaining demand in TS3, but this asset can only serve 50 of this because of its own timeslice-level limits. Therefore, after 3 tranches and 450 capacity investment, according to the investment algorithm there's still unmet demand, despite the fact that we earlier calculated 450 capacity to be sufficient (at this point it would currently give up and exit the simulation). We could overcome this by allowing it to consider a 4th tranche, but in the end that would allow it to invest in more capacity than it actually needs. |
|
Thanks for looking into this! There's a lot to think about here... Would probs be easiest to have a chat about it, but I'll dump some thoughts in here before I forget.
Ok, so you figured this out for the second two (nice work!). Out of curiosity, I tried merging #1322 into this branch to see if the There was a candidate asset but it wasn't dispatched, therefore not considered.
Do you think it's worth trying to get #1326 merged? If it fixes a problem with the current approach, it might be worth doing even if we expect we'll go down a different route soon anyway.
Presumably! Would be worth checking...
It feels a bit wrong that assets which could meet some (all?) of the demand aren't even being considered. Sure, it would be better to have a profitable asset than an unprofitable one, but better to have an unprofitable one than not meet demand at all. That's like choosing to starve to death because you don't fancy the hamburger in front of you. Here are my half-baked ideas about how to handle the "only remaining options are non-feasible" case:
I think your idea of including capacity for the same asset that was selected in a previous tranche is an interesting one. I wonder if there will be subtle consequences to doing this though. Let's discuss!
If Adam is happy with this, then shall we just require that users use only shadow pricing or only cost-based? We don't want to let users do silly things. The other option sounds intruiging but a bit fiddly, so let's not make our lives hard until we have a clear use-case. |
2686a3e to
3f8afb4
Compare
|
Rebasing now that #1322 is merged |
Or rather, choosing not to make a hamburger for a starving person because it looses you money. In reality, the starving person would offer to pay more, or buy a salad instead, but in the MUSE world you'd have to wait until the next milestone year for either of these to happen.
Sounds reasonable. Should discuss with Adam
👍
Yeah, I think this is sensible |
3f8afb4 to
8fd0634
Compare
|
I've rebased this now. @tsmbland: I poached your bits for working out unmet demand from #1329, because this doesn't really make sense without it. We now have four failures: In every case, there is one non-feasible option not considered (see #1347). I'm going to try forcing the model to select them anyway and see if we can at least get the models to run to completion. |
Unless anything major has changed,
|
8fd0634 to
4d2f352
Compare
This way, you get a more informative error message if a test fails.
4d2f352 to
9ed8b03
Compare
|
So now all the models that were previously failing are passing 🥳. Unfortunately, I didn't notice before that I don't think we had that model when I first opened this PR. Any thoughts @tsmbland? I don't have lots of time to dig into this in detail today. I suppose we do have the option of temporarily disabling this one test until we figure out why it's not working, if that turns out to be difficult. It may be that #1201 magically fixes things (or makes them worse...). |
|
Let's just disable it for now, in the interests of getting this merged |
Closes #1199. Co-authored-by: Tom Bland <t.bland@imperial.ac.uk>
With help from Claude Opus 4.8
9ed8b03 to
10b4fd5
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1319 +/- ##
==========================================
- Coverage 89.80% 89.68% -0.12%
==========================================
Files 58 58
Lines 8524 8406 -118
Branches 8524 8406 -118
==========================================
- Hits 7655 7539 -116
+ Misses 556 550 -6
- Partials 313 317 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| #[allow(clippy::cast_precision_loss)] | ||
| let unmet_per_slice = (demand_for_selection - supply_for_selection).max(Flow(0.0)) | ||
| / Dimensionless(time_slices.len() as f64); | ||
| for (time_slice, _) in &time_slices { | ||
| unmet_demand.insert((*time_slice).clone(), unmet_per_slice); | ||
| } |
| let results = | ||
| perform_optimisation(model, asset, max_capacity, commodity, coefficients, demand)?; | ||
|
|
||
| let cost_index = lcox( | ||
| results.capacity.total_capacity(), | ||
| coefficients.capacity_coefficient, | ||
| max_capacity.total_capacity(), |
There was a problem hiding this comment.
At least for LCOX, if activity is zero, then lcox() will return None and so the value of the metric will also be None.
| let results = | ||
| perform_optimisation(model, asset, max_capacity, commodity, coefficients, demand)?; | ||
|
|
||
| let annual_fixed_cost = annual_fixed_cost(asset); |
|
PS -- I tried running |
This should make it clearer to users if something has gone wrong.
Description
This PR changes the approach to LCOX appraisal, which means the mini-optimisation performed is now the same as for NPV. See #1199 for background and discussion.
I've held off on carrying out more substantial refactoring of the appraisal and investment code -- though this is sorely needed -- because we have other branches based on this (e.g. #1357). After all this work has settled down a bit, we can take another pass at it and tidy things up a bit.
The
circularity_npvmodel unfortunately no longer works with these changes (#1368). I haven't had a chance to look into why this is, but disabling the test temporarily in order to get this branch merged and unblock other work seemed like the best approach. We should try to figure out what's going wrong there, though.The first three commits are changes I made for the sake of debugging, but I think they're worth merging:
fixture.rsregenerate_test_data.sh: Carry on if model fails to runCloses #1199.
Type of change
Key checklist
$ cargo test$ cargo docpresent in the previous release
Further checks