diff --git a/docs/release_notes/upcoming.md b/docs/release_notes/upcoming.md index e4b0ad259..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 @@ -52,6 +55,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 +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 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/fixture.rs b/src/fixture.rs index a3ef113d5..a9e02c391 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -382,18 +382,16 @@ 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), 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, - 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..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 @@ -793,6 +794,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..69fb6c35f 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -5,15 +5,18 @@ 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; 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; +use std::rc::Rc; pub mod coefficients; mod constraints; @@ -47,7 +50,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, @@ -60,12 +66,35 @@ pub struct AppraisalOutput { /// 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 - pub demand: DemandMap, + pub coefficients: Rc, } 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) { + debug!("Skipping investment option with zero capacity"); + 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) @@ -225,9 +254,9 @@ fn calculate_lcox( asset: &AssetRef, max_capacity: Option, commodity: &Commodity, - coefficients: &ObjectiveCoefficients, + coefficients: &Rc, demand: &DemandMap, -) -> Result { +) -> Result> { let results = perform_optimisation( asset, max_capacity, @@ -238,22 +267,22 @@ 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 { - asset: asset.clone(), - capacity: results.capacity, - activity: results.activity, - unmet_demand: results.unmet_demand, - metric: Box::new(LCOXMetric::new(cost_index)), - coefficients: coefficients.clone(), - demand: demand.clone(), - }) + Ok(AppraisalOutput::new( + asset.clone(), + results, + LCOXMetric::new(cost_index), + coefficients.clone(), + )) } /// Calculate NPV for a hypothetical investment in the given asset. @@ -266,9 +295,9 @@ fn calculate_npv( asset: &AssetRef, max_capacity: Option, commodity: &Commodity, - coefficients: &ObjectiveCoefficients, + coefficients: &Rc, demand: &DemandMap, -) -> Result { +) -> Result> { let results = perform_optimisation( asset, max_capacity, @@ -292,32 +321,30 @@ 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(), - demand: demand.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, max_capacity: Option, commodity: &Commodity, objective_type: &ObjectiveType, - coefficients: &ObjectiveCoefficients, + coefficients: &Rc, demand: &DemandMap, -) -> Result { +) -> Result> { let appraisal_method = match objective_type { ObjectiveType::LevelisedCostOfX => calculate_lcox, ObjectiveType::NetPresentValue => calculate_npv, @@ -337,17 +364,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), @@ -360,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; @@ -551,9 +574,8 @@ 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(), - demand: IndexMap::new(), unmet_demand: IndexMap::new(), metric, }) @@ -862,32 +884,62 @@ mod tests { assert_approx_eq!(f64, outputs[0].metric.value(), best_metric_value); } - /// Test that appraisal outputs with zero capacity are filtered out during sorting. + /// Tests for `AppraisalOutput::new()` method. #[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(); + #[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) }; - sort_appraisal_outputs_by_investment_priority(&mut outputs); + 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), + }); - // All zero capacity outputs should be filtered out - assert_eq!(outputs.len(), 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}"); + } } } 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() }