diff --git a/src/finance.rs b/src/finance.rs index 9cfc850d..8b4c5952 100644 --- a/src/finance.rs +++ b/src/finance.rs @@ -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 { + /// 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, activity_surpluses: &IndexMap, -) -> Dimensionless { +) -> ProfitabilityIndex { // Calculate the annualised fixed costs let annualised_fixed_cost = annual_fixed_cost * capacity; @@ -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. @@ -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, @@ -172,7 +190,7 @@ mod tests { &activity_surpluses, ); - assert_approx_eq!(Dimensionless, result, Dimensionless(expected)); + assert_approx_eq!(Dimensionless, result.value(), Dimensionless(expected)); } #[test] @@ -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] diff --git a/src/fixture.rs b/src/fixture.rs index cd444386..e552bc5f 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -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, }; @@ -391,7 +392,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))), } } diff --git a/src/output.rs b/src/output.rs index 48edd6e4..f1968f76 100644 --- a/src/output.rs +++ b/src/output.rs @@ -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)?; } diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index a5b8cc77..705f567f 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -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; @@ -30,8 +31,8 @@ pub struct AppraisalOutput { pub activity: IndexMap, /// 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, /// Capacity and activity coefficients used in the appraisal pub coefficients: ObjectiveCoefficients, /// Demand profile used in the appraisal @@ -49,16 +50,144 @@ 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; +} + +/// 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 } + } +} - if approx_eq!(f64, self.metric, other.metric) { +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::() + .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(ProfitabilityIndex); + +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.0.annualised_fixed_cost == Money(0.0) + } +} + +impl MetricTrait for NPVMetric { + fn value(&self) -> f64 { + if self.is_zero_fixed_cost() { + self.0.total_annualised_surplus.value() + } else { + self.0.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::() + .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.0.total_annualised_surplus; + let other_surplus = other.0.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.0.value(); + let other_pi = other.0.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, + } + } + + fn as_any(&self) -> &dyn Any { + self + } } /// Calculate LCOX for a hypothetical investment in the given asset. @@ -78,7 +207,6 @@ fn calculate_lcox( coefficients: &ObjectiveCoefficients, demand: &DemandMap, ) -> Result { - // Perform optimisation to calculate capacity, activity and unmet demand let results = perform_optimisation( asset, max_capacity, @@ -89,23 +217,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(), }) @@ -116,8 +240,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, @@ -126,7 +248,6 @@ fn calculate_npv( coefficients: &ObjectiveCoefficients, demand: &DemandMap, ) -> Result { - // Perform optimisation to calculate capacity, activity and unmet demand let results = perform_optimisation( asset, max_capacity, @@ -137,24 +258,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(), }) @@ -165,7 +287,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,