From 8a04b1a3214a6ea7beb622e6d900fa62fd35d0e6 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 14:39:15 +0000 Subject: [PATCH 1/8] Drop `demand` field from `AppraisalOutput` The demand map is the same for all `AppraisalOutput`s for a given appraisal iteration, so there's no need to store it. If many assets are being appraised, it could result in many clones. --- src/fixture.rs | 2 -- src/output.rs | 7 ++++++- src/simulation/investment.rs | 1 + src/simulation/investment/appraisal.rs | 6 ------ 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/fixture.rs b/src/fixture.rs index a3ef113d5..2e9188bf2 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -382,7 +382,6 @@ pub fn time_slice_info2() -> TimeSliceInfo { pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutput { let activity_coefficients = indexmap! { time_slice.clone() => MoneyPerActivity(0.5) }; let activity = indexmap! { time_slice.clone() => Activity(10.0) }; - let demand = indexmap! { time_slice.clone() => Flow(100.0) }; let unmet_demand = indexmap! { time_slice.clone() => Flow(5.0) }; AppraisalOutput { asset: AssetRef::from(asset), @@ -393,7 +392,6 @@ pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutpu unmet_demand_coefficient: MoneyPerFlow(10000.0), }, activity, - demand, unmet_demand, metric: Box::new(LCOXMetric::new(MoneyPerActivity(4.14))), } diff --git a/src/output.rs b/src/output.rs index edd3df829..67f403183 100644 --- a/src/output.rs +++ b/src/output.rs @@ -467,11 +467,12 @@ impl DebugDataWriter { milestone_year: u32, run_description: &str, appraisal_results: &[AppraisalOutput], + demand: &IndexMap, ) -> Result<()> { for result in appraisal_results { for (time_slice, activity) in &result.activity { let activity_coefficient = result.coefficients.activity_coefficients[time_slice]; - let demand = result.demand[time_slice]; + let demand = demand[time_slice]; let unmet_demand = result.unmet_demand[time_slice]; let row = AppraisalResultsTimeSliceRow { milestone_year, @@ -564,6 +565,7 @@ impl DataWriter { milestone_year: u32, run_description: &str, appraisal_results: &[AppraisalOutput], + demand: &IndexMap, ) -> Result<()> { if let Some(wtr) = &mut self.debug_writer { wtr.write_appraisal_results(milestone_year, run_description, appraisal_results)?; @@ -571,6 +573,7 @@ impl DataWriter { milestone_year, run_description, appraisal_results, + demand, )?; } @@ -1006,6 +1009,7 @@ mod tests { let milestone_year = 2020; let run_description = "test_run".to_string(); let dir = tempdir().unwrap(); + let demand = indexmap! {time_slice.clone() => Flow(100.0) }; // Write appraisal time slice results { @@ -1015,6 +1019,7 @@ mod tests { milestone_year, &run_description, &[appraisal_output], + &demand, ) .unwrap(); writer.flush().unwrap(); diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index a1d6e6ecc..f67c8381f 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -793,6 +793,7 @@ fn select_best_assets( year, &format!("{} {} round {}", &commodity.id, &agent.id, round), &outputs_for_opts, + &demand, )?; sort_appraisal_outputs_by_investment_priority(&mut outputs_for_opts); diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index bf33739e7..e09c472be 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -61,8 +61,6 @@ pub struct AppraisalOutput { pub metric: Box, /// Capacity and activity coefficients used in the appraisal pub coefficients: ObjectiveCoefficients, - /// Demand profile used in the appraisal - pub demand: DemandMap, } impl AppraisalOutput { @@ -252,7 +250,6 @@ fn calculate_lcox( unmet_demand: results.unmet_demand, metric: Box::new(LCOXMetric::new(cost_index)), coefficients: coefficients.clone(), - demand: demand.clone(), }) } @@ -299,7 +296,6 @@ fn calculate_npv( unmet_demand: results.unmet_demand, metric: Box::new(NPVMetric::new(profitability_index)), coefficients: coefficients.clone(), - demand: demand.clone(), }) } @@ -553,7 +549,6 @@ mod tests { capacity: AssetCapacity::Continuous(Capacity(10.0)), coefficients: ObjectiveCoefficients::default(), activity: IndexMap::new(), - demand: IndexMap::new(), unmet_demand: IndexMap::new(), metric, }) @@ -879,7 +874,6 @@ mod tests { capacity: AssetCapacity::Continuous(Capacity(0.0)), coefficients: ObjectiveCoefficients::default(), activity: IndexMap::new(), - demand: IndexMap::new(), unmet_demand: IndexMap::new(), metric, }) From a44c3e7fd75b9a150b288a3350080224c436db92 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 14:47:44 +0000 Subject: [PATCH 2/8] Make `ObjectiveCoefficients` `Rc` to remove need for deep copy --- src/fixture.rs | 4 ++-- src/simulation/investment/appraisal.rs | 13 +++++++------ src/simulation/investment/appraisal/coefficients.rs | 5 +++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/fixture.rs b/src/fixture.rs index 2e9188bf2..a9e02c391 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -386,11 +386,11 @@ pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutpu AppraisalOutput { asset: AssetRef::from(asset), capacity: AssetCapacity::Continuous(Capacity(42.0)), - coefficients: ObjectiveCoefficients { + coefficients: Rc::new(ObjectiveCoefficients { capacity_coefficient: MoneyPerCapacity(2.14), activity_coefficients, unmet_demand_coefficient: MoneyPerFlow(10000.0), - }, + }), activity, unmet_demand, metric: Box::new(LCOXMetric::new(MoneyPerActivity(4.14))), diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index e09c472be..75beb944e 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -14,6 +14,7 @@ use indexmap::IndexMap; use serde::Serialize; use std::any::Any; use std::cmp::Ordering; +use std::rc::Rc; pub mod coefficients; mod constraints; @@ -60,7 +61,7 @@ pub struct AppraisalOutput { /// The comparison metric to compare investment decisions pub metric: Box, /// Capacity and activity coefficients used in the appraisal - pub coefficients: ObjectiveCoefficients, + pub coefficients: Rc, } impl AppraisalOutput { @@ -223,7 +224,7 @@ fn calculate_lcox( asset: &AssetRef, max_capacity: Option, commodity: &Commodity, - coefficients: &ObjectiveCoefficients, + coefficients: &Rc, demand: &DemandMap, ) -> Result { let results = perform_optimisation( @@ -263,7 +264,7 @@ fn calculate_npv( asset: &AssetRef, max_capacity: Option, commodity: &Commodity, - coefficients: &ObjectiveCoefficients, + coefficients: &Rc, demand: &DemandMap, ) -> Result { let results = perform_optimisation( @@ -311,7 +312,7 @@ pub fn appraise_investment( max_capacity: Option, commodity: &Commodity, objective_type: &ObjectiveType, - coefficients: &ObjectiveCoefficients, + coefficients: &Rc, demand: &DemandMap, ) -> Result { let appraisal_method = match objective_type { @@ -547,7 +548,7 @@ mod tests { .map(|(asset, metric)| AppraisalOutput { asset: AssetRef::from(asset), capacity: AssetCapacity::Continuous(Capacity(10.0)), - coefficients: ObjectiveCoefficients::default(), + coefficients: Rc::default(), activity: IndexMap::new(), unmet_demand: IndexMap::new(), metric, @@ -872,7 +873,7 @@ mod tests { .map(|metric| AppraisalOutput { asset: AssetRef::from(asset.clone()), capacity: AssetCapacity::Continuous(Capacity(0.0)), - coefficients: ObjectiveCoefficients::default(), + coefficients: Rc::default(), activity: IndexMap::new(), unmet_demand: IndexMap::new(), metric, diff --git a/src/simulation/investment/appraisal/coefficients.rs b/src/simulation/investment/appraisal/coefficients.rs index e09511e5d..58135f74d 100644 --- a/src/simulation/investment/appraisal/coefficients.rs +++ b/src/simulation/investment/appraisal/coefficients.rs @@ -8,6 +8,7 @@ use crate::time_slice::{TimeSliceID, TimeSliceInfo}; use crate::units::{MoneyPerActivity, MoneyPerCapacity, MoneyPerFlow}; use indexmap::IndexMap; use std::collections::HashMap; +use std::rc::Rc; /// Map storing cost coefficients for an asset. /// @@ -32,7 +33,7 @@ pub fn calculate_coefficients_for_assets( assets: &[AssetRef], prices: &CommodityPrices, year: u32, -) -> HashMap { +) -> HashMap> { assets .iter() .map(|asset| { @@ -48,7 +49,7 @@ pub fn calculate_coefficients_for_assets( calculate_coefficients_for_npv(asset, &model.time_slice_info, prices, year) } }; - (asset.clone(), coefficient) + (asset.clone(), Rc::new(coefficient)) }) .collect() } From 299568a0574be2e5a42786b37aed96e1807c5a89 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 15:02:24 +0000 Subject: [PATCH 3/8] appraise_investment: Return `None` for zero-capacity results, rather than filtering later --- src/simulation/investment.rs | 7 +- src/simulation/investment/appraisal.rs | 115 ++++++++++++------------- 2 files changed, 57 insertions(+), 65 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index f67c8381f..a7428d338 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -776,7 +776,7 @@ fn select_best_assets( continue; } - let output = appraise_investment( + if let Some(output) = appraise_investment( model, asset, max_capacity, @@ -784,8 +784,9 @@ fn select_best_assets( objective_type, &coefficients[asset], &demand, - )?; - outputs_for_opts.push(output); + )? { + outputs_for_opts.push(output); + } } // Save appraisal results diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 75beb944e..2787adf9b 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -5,6 +5,7 @@ use crate::asset::{Asset, AssetCapacity, AssetRef}; use crate::commodity::Commodity; use crate::finance::{ProfitabilityIndex, lcox, profitability_index}; use crate::model::Model; +use crate::simulation::investment::appraisal::optimisation::ResultsMap; use crate::time_slice::TimeSliceID; use crate::units::{Activity, Capacity, Money, MoneyPerActivity, MoneyPerCapacity}; use anyhow::Result; @@ -48,7 +49,10 @@ where } } -/// The output of investment appraisal required to compare potential investment decisions +/// The output of investment appraisal required to compare potential investment decisions. +/// +/// Note that this struct should be created with the [`AppraisalOutput::new`] constructor to check +/// the parameters. pub struct AppraisalOutput { /// The asset being appraised pub asset: AssetRef, @@ -65,6 +69,30 @@ pub struct AppraisalOutput { } impl AppraisalOutput { + /// Create a new `AppraisalOutput`. + /// + /// Returns `None` if the capacity is zero, otherwise `Some(AppraisalOutput)` with the specified + /// parameters. + pub fn new( + asset: AssetRef, + results: ResultsMap, + metric: T, + coefficients: Rc, + ) -> Option { + if results.capacity.total_capacity() == Capacity(0.0) { + return None; + } + + Some(Self { + asset, + capacity: results.capacity, + activity: results.activity, + unmet_demand: results.unmet_demand, + metric: Box::new(metric), + coefficients, + }) + } + /// Compare this appraisal to another on the basis of the comparison metric. /// /// Note that if the metrics are approximately equal (as determined by the [`approx_eq!`] macro) @@ -226,7 +254,7 @@ fn calculate_lcox( commodity: &Commodity, coefficients: &Rc, demand: &DemandMap, -) -> Result { +) -> Result> { let results = perform_optimisation( asset, max_capacity, @@ -244,14 +272,12 @@ fn calculate_lcox( &coefficients.activity_coefficients, ); - Ok(AppraisalOutput { - asset: asset.clone(), - capacity: results.capacity, - activity: results.activity, - unmet_demand: results.unmet_demand, - metric: Box::new(LCOXMetric::new(cost_index)), - coefficients: coefficients.clone(), - }) + Ok(AppraisalOutput::new( + asset.clone(), + results, + LCOXMetric::new(cost_index), + coefficients.clone(), + )) } /// Calculate NPV for a hypothetical investment in the given asset. @@ -266,7 +292,7 @@ fn calculate_npv( commodity: &Commodity, coefficients: &Rc, demand: &DemandMap, -) -> Result { +) -> Result> { let results = perform_optimisation( asset, max_capacity, @@ -290,22 +316,21 @@ fn calculate_npv( &coefficients.activity_coefficients, ); - Ok(AppraisalOutput { - asset: asset.clone(), - capacity: results.capacity, - activity: results.activity, - unmet_demand: results.unmet_demand, - metric: Box::new(NPVMetric::new(profitability_index)), - coefficients: coefficients.clone(), - }) + Ok(AppraisalOutput::new( + asset.clone(), + results, + NPVMetric::new(profitability_index), + coefficients.clone(), + )) } -/// Appraise the given investment with the specified objective type +/// Appraise the given investment with the specified parameters. /// /// # Returns /// -/// The `AppraisalOutput` produced by the selected appraisal method. The `metric` field is -/// comparable with other appraisals of the same type (npv/lcox). +/// - An error, if something fatal has occurred (i.e. the optimisation failed) +/// - `None` if this is not a viable option (e.g. because the returned capacity would be zero) +/// - `Some(AppraisalOutput)` with the appraisal result if it is a viable option pub fn appraise_investment( model: &Model, asset: &AssetRef, @@ -314,7 +339,7 @@ pub fn appraise_investment( objective_type: &ObjectiveType, coefficients: &Rc, demand: &DemandMap, -) -> Result { +) -> Result> { let appraisal_method = match objective_type { ObjectiveType::LevelisedCostOfX => calculate_lcox, ObjectiveType::NetPresentValue => calculate_npv, @@ -334,17 +359,11 @@ fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { /// 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)); +/// 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. +pub fn sort_appraisal_outputs_by_investment_priority(outputs_for_opts: &mut [AppraisalOutput]) { 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), @@ -857,32 +876,4 @@ mod tests { // 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: Rc::default(), - activity: 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); - } } From 1c912e70673f8eb95ea7fd16630be97b8ee676fc Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 15:10:32 +0000 Subject: [PATCH 4/8] Log when skipping zero-capacity investment option --- src/simulation/investment/appraisal.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 2787adf9b..688cd5aef 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -12,6 +12,7 @@ use anyhow::Result; use costs::annual_fixed_cost; use erased_serde::Serialize as ErasedSerialize; use indexmap::IndexMap; +use log::debug; use serde::Serialize; use std::any::Any; use std::cmp::Ordering; @@ -80,6 +81,7 @@ impl AppraisalOutput { coefficients: Rc, ) -> Option { if results.capacity.total_capacity() == Capacity(0.0) { + debug!("Skipping investment option with zero capacity"); return None; } From ebb5569750e28152ff7095e6ebc8dd7ebb1d93c6 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 15:20:13 +0000 Subject: [PATCH 5/8] Skip investments with zero activity for LCOX Fixes #1126. --- src/finance.rs | 23 ++++++++++++++++------- src/simulation/investment/appraisal.rs | 7 +++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/finance.rs b/src/finance.rs index 495baa54b..bdb296576 100644 --- a/src/finance.rs +++ b/src/finance.rs @@ -74,12 +74,14 @@ pub fn profitability_index( } /// Calculates annual LCOX based on capacity and activity. +/// +/// If the total activity is zero, then it returns `None`, otherwise `Some` LCOX value. pub fn lcox( capacity: Capacity, annual_fixed_cost: MoneyPerCapacity, activity: &IndexMap, activity_costs: &IndexMap, -) -> MoneyPerActivity { +) -> Option { // Calculate the annualised fixed costs let annualised_fixed_cost = annual_fixed_cost * capacity; @@ -92,7 +94,8 @@ pub fn lcox( total_activity_costs += activity_cost * *activity; } - (annualised_fixed_cost + total_activity_costs) / total_activity + (total_activity > Activity(0.0)) + .then(|| (annualised_fixed_cost + total_activity_costs) / total_activity) } #[cfg(test)] @@ -223,20 +226,26 @@ mod tests { 100.0, 50.0, vec![("winter", "day", 10.0), ("summer", "night", 20.0)], vec![("winter", "day", 5.0), ("summer", "night", 3.0)], - 170.33333333333334 // (100*50 + 10*5 + 20*3) / (10+20) = 5110/30 + Some(170.33333333333334) // (100*50 + 10*5 + 20*3) / (10+20) = 5110/30 )] #[case( 50.0, 100.0, vec![("winter", "day", 25.0)], vec![("winter", "day", 0.0)], - 200.0 // (50*100 + 25*0) / 25 = 5000/25 + Some(200.0) // (50*100 + 25*0) / 25 = 5000/25 + )] + #[case( + 50.0, 100.0, + vec![("winter", "day", 0.0)], + vec![("winter", "day", 0.0)], + None // (50*0 + 25*0) / 0 = not feasible )] fn lcox_works( #[case] capacity: f64, #[case] annual_fixed_cost: f64, #[case] activity_data: Vec<(&str, &str, f64)>, #[case] cost_data: Vec<(&str, &str, f64)>, - #[case] expected: f64, + #[case] expected: Option, ) { let activity = activity_data .into_iter() @@ -271,7 +280,7 @@ mod tests { &activity_costs, ); - let expected = MoneyPerActivity(expected); - assert_approx_eq!(MoneyPerActivity, result, expected); + let expected = expected.map(MoneyPerActivity); + assert_approx_eq!(Option, result, expected); } } diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 688cd5aef..dee4e6c9d 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -267,12 +267,15 @@ fn calculate_lcox( highs::Sense::Minimise, )?; - let cost_index = lcox( + let Some(cost_index) = lcox( results.capacity.total_capacity(), coefficients.capacity_coefficient, &results.activity, &coefficients.activity_coefficients, - ); + ) else { + debug!("Skipping investment option with zero activity"); + return Ok(None); + }; Ok(AppraisalOutput::new( asset.clone(), From cfee637c54ecba30e64e0268a26b709f3eb2d78d Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 15:50:02 +0000 Subject: [PATCH 6/8] Mention LCOX bugfix in release notes --- docs/release_notes/upcoming.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release_notes/upcoming.md b/docs/release_notes/upcoming.md index e4b0ad259..cf26a38d3 100644 --- a/docs/release_notes/upcoming.md +++ b/docs/release_notes/upcoming.md @@ -52,6 +52,7 @@ ready to be released, carry out the following steps: - Users can now set demand to zero in `demand.csv` ([#871]) - Fix: sign for levies of type `net` was wrong for inputs ([#969]) - Fix `--overwrite` option for `save-graphs` command ([#1001]) +- Skip assets with zero activity when appraising with LCOX ([#1129]) [#767]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/767 [#868]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/868 @@ -70,3 +71,4 @@ ready to be released, carry out the following steps: [#1021]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1021 [#1022]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1022 [#1030]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1030 +[#1129]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1129 From d3760751c21e36f497031cde9004ff22627c9557 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 15:56:17 +0000 Subject: [PATCH 7/8] Add some missing details to release notes --- docs/release_notes/upcoming.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release_notes/upcoming.md b/docs/release_notes/upcoming.md index cf26a38d3..36e7fe99d 100644 --- a/docs/release_notes/upcoming.md +++ b/docs/release_notes/upcoming.md @@ -31,6 +31,9 @@ ready to be released, carry out the following steps: - Allow for adding both a `prod` and `cons` levy to a commodity ([#969]) - Availability limits can now be provided at multiple levels for a process ([#1018]) - Pricing strategy can now vary by commodity ([#1021]) +- Users can now specify investment limits ([#1096]) +- It is now required to provide unit types for every commodity; this is used for validation + ([#1110]) ## Experimental features @@ -71,4 +74,6 @@ ready to be released, carry out the following steps: [#1021]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1021 [#1022]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1022 [#1030]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1030 +[#1096]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1096 +[#1110]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1110 [#1129]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1129 From 2a2a58702b8cd2dda7c20834629a1a5cdbc29233 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 13 Feb 2026 16:15:50 +0000 Subject: [PATCH 8/8] Add test for `AppraisalOutput::new()` --- src/simulation/investment/appraisal.rs | 65 +++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index dee4e6c9d..69fb6c35f 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -381,11 +381,13 @@ mod tests { use super::*; use crate::agent::AgentID; use crate::finance::ProfitabilityIndex; - use crate::fixture::{agent_id, asset, process, region_id}; + use crate::fixture::{agent_id, asset, process, region_id, time_slice}; use crate::process::Process; use crate::region::RegionID; - use crate::units::{Money, MoneyPerActivity}; + use crate::simulation::investment::appraisal::optimisation::ResultsMap; + use crate::units::{Flow, Money, MoneyPerActivity, MoneyPerFlow}; use float_cmp::assert_approx_eq; + use indexmap::indexmap; use rstest::rstest; use std::rc::Rc; @@ -881,4 +883,63 @@ mod tests { // non-commissioned asset prioritised because it has a slightly better metric assert_approx_eq!(f64, outputs[0].metric.value(), best_metric_value); } + + /// Tests for `AppraisalOutput::new()` method. + #[rstest] + #[case(AssetCapacity::Continuous(Capacity(10.0)), true, "non_zero_capacity")] + #[case( + AssetCapacity::Continuous(Capacity(0.0)), + false, + "zero_continuous_capacity" + )] + #[case( + AssetCapacity::Discrete(0, Capacity(1.0)), + false, + "zero_discrete_capacity" + )] + fn appraisal_output_new_capacity_validation( + asset: Asset, + time_slice: TimeSliceID, + #[case] capacity: AssetCapacity, + #[case] should_succeed: bool, + #[case] description: &str, + ) { + // Arrange + let asset_ref = AssetRef::from(asset); + let activity = indexmap! { time_slice.clone() => Activity(5.0) }; + let unmet_demand = indexmap! { time_slice => Flow(2.0) }; + + let results = ResultsMap { + capacity, + activity: activity.clone(), + unmet_demand: unmet_demand.clone(), + }; + + let metric = LCOXMetric::new(MoneyPerActivity(42.0)); + let coefficients = Rc::new(ObjectiveCoefficients { + capacity_coefficient: MoneyPerCapacity(1.5), + activity_coefficients: indexmap! { + TimeSliceID { season: "winter".into(), time_of_day: "day".into() } => MoneyPerActivity(0.8) + }, + unmet_demand_coefficient: MoneyPerFlow(1000.0), + }); + + // Act + let result = AppraisalOutput::new(asset_ref.clone(), results, metric, coefficients.clone()); + + // Assert + if should_succeed { + assert!(result.is_some(), "Should succeed for case: {description}"); + let output = result.unwrap(); + assert_eq!(output.asset, asset_ref); + assert_eq!(output.capacity, capacity); + assert_eq!(output.activity, activity); + assert_eq!(output.unmet_demand, unmet_demand); + // Note: Cannot directly compare Rc as it doesn't implement PartialEq + assert!(Rc::ptr_eq(&output.coefficients, &coefficients)); + assert_approx_eq!(f64, output.metric.value(), 42.0); + } else { + assert!(result.is_none(), "Should fail for case: {description}"); + } + } }