Skip to content

Change LCOX to use same optimisation as NPV#1319

Draft
alexdewar wants to merge 6 commits into
mainfrom
new-lcox-optimisation
Draft

Change LCOX to use same optimisation as NPV#1319
alexdewar wants to merge 6 commits into
mainfrom
new-lcox-optimisation

Conversation

@alexdewar

@alexdewar alexdewar commented May 28, 2026

Copy link
Copy Markdown
Member

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

  • Bug fix (non-breaking change to fix an issue)
  • New feature (non-breaking change to add functionality)
  • Refactoring (non-breaking, non-functional change to improve maintainability)
  • Optimization (non-breaking change to speed up the code)
  • Breaking change (whatever its nature)
  • Documentation (improve or add documentation)

Key checklist

  • All tests pass: $ cargo test
  • The documentation builds and looks OK: $ cargo doc
  • Update release notes for the latest release if this PR adds a new feature or fixes a bug
    present in the previous release

Further checks

  • Code is commented, particularly in hard-to-understand areas
  • Tests added that prove fix is effective or that feature works

Copilot AI review requested due to automatic review settings May 28, 2026 14:25
@alexdewar alexdewar marked this pull request as draft May 28, 2026 14:29

Copilot AI left a comment

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.

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::Minimise path from the appraisal optimisation; both LCOX and NPV now call a single shared perform_optimisation that maximises activity surplus.
  • Restructured ObjectiveCoefficients to hold a shared activity_coefficients map plus an optional lcox_costs map; calculate_coefficients_for_assets now always builds the activity coefficients via a single helper and fills lcox_costs only when the objective is LCOX.
  • Propagated removal of capacity/unmet-demand coefficients through callers (appraisal.rs, output.rs, fixture.rs), so AppraisalOutput.capacity is now simply the supplied max_capacity and the LCOX metric uses annual_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.

Comment on lines 13 to 24
/// 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>,
}
Comment on lines +55 to +56
/// 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;

@tsmbland

tsmbland commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

There's a few problems here:

  1. Assets are frequently choosing not to serve at all. This may be fair enough and something we anticipated. However, we don't have a solution for if all assets choose not to serve, so currently it fails. This happens for circularity, simple_marginal and simple_marginal_average

  2. We're calculating demand limiting capacity incorrectly for processes with seasonal/annual activity constraints. See Fix get_demand_limiting_capacity to account for seasonal/annual activity limits #1326

  3. Even with the above fix it still doesn't work. Hard to explain, but the issue seems to be that the DLC calculation inherently assumes that activity is fully fungible across capacity, which isn't the case in the way that we do tranching.

    Taking the example of windturbine in the muse1_default model: DLC is calculated based on 40% annual utilisation (-> ~13.2 -> tranch size of ~0.132). Meeting the full annual demands at a total capacity of the DLC means running at the full 40% annual activity. Since no individual tranche asset can exceed 40%, this also means that each individual tranche asset would have to run at 40% annual activity. Because each timeslice is 16.67% of the year, full utilisation requires activity in at least three timeslices. For the first few tranches it runs with activity in three timeslices, then switching to different timeslices in later tranches when these are full (note, there is a tendency to settle on "corner solutions", rather than spreading activity across timeslices, which links back to Potential for different dispatch results on different platforms #1174). Eventually, in the last couple of tranches, all but two timeslices are full, so it can only run with activity of up to 33.33% (2 x 16.67%). Thus, by the time all 10 tranches are appraised, the DLC has been fully invested, but since some of the tranche assets were not active at 40%, there's still some unmet demand remaining in two of the timeslices. At that point the algorithm believes the demand to be unreachable and exits the simulation.

    So, there's a mismatch between the DLC that we're calculating and the actual demand that can be realised via the tranching approach. That said, since we ultimately pool the tranche assets for dispatch, and the combined asset can allocate demand however it wants across capacity (within the overall constraints for the total capacity), DLC capacity actually is sufficient and correct.

    We could try and address this by changing the DLC calculation, or allowing more than DLC capacity investment, but ultimately that would allow too much capacity to be installed which would end up under-utilised in the dispatch. Doesn't seem realistic to me, but maybe it is in the real world? (I have to say, the premise of a wind turbine that can be active for 40% of the year with no specifying what 40% is a bit ridiculous, and maybe wasn't the intention anyway for this model, so the question of "realistic" is a bit out the window anyway. Still, this raises a general point).

    Alternatively, we could try to reframe the way that we do tranching. Rather than looking at an entirely new asset with capacity = capacity_limit_factor * DLC, maybe we need to take into account the capacity that's been selected so far so that the new capacity can allocate activity in a way that's compatible with the overall asset rather than the new capacity in isolation. I imagine possibly that this could be different for divisible assets, which are new physical entities, vs non-divisible assets, in which tranches represent growth of a single entity.

Going forward:

  • Figure out why assets are choosing not to be active. Presumably because it's not profitable to do so, in which case that asset probably shouldn't be selected (which currently it wouldn't be)
  • Figure out what to do if all candidates are inactive. I think part of the problem is that choice of whether it's worth serving demands is based on previous years prices. In reality, prices would go up to the point where it becomes profitable to serve (and/or demand would drop), but there's no mechanism for this
  • I think there's a genuine problem with the way that we're doing investments by breaking assets into independent sub-assets. We need to address this

@tsmbland tsmbland force-pushed the new-lcox-optimisation branch from b3c1582 to c46f693 Compare June 4, 2026 14:12
@tsmbland

tsmbland commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

I've figured out the problem with the marginal and marginal_average models. These were actually using shadow pricing for heat, because the patch wasn't configured properly (fixed in #1324). The problem with this is that if heat uses shadow pricing and electricity etc use cost-based pricing, then heat ends up underpriced compared to the other commodities in the system (shadow prices are set without any context of the cost-based prices we later calculate).

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.

@tsmbland tsmbland force-pushed the new-lcox-optimisation branch from c46f693 to 2686a3e Compare June 5, 2026 10:05
@tsmbland

tsmbland commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

I've tried to come up with a simpler/more generic example to demonstrate the point above about tranching.


Consider the following scenario:
3 timeslices (TS1, TS2, TS3) each of which is 1/3 of the year
We're considering a process with an output coefficient of 1, and an annual activity limit of 2/3

Together, these imply:

  • Max timeslice output for 1 unit of capacity = 1/3
  • Max annual output for 1 unit of capacity = 2/3

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:

  • TS1 DLC: 100 / (1/3) = 300
  • TS2 DLC: 100 / (1/3) = 300
  • TS3 DLC: 100 / (1/3) = 300
  • Annual DLC: 300 / (2/3) = 450
    -> 450

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:

  • TS1: 50
  • TS2: 50
  • TS3: 100

Tranche 2 does the same, giving remaining demand of:

  • TS1: 0
  • TS2: 0
  • TS3: 100

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.

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.

Change the way LCOX appraisal tool works

3 participants