diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index d7a92792..c5caea73 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -13,13 +13,14 @@ use anyhow::{Context, Result, ensure}; use indexmap::IndexMap; use itertools::{Itertools, chain}; use log::debug; -use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::fmt::Display; pub mod appraisal; use appraisal::coefficients::calculate_coefficients_for_assets; -use appraisal::{AppraisalOutput, appraise_investment}; +use appraisal::{ + AppraisalOutput, appraise_investment, sort_appraisal_outputs_by_investment_priority, +}; /// A map of demand across time slices for a specific market type DemandMap = IndexMap; @@ -745,16 +746,7 @@ fn select_best_assets( &outputs_for_opts, )?; - // Sort assets by appraisal metric - let assets_sorted_by_metric = outputs_for_opts - .into_iter() - .filter(|output| output.capacity.total_capacity() > Capacity(0.0)) - .sorted_by(|output1, output2| match output1.compare_metric(output2) { - // If equal, we fall back on comparing asset properties - Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset), - cmp => cmp, - }) - .collect_vec(); + sort_appraisal_outputs_by_investment_priority(&mut outputs_for_opts); // Check if all options have zero capacity. If so, we cannot meet demand, so have to bail // out. @@ -765,20 +757,15 @@ fn select_best_assets( // - known issue with the NPV objective // (see https://github.com/EnergySystemsModellingLab/MUSE2/issues/716). ensure!( - !assets_sorted_by_metric.is_empty(), + !outputs_for_opts.is_empty(), "No feasible investment options for commodity '{}' after appraisal", &commodity.id ); // Warn if there are multiple equally good assets - warn_on_equal_appraisal_outputs( - &assets_sorted_by_metric, - &agent.id, - &commodity.id, - region_id, - ); + warn_on_equal_appraisal_outputs(&outputs_for_opts, &agent.id, &commodity.id, region_id); - let best_output = assets_sorted_by_metric.into_iter().next().unwrap(); + let best_output = outputs_for_opts.into_iter().next().unwrap(); // Log the selected asset debug!( @@ -819,16 +806,6 @@ fn is_any_remaining_demand(demand: &DemandMap) -> bool { demand.values().any(|flow| *flow > Flow(0.0)) } -/// Compare assets as a fallback if metrics are equal. -/// -/// Commissioned assets are ordered before uncommissioned and newer before older. -/// -/// Used as a fallback to sort assets when they have equal appraisal tool outputs. -fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { - (asset2.is_commissioned(), asset2.commission_year()) - .cmp(&(asset1.is_commissioned(), asset1.commission_year())) -} - /// Update capacity of chosen asset, if needed, and update both asset options and chosen assets fn update_assets( mut best_asset: AssetRef, @@ -875,15 +852,12 @@ fn update_assets( #[cfg(test)] mod tests { use super::*; - use crate::agent::AgentID; - use crate::asset::Asset; use crate::commodity::Commodity; use crate::fixture::{ - agent_id, asset, process, process_activity_limits_map, process_flows_map, region_id, - svd_commodity, time_slice, time_slice_info, time_slice_info2, + asset, process, process_activity_limits_map, process_flows_map, svd_commodity, time_slice, + time_slice_info, time_slice_info2, }; use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow}; - use crate::region::RegionID; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; use crate::units::{Capacity, Flow, FlowPerActivity, MoneyPerFlow}; use indexmap::indexmap; @@ -971,32 +945,4 @@ mod tests { // Maximum = 20.0 assert_eq!(result, Capacity(20.0)); } - - #[rstest] - fn compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) { - let process = Rc::new(process); - let capacity = Capacity(2.0); - let asset1 = Asset::new_commissioned( - agent_id.clone(), - process.clone(), - region_id.clone(), - capacity, - 2015, - ) - .unwrap(); - let asset2 = - Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap(); - let asset3 = - Asset::new_commissioned(agent_id, process, region_id.clone(), capacity, 2010).unwrap(); - - assert!(compare_asset_fallback(&asset1, &asset1).is_eq()); - assert!(compare_asset_fallback(&asset2, &asset2).is_eq()); - assert!(compare_asset_fallback(&asset3, &asset3).is_eq()); - assert!(compare_asset_fallback(&asset1, &asset2).is_lt()); - assert!(compare_asset_fallback(&asset2, &asset1).is_gt()); - assert!(compare_asset_fallback(&asset1, &asset3).is_lt()); - assert!(compare_asset_fallback(&asset3, &asset1).is_gt()); - assert!(compare_asset_fallback(&asset3, &asset2).is_lt()); - assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); - } } diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 5eef02cf..85e97808 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -1,12 +1,12 @@ //! Calculation for investment tools such as Levelised Cost of X (LCOX) and Net Present Value (NPV). use super::DemandMap; use crate::agent::ObjectiveType; -use crate::asset::{AssetCapacity, AssetRef}; +use crate::asset::{Asset, AssetCapacity, AssetRef}; use crate::commodity::Commodity; use crate::finance::{ProfitabilityIndex, lcox, profitability_index}; use crate::model::Model; use crate::time_slice::TimeSliceID; -use crate::units::{Activity, Money, MoneyPerActivity, MoneyPerCapacity}; +use crate::units::{Activity, Capacity, Money, MoneyPerActivity, MoneyPerCapacity}; use anyhow::Result; use costs::annual_fixed_cost; use erased_serde::Serialize as ErasedSerialize; @@ -325,12 +325,48 @@ pub fn appraise_investment( appraisal_method(model, asset, max_capacity, commodity, coefficients, demand) } +/// Compare assets as a fallback if metrics are equal. +/// +/// Commissioned assets are ordered before uncommissioned and newer before older. +/// +/// Used as a fallback to sort assets when they have equal appraisal tool outputs. +fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { + (asset2.is_commissioned(), asset2.commission_year()) + .cmp(&(asset1.is_commissioned(), asset1.commission_year())) +} + +/// Sort appraisal outputs by their investment priority. +/// +/// Primarily this is decided by their appraisal metric. +/// When appraisal metrics are equal, a tie-breaker fallback is used. Commissioned assets +/// are preferred over uncommissioned assets, and newer assets are preferred over older +/// ones. The function does not guarantee that all ties will be resolved. +/// +/// Assets with zero capacity are filtered out before sorting, +/// as their metric would be `NaN` and could cause the program to panic. So the length +/// of the returned vector may be less than the input. +/// +pub fn sort_appraisal_outputs_by_investment_priority(outputs_for_opts: &mut Vec) { + outputs_for_opts.retain(|output| output.capacity.total_capacity() > Capacity(0.0)); + outputs_for_opts.sort_by(|output1, output2| match output1.compare_metric(output2) { + // If equal, we fall back on comparing asset properties + Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset), + cmp => cmp, + }); +} + #[cfg(test)] mod tests { use super::*; + use crate::agent::AgentID; use crate::finance::ProfitabilityIndex; + use crate::fixture::{agent_id, asset, process, region_id}; + use crate::process::Process; + use crate::region::RegionID; use crate::units::{Money, MoneyPerActivity}; + use float_cmp::assert_approx_eq; use rstest::rstest; + use std::rc::Rc; /// Parametrised tests for LCOX metric comparison. #[rstest] @@ -465,4 +501,392 @@ mod tests { "Failed comparison for case: {description}" ); } + + #[rstest] + fn compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) { + let process = Rc::new(process); + let capacity = Capacity(2.0); + let asset1 = Asset::new_commissioned( + agent_id.clone(), + process.clone(), + region_id.clone(), + capacity, + 2015, + ) + .unwrap(); + let asset2 = + Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap(); + let asset3 = + Asset::new_commissioned(agent_id, process, region_id.clone(), capacity, 2010).unwrap(); + + assert!(compare_asset_fallback(&asset1, &asset1).is_eq()); + assert!(compare_asset_fallback(&asset2, &asset2).is_eq()); + assert!(compare_asset_fallback(&asset3, &asset3).is_eq()); + assert!(compare_asset_fallback(&asset1, &asset2).is_lt()); + assert!(compare_asset_fallback(&asset2, &asset1).is_gt()); + assert!(compare_asset_fallback(&asset1, &asset3).is_lt()); + assert!(compare_asset_fallback(&asset3, &asset1).is_gt()); + assert!(compare_asset_fallback(&asset3, &asset2).is_lt()); + assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); + } + + /// Creates appraisal from corresponding assets and metrics + /// + /// # Panics + /// Panics if `assets` and `metrics` have different lengths + fn appraisal_outputs( + assets: Vec, + metrics: Vec>, + ) -> Vec { + assert_eq!( + assets.len(), + metrics.len(), + "assets and metrics must have the same length" + ); + + assets + .into_iter() + .zip(metrics) + .map(|(asset, metric)| AppraisalOutput { + asset: AssetRef::from(asset), + capacity: AssetCapacity::Continuous(Capacity(10.0)), + coefficients: ObjectiveCoefficients::default(), + activity: IndexMap::new(), + demand: IndexMap::new(), + unmet_demand: IndexMap::new(), + metric, + }) + .collect() + } + + /// Creates appraisal outputs with given metrics. + /// Copies the provided default asset for each metric. + fn appraisal_outputs_with_investment_priority_invariant_to_assets( + metrics: Vec>, + asset: &Asset, + ) -> Vec { + let assets = vec![asset.clone(); metrics.len()]; + appraisal_outputs(assets, metrics) + } + + /// Test sorting by LCOX metric when invariant to asset properties + #[rstest] + fn appraisal_sort_by_lcox_metric(asset: Asset) { + let metrics: Vec> = vec![ + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(3.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(7.0))), + ]; + + let mut outputs = + appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + assert_approx_eq!(f64, outputs[0].metric.value(), 3.0); // Best (lowest) + assert_approx_eq!(f64, outputs[1].metric.value(), 5.0); + assert_approx_eq!(f64, outputs[2].metric.value(), 7.0); // Worst (highest) + } + + /// Test sorting by NPV profitability index when invariant to asset properties + #[rstest] + fn appraisal_sort_by_npv_metric(asset: Asset) { + let metrics: Vec> = vec![ + Box::new(NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(200.0), + annualised_fixed_cost: Money(100.0), + })), + Box::new(NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(300.0), + annualised_fixed_cost: Money(100.0), + })), + Box::new(NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(150.0), + annualised_fixed_cost: Money(100.0), + })), + ]; + + let mut outputs = + appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + // Higher profitability index is better, so should be sorted: 3.0, 2.0, 1.5 + assert_approx_eq!(f64, outputs[0].metric.value(), 3.0); // Best (highest PI) + assert_approx_eq!(f64, outputs[1].metric.value(), 2.0); + assert_approx_eq!(f64, outputs[2].metric.value(), 1.5); // Worst (lowest PI) + } + + /// Test that NPV metrics with zero annual fixed cost are prioritised above all others + /// when invariant to asset properties + #[rstest] + fn appraisal_sort_by_npv_metric_zero_afc_prioritised(asset: Asset) { + let metrics: Vec> = vec![ + // Very high profitability index but non-zero AFC + Box::new(NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(1000.0), + annualised_fixed_cost: Money(100.0), + })), + // Zero AFC with modest surplus - should be prioritised first + Box::new(NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(50.0), + annualised_fixed_cost: Money(0.0), + })), + // Another high profitability index but non-zero AFC + Box::new(NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(500.0), + annualised_fixed_cost: Money(50.0), + })), + ]; + + let mut outputs = + appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + // Zero AFC should be first despite lower absolute surplus value + assert_approx_eq!(f64, outputs[0].metric.value(), 50.0); // Zero AFC (uses surplus) + assert_approx_eq!(f64, outputs[1].metric.value(), 10.0); // PI = 1000/100 + assert_approx_eq!(f64, outputs[2].metric.value(), 10.0); // PI = 500/50 + } + + /// Test that mixing LCOX and NPV metrics causes a runtime panic during comparison + #[rstest] + #[should_panic(expected = "Cannot compare metrics of different types")] + fn appraisal_sort_by_mixed_metrics_panics(asset: Asset) { + let metrics: Vec> = vec![ + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(NPVMetric::new(ProfitabilityIndex { + total_annualised_surplus: Money(200.0), + annualised_fixed_cost: Money(100.0), + })), + Box::new(LCOXMetric::new(MoneyPerActivity(3.0))), + ]; + + let mut outputs = + appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + // This should panic when trying to compare different metric types + sort_appraisal_outputs_by_investment_priority(&mut outputs); + } + + /// Test that when metrics are equal, commissioned assets are sorted by commission year (newer first) + #[rstest] + fn appraisal_sort_by_commission_year_when_metrics_equal( + process: Process, + region_id: RegionID, + agent_id: AgentID, + ) { + let process_rc = Rc::new(process); + let capacity = Capacity(10.0); + let commission_years = [2015, 2020, 2010]; + + let assets: Vec<_> = commission_years + .iter() + .map(|&year| { + Asset::new_commissioned( + agent_id.clone(), + process_rc.clone(), + region_id.clone(), + capacity, + year, + ) + .unwrap() + }) + .collect(); + + // All metrics have the same value + let metrics: Vec> = vec![ + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + ]; + + let mut outputs = appraisal_outputs(assets, metrics); + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + // Should be sorted by commission year, newest first: 2020, 2015, 2010 + assert_eq!(outputs[0].asset.commission_year(), 2020); + assert_eq!(outputs[1].asset.commission_year(), 2015); + assert_eq!(outputs[2].asset.commission_year(), 2010); + } + + /// Test that when metrics and commission years are equal, the original order is preserved + #[rstest] + fn appraisal_sort_maintains_order_when_all_equal(process: Process, region_id: RegionID) { + let process_rc = Rc::new(process); + let capacity = Capacity(10.0); + let commission_year = 2015; + let agent_ids = ["agent1", "agent2", "agent3"]; + + let assets: Vec<_> = agent_ids + .iter() + .map(|&id| { + Asset::new_commissioned( + AgentID(id.into()), + process_rc.clone(), + region_id.clone(), + capacity, + commission_year, + ) + .unwrap() + }) + .collect(); + + let metrics: Vec> = vec![ + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + ]; + + let mut outputs = appraisal_outputs(assets.clone(), metrics); + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + // Verify order is preserved - should match the original agent_ids array + for (&expected_id, output) in agent_ids.iter().zip(outputs) { + assert_eq!(output.asset.agent_id(), Some(&AgentID(expected_id.into()))); + } + } + + /// Test that commissioned assets are prioritised over non-commissioned assets when metrics are equal + #[rstest] + fn appraisal_sort_commissioned_before_uncommissioned_when_metrics_equal( + process: Process, + region_id: RegionID, + agent_id: AgentID, + ) { + let process_rc = Rc::new(process); + let capacity = Capacity(10.0); + + // Create a mix of commissioned and candidate (non-commissioned) assets + let commissioned_asset_newer = Asset::new_commissioned( + agent_id.clone(), + process_rc.clone(), + region_id.clone(), + capacity, + 2020, + ) + .unwrap(); + + let commissioned_asset_older = Asset::new_commissioned( + agent_id.clone(), + process_rc.clone(), + region_id.clone(), + capacity, + 2015, + ) + .unwrap(); + + let candidate_asset = + Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap(); + + let assets = vec![ + candidate_asset.clone(), + commissioned_asset_older.clone(), + candidate_asset.clone(), + commissioned_asset_newer.clone(), + ]; + + // All metrics have identical values to test fallback ordering + let metrics: Vec> = vec![ + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), + ]; + + let mut outputs = appraisal_outputs(assets, metrics); + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + // Commissioned assets should be prioritised first + assert!(outputs[0].asset.is_commissioned()); + assert!(outputs[0].asset.commission_year() == 2020); + assert!(outputs[1].asset.is_commissioned()); + assert!(outputs[1].asset.commission_year() == 2015); + + // Non-commissioned assets should come after + assert!(!outputs[2].asset.is_commissioned()); + assert!(!outputs[3].asset.is_commissioned()); + } + + /// Test that appraisal metric is prioritised over asset properties when sorting + #[rstest] + fn appraisal_metric_is_prioritised_over_asset_properties( + process: Process, + region_id: RegionID, + agent_id: AgentID, + ) { + let process_rc = Rc::new(process); + let capacity = Capacity(10.0); + + // Create a mix of commissioned and candidate (non-commissioned) assets + let commissioned_asset_newer = Asset::new_commissioned( + agent_id.clone(), + process_rc.clone(), + region_id.clone(), + capacity, + 2020, + ) + .unwrap(); + + let commissioned_asset_older = Asset::new_commissioned( + agent_id.clone(), + process_rc.clone(), + region_id.clone(), + capacity, + 2015, + ) + .unwrap(); + + let candidate_asset = + Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap(); + + let assets = vec![ + candidate_asset.clone(), + commissioned_asset_older.clone(), + candidate_asset.clone(), + commissioned_asset_newer.clone(), + ]; + + // Make one metric slightly better than all others + let baseline_metric_value = 5.0; + let best_metric_value = baseline_metric_value - 1e-5; + let metrics: Vec> = vec![ + Box::new(LCOXMetric::new(MoneyPerActivity(best_metric_value))), + Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))), + Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))), + Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))), + ]; + + let mut outputs = appraisal_outputs(assets, metrics); + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + // non-commissioned asset prioritised because it has a slightly better metric + assert_approx_eq!(f64, outputs[0].metric.value(), best_metric_value); + } + + /// Test that appraisal outputs with zero capacity are filtered out during sorting. + #[rstest] + fn appraisal_sort_filters_zero_capacity_outputs(asset: Asset) { + let metrics: Vec> = vec![ + Box::new(LCOXMetric::new(MoneyPerActivity(f64::NAN))), + Box::new(LCOXMetric::new(MoneyPerActivity(f64::NAN))), + Box::new(LCOXMetric::new(MoneyPerActivity(f64::NAN))), + ]; + + // Create outputs with zero capacity + let mut outputs: Vec = metrics + .into_iter() + .map(|metric| AppraisalOutput { + asset: AssetRef::from(asset.clone()), + capacity: AssetCapacity::Continuous(Capacity(0.0)), + coefficients: ObjectiveCoefficients::default(), + activity: IndexMap::new(), + demand: IndexMap::new(), + unmet_demand: IndexMap::new(), + metric, + }) + .collect(); + + sort_appraisal_outputs_by_investment_priority(&mut outputs); + + // All zero capacity outputs should be filtered out + assert_eq!(outputs.len(), 0); + } } diff --git a/src/simulation/investment/appraisal/coefficients.rs b/src/simulation/investment/appraisal/coefficients.rs index 0c17f2b7..e09511e5 100644 --- a/src/simulation/investment/appraisal/coefficients.rs +++ b/src/simulation/investment/appraisal/coefficients.rs @@ -15,6 +15,7 @@ use std::collections::HashMap; /// 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)] +#[cfg_attr(test, derive(Default))] pub struct ObjectiveCoefficients { /// Cost per unit of capacity pub capacity_coefficient: MoneyPerCapacity,