diff --git a/Cargo.lock b/Cargo.lock index fcc4ac84..21952797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,6 +433,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -959,6 +970,7 @@ dependencies = [ "dirs", "documented", "edit", + "erased-serde", "fern", "float-cmp", "highs", @@ -1569,6 +1581,12 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "unicase" version = "2.9.0" diff --git a/Cargo.toml b/Cargo.toml index e10eee49..71b054de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ strum = {version = "0.27.2", features = ["derive"]} documented = "0.9.2" dirs = "6.0.0" edit = "0.1.5" +erased-serde = "0.4.9" [dev-dependencies] map-macro = "0.3.0" diff --git a/src/finance.rs b/src/finance.rs index 9cfc850d..495baa54 100644 --- a/src/finance.rs +++ b/src/finance.rs @@ -2,6 +2,7 @@ use crate::time_slice::TimeSliceID; use crate::units::{Activity, Capacity, Dimensionless, Money, MoneyPerActivity, MoneyPerCapacity}; use indexmap::IndexMap; +use serde::Serialize; /// Calculates the capital recovery factor (CRF) for a given lifetime and discount rate. /// @@ -28,13 +29,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, Serialize)] +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 +67,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 +151,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 +191,7 @@ mod tests { &activity_surpluses, ); - assert_approx_eq!(Dimensionless, result, Dimensionless(expected)); + assert_approx_eq!(Dimensionless, result.value(), Dimensionless(expected)); } #[test] @@ -184,7 +203,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..54b8dff1 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -3,13 +3,16 @@ 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, Money, MoneyPerActivity, MoneyPerCapacity}; use anyhow::Result; use costs::annual_fixed_cost; +use erased_serde::Serialize as ErasedSerialize; use indexmap::IndexMap; +use serde::Serialize; +use std::any::Any; use std::cmp::Ordering; pub mod coefficients; @@ -18,8 +21,32 @@ mod costs; mod optimisation; use coefficients::ObjectiveCoefficients; use float_cmp::approx_eq; +use float_cmp::{ApproxEq, F64Margin}; use optimisation::perform_optimisation; +/// Compares two values with approximate equality checking. +/// +/// Returns `Ordering::Equal` if the values are approximately equal +/// according to the default floating-point margin, otherwise returns +/// their relative ordering based on `a.partial_cmp(&b)`. +/// +/// This is useful when comparing floating-point-based types where exact +/// equality may not be appropriate due to numerical precision limitations. +/// +/// # Panics +/// +/// Panics if `partial_cmp` returns `None` (i.e., if either value is NaN). +fn compare_approx(a: T, b: T) -> Ordering +where + T: Copy + PartialOrd + ApproxEq, +{ + if a.approx_eq(b, F64Margin::default()) { + Ordering::Equal + } else { + a.partial_cmp(&b).unwrap() + } +} + /// The output of investment appraisal required to compare potential investment decisions pub struct AppraisalOutput { /// The asset being appraised @@ -30,8 +57,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,18 +76,141 @@ 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()) + } +} + +/// Supertrait for appraisal metrics that can be serialised and compared. +pub trait MetricTrait: ComparableMetric + ErasedSerialize {} +erased_serde::serialize_trait_object!(MetricTrait); + +/// 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 ComparableMetric: 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 ComparableMetric) -> 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, Serialize)] +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 ComparableMetric for LCOXMetric { + fn value(&self) -> f64 { + self.cost.value() + } + + fn compare(&self, other: &dyn ComparableMetric) -> Ordering { + let other = other + .as_any() + .downcast_ref::() + .expect("Cannot compare metrics of different types"); + + compare_approx(self.cost, other.cost) + } - if approx_eq!(f64, self.metric, other.metric) { - Ordering::Equal + fn as_any(&self) -> &dyn Any { + self + } +} + +/// `LCOXMetric` implements the `MetricTrait` supertrait. +impl MetricTrait for LCOXMetric {} + +/// Net Present Value (NPV) metric +#[derive(Debug, Clone, Serialize)] +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 { + approx_eq!(Money, self.0.annualised_fixed_cost, Money(0.0)) + } +} + +impl ComparableMetric for NPVMetric { + fn value(&self) -> f64 { + if self.is_zero_fixed_cost() { + self.0.total_annualised_surplus.value() } else { - self.metric.partial_cmp(&other.metric).unwrap() + 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 ComparableMetric) -> 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; + compare_approx(other_surplus, self_surplus) + } + // 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(); + compare_approx(other_pi, self_pi) + } + // 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 + } } +/// `NPVMetric` implements the `MetricTrait` supertrait. +impl MetricTrait for NPVMetric {} + /// Calculate LCOX for a hypothetical investment in the given asset. /// /// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can @@ -78,7 +228,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 +238,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 +261,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 +269,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 +279,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 +308,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, @@ -181,3 +324,111 @@ pub fn appraise_investment( }; appraisal_method(model, asset, max_capacity, commodity, coefficients, demand) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::finance::ProfitabilityIndex; + use crate::units::{Money, MoneyPerActivity}; + + #[test] + fn lcox_compare_equal() { + let metric1 = LCOXMetric::new(MoneyPerActivity(10.0)); + let metric2 = LCOXMetric::new(MoneyPerActivity(10.0)); + + assert_eq!(metric1.compare(&metric2), Ordering::Equal); + } + + #[test] + fn lcox_compare_less_is_better() { + let metric1 = LCOXMetric::new(MoneyPerActivity(5.0)); + let metric2 = LCOXMetric::new(MoneyPerActivity(10.0)); + + // metric1 has lower cost, so it's better (Less) + assert_eq!(metric1.compare(&metric2), Ordering::Less); + } + + #[test] + fn lcox_compare_greater_is_worse() { + let metric1 = LCOXMetric::new(MoneyPerActivity(15.0)); + let metric2 = LCOXMetric::new(MoneyPerActivity(10.0)); + + // metric1 has higher cost, so it's worse (Greater) + assert_eq!(metric1.compare(&metric2), Ordering::Greater); + } + + #[test] + fn npv_compare_both_zero_fixed_cost() { + let metric1 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(100.0), + annualised_fixed_cost: Money(0.0), + }); + let metric2 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(50.0), + annualised_fixed_cost: Money(0.0), + }); + + // Compare by surplus: metric1 (100) is better than metric2 (50) + assert_eq!(metric1.compare(&metric2), Ordering::Less); + } + + #[test] + fn npv_compare_both_zero_fixed_cost_equal() { + let metric1 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(100.0), + annualised_fixed_cost: Money(0.0), + }); + let metric2 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(100.0), + annualised_fixed_cost: Money(0.0), + }); + + assert_eq!(metric1.compare(&metric2), Ordering::Equal); + } + + #[test] + fn npv_compare_zero_vs_nonzero_fixed_cost() { + let metric_zero = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(10.0), + annualised_fixed_cost: Money(0.0), + }); + let metric_nonzero = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(1000.0), + annualised_fixed_cost: Money(100.0), + }); + + // Zero fixed cost is always better + assert_eq!(metric_zero.compare(&metric_nonzero), Ordering::Less); + assert_eq!(metric_nonzero.compare(&metric_zero), Ordering::Greater); + } + + #[test] + fn npv_compare_both_nonzero_fixed_cost() { + let metric1 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(200.0), + annualised_fixed_cost: Money(100.0), + }); + let metric2 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(150.0), + annualised_fixed_cost: Money(100.0), + }); + + // Compare by profitability index: 200/100 = 2.0 vs 150/100 = 1.5 + // metric1 is better (higher PI) + assert_eq!(metric1.compare(&metric2), Ordering::Less); + } + + #[test] + fn npv_compare_both_nonzero_fixed_cost_equal() { + let metric1 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(200.0), + annualised_fixed_cost: Money(100.0), + }); + let metric2 = NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(200.0), + annualised_fixed_cost: Money(100.0), + }); + + assert_eq!(metric1.compare(&metric2), Ordering::Equal); + } +}