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.
| /// Map storing cost coefficients for an asset. | ||
| /// | ||
| /// These coefficients are calculated according to the agent's `ObjectiveType` and are used by | ||
| /// the investment appraisal routines. The map contains the per-capacity and per-activity cost | ||
| /// coefficients used in the appraisal optimisation, together with the unmet-demand penalty. | ||
| #[derive(Clone)] | ||
| pub struct ObjectiveCoefficients { | ||
| /// Cost per unit of capacity | ||
| pub capacity_coefficient: MoneyPerCapacity, | ||
| /// Cost per unit of activity in each time slice | ||
| pub activity_coefficients: IndexMap<TimeSliceID, MoneyPerActivity>, | ||
| /// Unmet demand coefficient | ||
| pub unmet_demand_coefficient: MoneyPerFlow, | ||
| /// Costs for LCOX | ||
| pub lcox_costs: IndexMap<TimeSliceID, MoneyPerActivity>, | ||
| } |
| /// activity coefficients so that assets with near-zero net value still appear in dispatch. Capacity | ||
| /// costs and unmet-demand penalties are set to zero. |
| use crate::agent::ObjectiveType; | ||
| use crate::asset::AssetRef; | ||
| use crate::model::Model; | ||
|
|
|
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. |
We shouldn't be using the result of the capacity var for NPV.
It's always zero now, so we don't need to store it.
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. |
Description
The following models are currently failing to run to completion:
Examples:
circularity: FAILED
missing_commodity: succeeded
muse1_default: FAILED
simple: succeeded
two_outputs: succeeded
two_regions: FAILED
Patched examples:
simple_divisible: succeeded
simple_full: succeeded
simple_full_average: succeeded
simple_ironing_out: succeeded
simple_marginal: FAILED
simple_marginal_average: FAILED
simple_npv: succeeded
Closes #1199.
Type of change
Key checklist
$ cargo test$ cargo docpresent in the previous release
Further checks