Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 40 additions & 10 deletions src/finance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,34 @@ pub fn annual_capital_cost(
capital_cost * crf
}

/// Represents the profitability index of an investment
/// in terms of its annualised components.
#[derive(Debug, Clone, Copy)]
pub struct ProfitabilityIndex {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm wondering if we really need this now that we have NPVMetric. Could just have profitability_index return a tuple and store each value in NPVMetric. Not sure if that's better?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I generally prefer structs over tuples but don't feel strongly either way about this

/// The total annualised surplus of an asset
pub total_annualised_surplus: Money,
/// The total annualised fixed cost of an asset
pub annualised_fixed_cost: Money,
}

impl ProfitabilityIndex {
/// Calculates the value of the profitability index.
pub fn value(&self) -> Dimensionless {
assert!(
self.annualised_fixed_cost != Money(0.0),
"Annualised fixed cost cannot be zero when calculating profitability index."
);
self.total_annualised_surplus / self.annualised_fixed_cost
}
}

/// Calculates an annual profitability index based on capacity and activity.
pub fn profitability_index(
capacity: Capacity,
annual_fixed_cost: MoneyPerCapacity,
activity: &IndexMap<TimeSliceID, Activity>,
activity_surpluses: &IndexMap<TimeSliceID, MoneyPerActivity>,
) -> Dimensionless {
) -> ProfitabilityIndex {
// Calculate the annualised fixed costs
let annualised_fixed_cost = annual_fixed_cost * capacity;

Expand All @@ -45,7 +66,10 @@ pub fn profitability_index(
total_annualised_surplus += activity_surplus * *activity;
}

total_annualised_surplus / annualised_fixed_cost
ProfitabilityIndex {
total_annualised_surplus,
annualised_fixed_cost,
}
}

/// Calculates annual LCOX based on capacity and activity.
Expand Down Expand Up @@ -126,12 +150,6 @@ mod tests {
vec![("q1", "peak", 40.0)],
0.04 // Expected PI: (5*40) / (50*100) = 200/5000 = 0.04
)]
#[case(
0.0, 100.0,
vec![("winter", "day", 10.0)],
vec![("winter", "day", 50.0)],
f64::INFINITY // Zero capacity case
)]
fn profitability_index_works(
#[case] capacity: f64,
#[case] annual_fixed_cost: f64,
Expand Down Expand Up @@ -172,7 +190,7 @@ mod tests {
&activity_surpluses,
);

assert_approx_eq!(Dimensionless, result, Dimensionless(expected));
assert_approx_eq!(Dimensionless, result.value(), Dimensionless(expected));
}

#[test]
Expand All @@ -184,7 +202,19 @@ mod tests {

let result =
profitability_index(capacity, annual_fixed_cost, &activity, &activity_surpluses);
assert_eq!(result, Dimensionless(0.0));
assert_eq!(result.value(), Dimensionless(0.0));
}

#[test]
#[should_panic(expected = "Annualised fixed cost cannot be zero")]
fn profitability_index_panics_on_zero_cost() {
let result = profitability_index(
Capacity(0.0),
MoneyPerCapacity(100.0),
&indexmap! {},
&indexmap! {},
);
result.value();
}

#[rstest]
Expand Down
3 changes: 2 additions & 1 deletion src/fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::process::{
ProcessInvestmentConstraintsMap, ProcessMap, ProcessParameter, ProcessParameterMap,
};
use crate::region::RegionID;
use crate::simulation::investment::appraisal::LCOXMetric;
use crate::simulation::investment::appraisal::{
AppraisalOutput, coefficients::ObjectiveCoefficients,
};
Expand Down Expand Up @@ -360,7 +361,7 @@ pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutpu
activity,
demand,
unmet_demand,
metric: 4.14,
metric: Box::new(LCOXMetric::new(MoneyPerActivity(4.14))),
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ impl DebugDataWriter {
region_id: result.asset.region_id().clone(),
capacity: result.capacity,
capacity_coefficient: result.coefficients.capacity_coefficient,
metric: result.metric,
metric: result.metric.value(),
};
self.appraisal_results_writer.serialize(row)?;
}
Expand Down
177 changes: 152 additions & 25 deletions src/simulation/investment/appraisal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ use super::DemandMap;
use crate::agent::ObjectiveType;
use crate::asset::AssetRef;
use crate::commodity::Commodity;
use crate::finance::{lcox, profitability_index};
use crate::finance::{ProfitabilityIndex, lcox, profitability_index};
use crate::model::Model;
use crate::time_slice::TimeSliceID;
use crate::units::{Activity, Capacity};
use crate::units::{Activity, Capacity, Dimensionless, Money, MoneyPerActivity, MoneyPerCapacity};
use anyhow::Result;
use costs::annual_fixed_cost;
use indexmap::IndexMap;
use std::any::Any;
use std::cmp::Ordering;

pub mod coefficients;
Expand All @@ -30,8 +31,8 @@ pub struct AppraisalOutput {
pub activity: IndexMap<TimeSliceID, Activity>,
/// The hypothetical unmet demand following investment in this asset
pub unmet_demand: DemandMap,
/// The comparison metric to compare investment decisions (lower is better)
pub metric: f64,
/// The comparison metric to compare investment decisions
pub metric: Box<dyn MetricTrait>,
/// Capacity and activity coefficients used in the appraisal
pub coefficients: ObjectiveCoefficients,
/// Demand profile used in the appraisal
Expand All @@ -49,16 +50,149 @@ impl AppraisalOutput {
/// possible, which is why we use a more approximate comparison.
pub fn compare_metric(&self, other: &Self) -> Ordering {
assert!(
!(self.metric.is_nan() || other.metric.is_nan()),
!(self.metric.value().is_nan() || other.metric.value().is_nan()),
"Appraisal metric cannot be NaN"
);
self.metric.compare(other.metric.as_ref())
}
}

/// Trait for appraisal metrics that can be compared.
///
/// Implementers define how their values should be compared to determine
/// which investment option is preferable through the `compare` method.
pub trait MetricTrait: Any + Send + Sync {
/// Returns the numeric value of this metric.
fn value(&self) -> f64;

/// Compares this metric with another of the same type.
///
/// Returns `Ordering::Less` if `self` is better than `other`,
/// `Ordering::Greater` if `other` is better, or `Ordering::Equal`
/// if they are approximately equal.
///
/// # Panics
///
/// Panics if `other` is not the same concrete type as `self`.
fn compare(&self, other: &dyn MetricTrait) -> Ordering;

/// Helper for downcasting to enable type-safe comparison.
fn as_any(&self) -> &dyn Any;
}
Comment on lines +60 to +81
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The MetricTrait design relies on runtime type checking via downcasting, which can panic if metrics of different types are compared. While this is documented in the trait, it would be safer to use an enum-based approach (e.g., enum Metric { LCOX(LCOXMetric), NPV(NPVMetric) }) which would provide compile-time type safety and eliminate the possibility of panics from type mismatches. The current design assumes all appraisals being compared use the same objective type, but this invariant is not enforced by the type system.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

@Aurashk Aurashk Jan 8, 2026

Choose a reason for hiding this comment

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

In case people are curious about this, I think it means an implementation without traits that looks like this:

// REMOVED: MetricTrait and its implementations for LCOXMetric and NPVMetric

// CHANGED: AppraisalOutput.metric field type
pub struct AppraisalOutput {
    // ... other fields unchanged ...
    /// The comparison metric to compare investment decisions
    pub metric: Metric,  // Changed from: Box<dyn MetricTrait>
    // ... other fields unchanged ...
}

// CHANGED: compare_metric implementation
impl AppraisalOutput {
    pub fn compare_metric(&self, other: &Self) -> Ordering {
        assert!(
            !self.metric.value().is_nan() && !other.metric.value().is_nan(),  // Changed: combined assertion
            "Appraisal metric cannot be NaN"
        );
        self.metric.compare(&other.metric)  // Changed: direct method call instead of as_ref()
    }
}

// NEW: Metric enum replaces the trait-based approach
/// Enum representing different types of investment appraisal metrics.
///
/// This enum provides compile-time type safety when comparing metrics,
/// ensuring that only compatible metric types can be compared without
/// risk of runtime panics from type mismatches.
#[derive(Debug, Clone)]
pub enum Metric {
    /// Levelised Cost of X metric variant
    LCOX(LCOXMetric),
    /// Net Present Value metric variant
    NPV(NPVMetric),
}

impl Metric {
    /// Returns the numeric value of this metric for display or logging purposes.
    pub fn value(&self) -> f64 {
        match self {
            Metric::LCOX(lcox) => lcox.cost.value(),
            Metric::NPV(npv) => npv.value(),
        }
    }

    /// Compares this metric with another of potentially different type.
    ///
    /// Returns `Ordering::Less` if `self` represents a better investment than `other`,
    /// `Ordering::Greater` if `other` is better, or `Ordering::Equal` if they are
    /// approximately equal.
    ///
    /// # Panics
    ///
    /// Panics if attempting to compare metrics of different types (e.g., LCOX vs NPV),
    /// as this represents a logical error in the calling code where incompatible
    /// investment appraisals are being compared.
    pub fn compare(&self, other: &Self) -> Ordering {
        match (self, other) {
            (Metric::LCOX(self_lcox), Metric::LCOX(other_lcox)) => {
                self_lcox.compare(other_lcox)
            }
            (Metric::NPV(self_npv), Metric::NPV(other_npv)) => self_npv.compare(other_npv),
            _ => panic!(
                "Cannot compare metrics of different types: attempting to compare {:?} with {:?}",
                std::mem::discriminant(self),
                std::mem::discriminant(other)
            ),
        }
    }
}

// CHANGED: LCOXMetric - removed MetricTrait implementation, added private compare method
impl LCOXMetric {
    // ... new() unchanged ...

    /// Compares this LCOX metric with another LCOX metric.
    ///
    /// Returns `Ordering::Less` if `self` has lower cost (better),
    /// `Ordering::Greater` if `other` has lower cost (better),
    /// or `Ordering::Equal` if costs are approximately equal.
    fn compare(&self, other: &Self) -> Ordering {
        if approx_eq!(MoneyPerActivity, self.cost, other.cost) {
            Ordering::Equal
        } else {
            self.cost.partial_cmp(&other.cost).unwrap()
        }
    }
}

// CHANGED: NPVMetric - removed MetricTrait implementation, made value() and compare() private
impl NPVMetric {
    // ... new() unchanged ...

    /// Returns the numeric value used for comparison.
    ///
    /// When annual fixed cost is zero, the profitability index becomes infinite,
    /// so we return the total annualised surplus instead for meaningful comparison.
    fn value(&self) -> f64 {  // Changed from pub to private
        // ... implementation unchanged ...
    }

    // ... is_zero_fixed_cost() unchanged ...

    /// Compares this NPV metric with another NPV metric.
    /// (Documentation unchanged)
    fn compare(&self, other: &Self) -> Ordering {  // Changed from pub to private, signature changed
        // ... implementation unchanged ...
    }
}

// CHANGED: Return type wrapped in Metric enum
fn calculate_lcox(/* params unchanged */) -> Result<AppraisalOutput> {
    // ... unchanged until ...
    Ok(AppraisalOutput {
        // ... other fields unchanged ...
        metric: Metric::LCOX(LCOXMetric::new(cost_index)),  // Changed: wrapped in enum
        // ... other fields unchanged ...
    })
}

// CHANGED: Return type wrapped in Metric enum
fn calculate_npv(/* params unchanged */) -> Result<AppraisalOutput> {
    // ... unchanged until ...
    Ok(AppraisalOutput {
        // ... other fields unchanged ...
        metric: Metric::NPV(NPVMetric::new(profitability_index)),  // Changed: wrapped in enum
        // ... other fields unchanged ...
    })
}

So the metric enum is like a union that can be either an NPVMetric or an LCOXMetric. I do think it has a good point here about preferring compile-time type checking over run-time type checking

Copy link
Collaborator

Choose a reason for hiding this comment

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

It is an interesting point, but I think the traits approach will be a bit cleaner when we get to the custom Serialize implementations. Sure, you can still do this with an enum, but I think it'll probably look cleaner to separate these things out for the different metric types.


if approx_eq!(f64, self.metric, other.metric) {
/// Levelised Cost of X (LCOX) metric.
///
/// Represents the average cost per unit of output. Lower values indicate
/// more cost-effective investments.
#[derive(Debug, Clone)]
pub struct LCOXMetric {
/// The calculated cost value for this LCOX metric
pub cost: MoneyPerActivity,
}

impl LCOXMetric {
/// Creates a new `LCOXMetric` with the given cost.
pub fn new(cost: MoneyPerActivity) -> Self {
Self { cost }
}
}

impl MetricTrait for LCOXMetric {
fn value(&self) -> f64 {
self.cost.value()
}

fn compare(&self, other: &dyn MetricTrait) -> Ordering {
let other = other
.as_any()
.downcast_ref::<Self>()
.expect("Cannot compare metrics of different types");

if approx_eq!(MoneyPerActivity, self.cost, other.cost) {
Ordering::Equal
} else {
self.metric.partial_cmp(&other.metric).unwrap()
// Lower cost is better
self.cost.partial_cmp(&other.cost).unwrap()
}
}

fn as_any(&self) -> &dyn Any {
self
}
}

/// Net Present Value (NPV) metric
#[derive(Debug, Clone)]
pub struct NPVMetric {
/// Profitability index data for this NPV metric
pub profitability_index: ProfitabilityIndex,
Copy link
Collaborator

Choose a reason for hiding this comment

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

You could also make this a newtype, i.e. not give the field a name, which would make things less verbose:

pub struct NPVMetric(ProfitabilityIndex);

You can then get at the field with my_metric.0.

}

impl NPVMetric {
/// Creates a new `NPVMetric` with the given profitability index.
pub fn new(profitability_index: ProfitabilityIndex) -> Self {
Self {
profitability_index,
}
}

/// Returns true if this metric represents a zero fixed cost case.
fn is_zero_fixed_cost(&self) -> bool {
self.profitability_index.annualised_fixed_cost == Money(0.0)
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The zero fixed cost check uses direct equality comparison with Money(0.0), which may not be robust for floating-point values. Consider using an approximate equality check or a small epsilon threshold to handle potential floating-point precision issues, especially since other parts of the codebase use approx_eq! for such comparisons.

Suggested change
self.profitability_index.annualised_fixed_cost == Money(0.0)
approx_eq!(Money, self.profitability_index.annualised_fixed_cost, Money(0.0))

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@tsmbland @alexdewar I was wondering about this the other day too. Even though very small positive denominators don't break the profitability index calculation mathematically, is it practically more stable to still use surplus instead if the denominator is very small?

Copy link
Collaborator

Choose a reason for hiding this comment

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

That's a good point. It probably makes sense to use approx_eq! instead, so that we don't get subtly different behaviours depending on small rounding errors (see also #893).

I don't think we need this as a separate helper method though, as it's only used it one place. See my comment below about refactoring.

}
}

impl MetricTrait for NPVMetric {
fn value(&self) -> f64 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bear in mind that this is the value that gets presented in the output file, and it's not ideal that this has a different meaning depending on the value of the denominator. I think we do need something like what Alex suggested here #1027 (comment)

If we're doing this then we probably don't even need a .value() implementation for MetricTrait

Copy link
Collaborator Author

@Aurashk Aurashk Jan 8, 2026

Choose a reason for hiding this comment

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

Agree it would be better to make it clear what the metric actually is, although I'm not totally clear on how you'd avoid having value altogether since it could either be an lcox metric or an npv?

We might just want a metric value() and a metric label() in the output file. And label is implemented to return 'lcox', 'npv annualised surplus' and 'npv profitability index' accordingly in the trait structs. Unless it's useful seeing both values where the profitability index denominator is nonzero

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should implement the Serialize trait for NPVMetric and LCOXMetric rather than having a method for getting a scalar like this. Then we can write the outputs in a custom way, without having to restrict ourselves to a single number, e.g. for NPV, we could write both the numerator and the denominator.

I think the easiest way to implement this is with a supertrait (without extra methods): https://doc.rust-lang.org/rust-by-example/trait/supertraits.html

So we'd have:

  • A trait for comparing two metrics, like the current MetricTrait (maybe rename to ComparableMetric or something)
  • A supertrait which is defined as:
pub trait MetricTrait: ComparableMetric + Serialize {}

Does that make sense?

That said, adding custom Serialize implementations is something we could do later, so if you'd rather open an issue for it instead, feel free.

if self.is_zero_fixed_cost() {
self.profitability_index.total_annualised_surplus.value()
} else {
self.profitability_index.value().value()
}
}

/// Higher profitability index values indicate more profitable investments.
/// When annual fixed cost is zero, the profitability index is infinite and
/// total surplus is used for comparison instead.
fn compare(&self, other: &dyn MetricTrait) -> Ordering {
let other = other
.as_any()
.downcast_ref::<Self>()
.expect("Cannot compare metrics of different types");

// Handle comparison based on fixed cost status
match (self.is_zero_fixed_cost(), other.is_zero_fixed_cost()) {
// Both have zero fixed cost: compare total surplus (higher is better)
(true, true) => {
let self_surplus = self.profitability_index.total_annualised_surplus;
let other_surplus = other.profitability_index.total_annualised_surplus;

if approx_eq!(Money, self_surplus, other_surplus) {
Ordering::Equal
} else {
other_surplus.partial_cmp(&self_surplus).unwrap()
}
}
// Both have non-zero fixed cost: compare profitability index (higher is better)
(false, false) => {
let self_pi = self.profitability_index.value();
let other_pi = other.profitability_index.value();

if approx_eq!(Dimensionless, self_pi, other_pi) {
Ordering::Equal
} else {
other_pi.partial_cmp(&self_pi).unwrap()
}
}
// Zero fixed cost is always better than non-zero fixed cost
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
}
}
Comment on lines +157 to +191
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The critical new behavior of NPVMetric comparison with zero fixed costs lacks test coverage. The new logic handles three scenarios: both with zero fixed cost, both with non-zero fixed cost, and mixed cases. Only the panic behavior on calling .value() with zero cost is tested. Consider adding unit tests for NPVMetric.compare() that verify: 1) Comparison of two metrics with zero fixed cost correctly uses total surplus, 2) Comparison of two metrics with non-zero fixed cost correctly uses profitability index, and 3) Zero fixed cost metrics are correctly preferred over non-zero fixed cost metrics.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'll wait for your review @alexdewar but I do think some tests would be nice

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed.

I think the logic for this method is a bit convoluted as it is. There will be different ways you could go about it, but here's how I'd split it up:

  1. Make a helper function for doing approx comparisons (as you're doing this in a bunch of places), returning Ordering::Equal if two values are approx equal. (You might need to make this generic, in which case I think T will have trait bounds PartialEq + ApproxEq<Margin=F64>.)
  2. Write a method which tries to compare the metrics based on profitability index, returning None if either has an AFC that's approx zero.
  3. Then this method can just call this method, falling back on comparing AFC, e.g.:
// Using `compare_approx` as I mentioned in 1 and newtypes
self.try_compare_pi(other).unwrap_or_else(|| compare_approx(self.0.annualised_fixed_cost, other.0.annualised_fixed_cost)

Does this make sense?


fn as_any(&self) -> &dyn Any {
self
}
}

/// Calculate LCOX for a hypothetical investment in the given asset.
Expand All @@ -78,7 +212,6 @@ fn calculate_lcox(
coefficients: &ObjectiveCoefficients,
demand: &DemandMap,
) -> Result<AppraisalOutput> {
// Perform optimisation to calculate capacity, activity and unmet demand
let results = perform_optimisation(
asset,
max_capacity,
Expand All @@ -89,23 +222,19 @@ fn calculate_lcox(
highs::Sense::Minimise,
)?;

// Calculate LCOX for the hypothetical investment
let annual_fixed_cost = coefficients.capacity_coefficient;
let activity_costs = &coefficients.activity_coefficients;
let cost_index = lcox(
results.capacity,
annual_fixed_cost,
coefficients.capacity_coefficient,
&results.activity,
activity_costs,
&coefficients.activity_coefficients,
);

// Return appraisal output
Ok(AppraisalOutput {
asset: asset.clone(),
capacity: results.capacity,
activity: results.activity,
unmet_demand: results.unmet_demand,
metric: cost_index.value(),
metric: Box::new(LCOXMetric::new(cost_index)),
coefficients: coefficients.clone(),
demand: demand.clone(),
})
Expand All @@ -116,8 +245,6 @@ fn calculate_lcox(
/// # Returns
///
/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand.
/// The returned `metric` is the negative of the profitability index so that, like LCOX,
/// lower metric values indicate a more desirable investment (i.e. higher NPV).
fn calculate_npv(
model: &Model,
asset: &AssetRef,
Expand All @@ -126,7 +253,6 @@ fn calculate_npv(
coefficients: &ObjectiveCoefficients,
demand: &DemandMap,
) -> Result<AppraisalOutput> {
// Perform optimisation to calculate capacity, activity and unmet demand
let results = perform_optimisation(
asset,
max_capacity,
Expand All @@ -137,24 +263,25 @@ fn calculate_npv(
highs::Sense::Maximise,
)?;

// Calculate profitability index for the hypothetical investment
let annual_fixed_cost = annual_fixed_cost(asset);
let activity_surpluses = &coefficients.activity_coefficients;
assert!(
annual_fixed_cost >= MoneyPerCapacity(0.0),
"The current NPV calculation does not support negative annual fixed costs"
);

let profitability_index = profitability_index(
results.capacity,
annual_fixed_cost,
&results.activity,
activity_surpluses,
&coefficients.activity_coefficients,
);

// Return appraisal output
// Higher profitability index is better, so we make it negative for comparison
Ok(AppraisalOutput {
asset: asset.clone(),
capacity: results.capacity,
activity: results.activity,
unmet_demand: results.unmet_demand,
metric: -profitability_index.value(),
metric: Box::new(NPVMetric::new(profitability_index)),
coefficients: coefficients.clone(),
demand: demand.clone(),
})
Expand All @@ -165,7 +292,7 @@ fn calculate_npv(
/// # Returns
///
/// The `AppraisalOutput` produced by the selected appraisal method. The `metric` field is
/// comparable across methods where lower values indicate a better investment.
/// comparable with other appraisals of the same type (npv/lcox).
pub fn appraise_investment(
model: &Model,
asset: &AssetRef,
Expand Down