diff --git a/src/fixture.rs b/src/fixture.rs index 1d215148a..3b63a5bae 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -14,10 +14,8 @@ 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, -}; +use crate::simulation::investment::appraisal::coefficients::ActivityCoefficients; +use crate::simulation::investment::appraisal::{AppraisalOptimisation, LCOXMetric, MetricTrait}; use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel}; use crate::units::{ Activity, ActivityPerCapacity, Capacity, Dimensionless, Flow, MoneyPerActivity, @@ -398,21 +396,27 @@ pub fn time_slice_info2() -> TimeSliceInfo { } #[fixture] -pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutput { +pub fn appraisal_output( + asset: Asset, + time_slice: TimeSliceID, +) -> ( + AssetRef, + AppraisalOptimisation, + Box, + Arc, +) { let activity_coefficients = indexmap! { time_slice.clone() => MoneyPerActivity(0.5) }; - let market_costs = indexmap! { time_slice.clone() => MoneyPerActivity(0.4) }; let activity = indexmap! { time_slice.clone() => Activity(10.0) }; let unmet_demand = indexmap! { time_slice.clone() => Flow(5.0) }; - AppraisalOutput { - asset: AssetRef::from(asset), - coefficients: Arc::new(ObjectiveCoefficients { - activity_coefficients, - market_costs, - }), - activity, - unmet_demand, - metric: Some(Box::new(LCOXMetric::new(MoneyPerActivity(4.14)))), - } + ( + AssetRef::from(asset), + AppraisalOptimisation { + activity, + unmet_demand, + }, + Box::new(LCOXMetric::new(MoneyPerActivity(4.14))), + Arc::new(activity_coefficients), + ) } #[cfg(test)] diff --git a/src/output.rs b/src/output.rs index fb68ed345..b4f659d04 100644 --- a/src/output.rs +++ b/src/output.rs @@ -4,7 +4,8 @@ use crate::asset::{Asset, AssetID, AssetRef}; use crate::commodity::CommodityID; use crate::process::ProcessID; use crate::region::RegionID; -use crate::simulation::investment::appraisal::AppraisalOutput; +use crate::simulation::investment::appraisal::coefficients::ActivityCoefficients; +use crate::simulation::investment::appraisal::{AppraisalMetrics, AppraisalOptimisation}; use crate::simulation::optimisation::{FlowMap, Solution}; use crate::simulation::prices::PriceMap; use crate::time_slice::TimeSliceID; @@ -13,9 +14,11 @@ use anyhow::{Context, Result, ensure}; use csv; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::fs::File; use std::path::{Path, PathBuf}; +use std::sync::Arc; pub mod metadata; use metadata::write_metadata; @@ -476,17 +479,17 @@ impl DebugDataWriter { &mut self, milestone_year: u32, run_description: &str, - appraisal_results: &[AppraisalOutput], + appraisal_results: &AppraisalMetrics, ) -> Result<()> { - for result in appraisal_results { + for (asset, metric) in appraisal_results { let row = AppraisalResultsRow { milestone_year, run_description: self.with_context(run_description), - asset_id: result.asset.id(), - process_id: result.asset.process_id().clone(), - region_id: result.asset.region_id().clone(), - capacity: result.asset.total_capacity(), - metric: result.metric.as_ref().map(|m| m.value()), + asset_id: asset.id(), + process_id: asset.process_id().clone(), + region_id: asset.region_id().clone(), + capacity: asset.total_capacity(), + metric: Some(metric.value()), }; self.appraisal_results_writer.serialize(row)?; } @@ -499,20 +502,21 @@ impl DebugDataWriter { &mut self, milestone_year: u32, run_description: &str, - appraisal_results: &[AppraisalOutput], + optimisations: &HashMap, + activity_coefficients: &HashMap>, demand: &IndexMap, ) -> Result<()> { - for result in appraisal_results { - for (time_slice, activity) in &result.activity { - let activity_coefficient = result.coefficients.activity_coefficients[time_slice]; + for (asset, optimisation) in optimisations { + for (time_slice, activity) in &optimisation.activity { + let activity_coefficient = activity_coefficients[asset][time_slice]; let demand = demand[time_slice]; - let unmet_demand = result.unmet_demand[time_slice]; + let unmet_demand = optimisation.unmet_demand[time_slice]; let row = AppraisalResultsTimeSliceRow { milestone_year, run_description: self.with_context(run_description), - asset_id: result.asset.id(), - process_id: result.asset.process_id().clone(), - region_id: result.asset.region_id().clone(), + asset_id: asset.id(), + process_id: asset.process_id().clone(), + region_id: asset.region_id().clone(), time_slice: time_slice.clone(), activity: *activity, activity_coefficient, @@ -601,7 +605,9 @@ impl DataWriter { &mut self, milestone_year: u32, run_description: &str, - appraisal_results: &[AppraisalOutput], + appraisal_results: &AppraisalMetrics, + optimisations: &HashMap, + activity_coefficients: &HashMap>, demand: &IndexMap, ) -> Result<()> { if let Some(wtr) = &mut self.debug { @@ -609,7 +615,8 @@ impl DataWriter { wtr.write_appraisal_time_slice_results( milestone_year, run_description, - appraisal_results, + optimisations, + activity_coefficients, demand, )?; } @@ -710,7 +717,7 @@ mod tests { use super::*; use crate::asset::AssetPool; use crate::fixture::{appraisal_output, asset, assets, commodity_id, region_id, time_slice}; - use crate::simulation::investment::appraisal::AppraisalOutput; + use crate::simulation::investment::appraisal::MetricTrait; use crate::time_slice::TimeSliceID; use indexmap::indexmap; use itertools::{Itertools, assert_equal}; @@ -1039,7 +1046,15 @@ mod tests { } #[rstest] - fn write_appraisal_results(asset: Asset, appraisal_output: AppraisalOutput) { + fn write_appraisal_results( + asset: Asset, + appraisal_output: ( + AssetRef, + AppraisalOptimisation, + Box, + Arc, + ), + ) { let milestone_year = 2020; let run_description = "test_run".to_string(); let dir = tempdir().unwrap(); @@ -1047,8 +1062,10 @@ mod tests { // Write appraisal results { let mut writer = DebugDataWriter::create(dir.path()).unwrap(); + let (asset_ref, _, metric, _) = appraisal_output; + let metrics = indexmap! { asset_ref => metric }; writer - .write_appraisal_results(milestone_year, &run_description, &[appraisal_output]) + .write_appraisal_results(milestone_year, &run_description, &metrics) .unwrap(); writer.flush().unwrap(); } @@ -1075,7 +1092,12 @@ mod tests { #[rstest] fn write_appraisal_time_slice_results( asset: Asset, - appraisal_output: AppraisalOutput, + appraisal_output: ( + AssetRef, + AppraisalOptimisation, + Box, + Arc, + ), time_slice: TimeSliceID, ) { let milestone_year = 2020; @@ -1086,11 +1108,15 @@ mod tests { // Write appraisal time slice results { let mut writer = DebugDataWriter::create(dir.path()).unwrap(); + let (asset_ref, optimisation, _, coefficients) = appraisal_output; + let optimisations = HashMap::from([(asset_ref.clone(), optimisation)]); + let activity_coefficients = HashMap::from([(asset_ref, coefficients)]); writer .write_appraisal_time_slice_results( milestone_year, &run_description, - &[appraisal_output], + &optimisations, + &activity_coefficients, &demand, ) .unwrap(); diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 297f6c4d8..5d0ac1be3 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -21,10 +21,12 @@ use std::collections::HashMap; use strum::IntoEnumIterator; pub mod appraisal; -use appraisal::coefficients::calculate_coefficients_for_assets; +use appraisal::coefficients::{ + calculate_activity_coefficients_for_assets, calculate_market_costs_for_assets, +}; use appraisal::{ - AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs, - sort_and_filter_appraisal_outputs, + AppraisalMetrics, AppraisalOptimisation, calculate_metric, make_investment_decision, + perform_optimisation, }; /// A map of demand across time slices for a specific market @@ -313,7 +315,7 @@ pub fn get_demand_limiting_capacity( /// Print debug message if there are multiple equally good outputs fn log_on_equal_appraisal_outputs( - outputs: &[AppraisalOutput], + outputs: &[AssetRef], agent_id: &AgentID, commodity_id: &CommodityID, region_id: &RegionID, @@ -322,13 +324,12 @@ fn log_on_equal_appraisal_outputs( return; } - let num_identical = count_equal_and_best_appraisal_outputs(outputs); + let num_identical = outputs.len().saturating_sub(1); if num_identical > 0 { let asset_details = outputs[..=num_identical] .iter() - .map(|output| { - let asset = &output.asset; + .map(|asset| { format!( "Process ID: '{}' (State: {}{}, Commission year: {})", asset.process_id(), @@ -349,7 +350,7 @@ fn log_on_equal_appraisal_outputs( } /// Get the best assets for meeting demand for the given commodity -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub fn select_best_assets( model: &Model, mut opt_assets: Vec, @@ -375,9 +376,12 @@ pub fn select_best_assets( // Store commissioned units available for retention and replace assets with single units let mut available_retention_units = prepare_commissioned_assets_for_retention(&mut opt_assets); - // Calculate coefficients for all asset options according to the agent's objective - let coefficients = - calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year); + // Activity coefficients are shared by all appraisal metrics; market costs depend on the + // selected objective and are calculated separately. + let activity_coefficients = + calculate_activity_coefficients_for_assets(model, &opt_assets, prices, year); + let market_costs = + calculate_market_costs_for_assets(model, objective_type, &opt_assets, prices, year); // Iteratively select the best asset until demand is met let mut round = 0; @@ -394,47 +398,44 @@ pub fn select_best_assets( region_id ); - // Appraise all options in parallel: each asset's appraisal is independent (all shared - // state is read-only within this block), so we can safely use Rayon here. - // Each HiGHS solve inside `appraise_investment` is configured to use only one thread - // (via `parallel="off"`) to avoid over-subscription. - let mut outputs: Vec = opt_assets + // Optimise all options in parallel. Each HiGHS solve is independent and configured to + // use only one thread to avoid over-subscription. + let mut optimisations: HashMap = opt_assets .par_iter() - .map(|asset| -> Result> { - // Skip assets with zero capacity + .map(|asset| -> Result> { if asset.total_capacity() <= Capacity(0.0) { return Ok(None); } - Ok(Some(appraise_investment( - model, - asset, - commodity, - objective_type, - &coefficients[asset], - &demand, - )?)) + Ok(Some(( + asset.clone(), + perform_optimisation( + model, + asset, + commodity, + &activity_coefficients[asset], + &demand, + )?, + ))) }) .collect::>>()? // propagate any solver error .into_iter() .flatten() .collect(); - // Save appraisal results - writer.write_appraisal_debug_info( - year, - &format!("{} {} round {}", commodity.id, agent.id, round), - &outputs, - &demand, - )?; - - // Sort by investment priority and discard non-feasible options - let num_nonfeasible = sort_and_filter_appraisal_outputs(&mut outputs); + // Discard options which cannot contribute to meeting demand. + let num_appraisals = optimisations.len(); + let feasible_assets: Vec<_> = optimisations + .iter() + .filter(|(_, optimisation)| optimisation.has_activity()) + .map(|(asset, _)| asset.clone()) + .collect(); + let num_nonfeasible = num_appraisals - feasible_assets.len(); // If none of the remaining options are feasible, we terminate the loop. We may still be // able to meet the full demands with assets selected so far, so we continue anyway with a // warning. - if outputs.is_empty() { + if feasible_assets.is_empty() { warn!( "Investment appraisal completed with unmet demand for commodity '{}', region '{}', \ year '{}', agent '{}'. {} non-feasible investments were not considered. \ @@ -444,29 +445,61 @@ pub fn select_best_assets( break; } + // Calculate metrics + let outputs: AppraisalMetrics = feasible_assets + .into_iter() + .map(|asset| { + let metric = calculate_metric( + &asset, + objective_type, + &market_costs[&asset], + &optimisations[&asset], + ); + (asset, metric) + }) + .collect(); + + // Save appraisal results + writer.write_appraisal_debug_info( + year, + &format!("{} {} round {}", commodity.id, agent.id, round), + &outputs, + &optimisations, + &activity_coefficients, + &demand, + )?; + + // Select the best option according to the agent's decision rule. This returns a Vec of + // best options, in case there are multiple equally good options. + let outputs = make_investment_decision(outputs, &agent.decision_rule)?; + // Warn if there are multiple equally good assets log_on_equal_appraisal_outputs(&outputs, &agent.id, &commodity.id, region_id); - let best_output = outputs.into_iter().next().unwrap(); + // Select the first option from the best options. + let best_asset = outputs.into_iter().next().unwrap(); // Log the selected asset debug!( "Selected {} asset '{}' (capacity: {})", - best_output.asset.state(), - best_output.asset.process_id(), - best_output.asset.total_capacity() + best_asset.state(), + best_asset.process_id(), + best_asset.total_capacity() ); // Record the selected asset and update the remaining selection state. record_asset_selection( - best_output.asset, + best_asset.clone(), &mut opt_assets, &mut remaining_agent_addition_limits, &mut available_retention_units, &mut best_assets, ); - demand = best_output.unmet_demand; + demand = optimisations + .remove(&best_asset) + .expect("Missing optimisation result for selected asset") + .unmet_demand; round += 1; } diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 795f79ddb..1194721ac 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -1,17 +1,13 @@ //! 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::agent::{DecisionRule, ObjectiveType}; use crate::asset::{Asset, AssetRef}; -use crate::commodity::Commodity; use crate::finance::{lcox, snas}; -use crate::model::Model; -use crate::time_slice::TimeSliceID; -use crate::units::{Activity, MoneyPerActivity, MoneyPerCapacity}; -use anyhow::Result; +use crate::units::{MoneyPerActivity, MoneyPerCapacity}; +use anyhow::{Result, bail}; use costs::annual_fixed_cost; use erased_serde::Serialize as ErasedSerialize; use indexmap::IndexMap; -use optimisation::ResultsMap; use serde::Serialize; use std::any::Any; use std::cmp::Ordering; @@ -21,9 +17,10 @@ pub mod coefficients; mod constraints; mod costs; mod optimisation; -use coefficients::ObjectiveCoefficients; +use coefficients::MarketCosts; use float_cmp::{ApproxEq, F64Margin}; -use optimisation::perform_optimisation; +pub use optimisation::AppraisalOptimisation; +pub use optimisation::perform_optimisation; /// Compares two values with approximate equality checking. /// @@ -48,56 +45,6 @@ where } } -/// The output of investment appraisal required to compare potential investment decisions -pub struct AppraisalOutput { - /// The asset being appraised - pub asset: AssetRef, - /// Time slice level activity of the asset - pub activity: IndexMap, - /// The hypothetical unmet demand following investment in this asset - pub unmet_demand: DemandMap, - /// The comparison metric to compare investment decisions - pub metric: Option>, - /// Activity coefficients and market costs used in the appraisal - pub coefficients: Arc, -} - -impl AppraisalOutput { - /// Create a new `AppraisalOutput` - fn new( - asset: AssetRef, - results: ResultsMap, - metric: Option, - coefficients: Arc, - ) -> Self { - Self { - asset, - activity: results.activity, - unmet_demand: results.unmet_demand, - metric: metric.map(|m| Box::new(m) as Box), - coefficients, - } - } - /// Compare this appraisal to another on the basis of the comparison metric. - /// - /// Note that if the metrics are approximately equal, then [`Ordering::Equal`] is returned. - /// The reason for this is because different CPU architectures may lead to subtly different - /// values for the comparison metrics and if the value is very similar to another, then it can - /// lead to different decisions being made, depending on the user's platform (e.g. macOS ARM - /// vs. Windows). We want to avoid this, if possible, which is why we use a more approximate - /// comparison. - pub fn compare_metric(&self, other: &Self) -> Ordering { - let (metric1, metric2) = self - .metric - .as_deref() - .zip(other.metric.as_deref()) - .expect("Cannot compare non-valid outputs"); - - // We've already checked the metrics aren't `None` - metric1.compare(metric2) - } -} - /// Supertrait for appraisal metrics that can be serialised and compared. pub trait MetricTrait: ComparableMetric + ErasedSerialize {} erased_serde::serialize_trait_object!(MetricTrait); @@ -200,56 +147,53 @@ impl ComparableMetric for NPVMetric { } } -/// `NPVMetric` implements the `MetricTrait` supertrait. impl MetricTrait for NPVMetric {} -/// Calculate LCOX for a hypothetical investment in the given asset. +/// Metric results keyed by candidate asset. +pub type AppraisalMetrics = IndexMap>; + +fn compare_asset_metrics( + (asset1, metric1): (&AssetRef, &dyn MetricTrait), + (asset2, metric2): (&AssetRef, &dyn MetricTrait), +) -> Ordering { + match metric1.compare(metric2) { + Ordering::Equal => compare_asset_fallback(asset1, asset2), + ordering => ordering, + } +} + +/// Calculate LCOX from a completed appraisal optimisation. /// /// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can /// include other flows, we use the term LCOX. /// /// # Returns /// -/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand. -/// The returned `metric` is the LCOX value (lower is better). +/// Returns the calculated LCOX metric (lower values are better). fn calculate_lcox( - model: &Model, + optimisation: &AppraisalOptimisation, asset: &AssetRef, - commodity: &Commodity, - coefficients: &Arc, - demand: &DemandMap, -) -> Result { - let results = perform_optimisation(model, asset, commodity, coefficients, demand)?; - + market_costs: &MarketCosts, +) -> Option> { let cost_index = lcox( asset.total_capacity(), annual_fixed_cost(asset), - &results.activity, - &coefficients.market_costs, + &optimisation.activity, + market_costs, ); - - Ok(AppraisalOutput::new( - asset.clone(), - results, - cost_index.map(LCOXMetric::new), - coefficients.clone(), - )) + cost_index.map(|cost| Box::new(LCOXMetric::new(cost)) as Box) } -/// Calculate NPV for a hypothetical investment in the given asset. +/// Calculate NPV from a completed appraisal optimisation. /// /// # Returns /// -/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand. +/// Returns the calculated NPV metric. fn calculate_npv( - model: &Model, + optimisation: &AppraisalOptimisation, asset: &AssetRef, - commodity: &Commodity, - coefficients: &Arc, - demand: &DemandMap, -) -> Result { - let results = perform_optimisation(model, asset, commodity, coefficients, demand)?; - + market_costs: &MarketCosts, +) -> Option> { let annual_fixed_cost = annual_fixed_cost(asset); assert!( annual_fixed_cost >= MoneyPerCapacity(0.0), @@ -259,37 +203,29 @@ fn calculate_npv( let snas = snas( asset.total_capacity(), annual_fixed_cost, - &results.activity, - &coefficients.market_costs, + &optimisation.activity, + market_costs, ); - - Ok(AppraisalOutput::new( - asset.clone(), - results, - snas.map(NPVMetric::new), - coefficients.clone(), - )) + snas.map(|value| Box::new(NPVMetric::new(value)) as Box) } -/// Appraise the given investment with the specified objective type. +/// Calculate the metric for a completed appraisal optimisation. /// /// # Returns /// -/// The `AppraisalOutput` produced by the selected appraisal method. The `metric` field is -/// comparable with other appraisals of the same type (npv/lcox). -pub fn appraise_investment( - model: &Model, +/// Returns the calculated metric. +pub fn calculate_metric( asset: &AssetRef, - commodity: &Commodity, objective_type: &ObjectiveType, - coefficients: &Arc, - demand: &DemandMap, -) -> Result { - let appraisal_method = match objective_type { - ObjectiveType::LevelisedCostOfX => calculate_lcox, - ObjectiveType::NetPresentValue => calculate_npv, - }; - appraisal_method(model, asset, commodity, coefficients, demand) + market_costs: &Arc, + optimisation: &AppraisalOptimisation, +) -> Box { + match objective_type { + ObjectiveType::LevelisedCostOfX => calculate_lcox(optimisation, asset, market_costs) + .expect("LCOX metric must be valid for an optimisation with activity"), + ObjectiveType::NetPresentValue => calculate_npv(optimisation, asset, market_costs) + .expect("NPV metric must be valid for an optimisation with activity"), + } } /// Compare assets as a fallback if metrics are equal. @@ -302,44 +238,64 @@ fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { .cmp(&(asset1.is_commissioned(), asset1.commission_year())) } -/// Sort appraisal outputs by their investment priority and exclude non-feasible options. +/// Sort appraisal outputs by their investment priority. /// /// Investment priority is primarily decided by 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. /// -/// Before sorting, outputs are filtered to exclude entries with invalid metrics (i.e. `None`), so -/// the length of the returned vector may be less than the input. -/// -/// # Returns -/// -/// Returns the number of non-feasible assets which were removed. -pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec) -> usize { - let old_len = outputs.len(); - outputs.retain(|output| output.metric.is_some()); - let num_nonfeasible = old_len - outputs.len(); - - outputs.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, +fn sort_appraisal_outputs(outputs: &mut AppraisalMetrics) { + let mut sorted: Vec<_> = outputs.drain(..).collect(); + sorted.sort_by(|(asset1, metric1), (asset2, metric2)| { + compare_asset_metrics((asset1, metric1.as_ref()), (asset2, metric2.as_ref())) }); + outputs.extend(sorted); +} - num_nonfeasible +/// Make an investment decision according to the configured decision rule. +/// +/// Returns all options which are equally good according to the decision rule. The options must +/// already have non-feasible outputs removed. +pub fn make_investment_decision( + mut outputs: AppraisalMetrics, + decision_rule: &DecisionRule, +) -> Result> { + match decision_rule { + DecisionRule::Single => { + sort_appraisal_outputs(&mut outputs); + if outputs.is_empty() { + return Ok(Vec::new()); + } + + let num_best_outputs = count_equal_and_best_appraisal_outputs(&outputs) + 1; + let best_outputs = outputs + .into_iter() + .take(num_best_outputs) + .map(|(asset, _)| asset) + .collect(); + + Ok(best_outputs) + } + DecisionRule::Weighted => bail!("The weighted decision rule is not yet supported"), + DecisionRule::Lexicographical { .. } => { + bail!("The lexicographical decision rule is not yet supported") + } + } } /// Counts the number of top appraisal outputs in a sorted slice that are indistinguishable /// by both metric and fallback ordering. Excludes the first element from the count. -pub fn count_equal_and_best_appraisal_outputs(outputs: &[AppraisalOutput]) -> usize { +pub fn count_equal_and_best_appraisal_outputs(outputs: &AppraisalMetrics) -> usize { if outputs.is_empty() { return 0; } - outputs[1..] - .iter() - .take_while(|output| { - output.compare_metric(&outputs[0]).is_eq() - && compare_asset_fallback(&output.asset, &outputs[0].asset).is_eq() + let mut outputs = outputs.iter(); + let (best_asset, best_metric) = outputs.next().unwrap(); + outputs + .take_while(|(asset, metric)| { + compare_asset_metrics((asset, metric.as_ref()), (best_asset, best_metric.as_ref())) + .is_eq() }) .count() } @@ -433,14 +389,7 @@ mod tests { assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); } - fn objective_coeffs() -> Arc { - Arc::new(ObjectiveCoefficients { - activity_coefficients: IndexMap::new(), - market_costs: IndexMap::new(), - }) - } - - /// Creates appraisal from corresponding assets and metrics + /// Creates appraisal from corresponding assets and metrics. /// /// # Panics /// @@ -448,7 +397,7 @@ mod tests { fn appraisal_outputs( assets: Vec, metrics: Vec>, - ) -> Vec { + ) -> AppraisalMetrics { assert_eq!( assets.len(), metrics.len(), @@ -458,85 +407,97 @@ mod tests { assets .into_iter() .zip(metrics) - .map(|(asset, metric)| AppraisalOutput { - asset: AssetRef::from(asset), - coefficients: objective_coeffs(), - activity: IndexMap::new(), - unmet_demand: IndexMap::new(), - metric: Some(metric), - }) + .map(|(asset, metric)| (AssetRef::from(asset), 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) + fn lcox_metric(value: f64) -> Box { + Box::new(LCOXMetric::new(MoneyPerActivity(value))) + } + + fn npv_metric(value: f64) -> Box { + Box::new(NPVMetric::new(MoneyPerActivity(value))) } /// 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))), - ]; + fn appraisal_sort_by_lcox_metric(asset: Asset, process: Process) { + let process = Arc::new(process); + let assets = (0..3) + .map(|index| { + Asset::new_ready( + AgentID(format!("agent{index}").into()), + process.clone(), + asset.region_id().clone(), + AssetCapacity::single(asset.total_capacity()), + asset.commission_year(), + ) + .unwrap() + }) + .collect(); + let metrics = vec![lcox_metric(5.0), lcox_metric(3.0), lcox_metric(7.0)]; - let mut outputs = - appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); - sort_and_filter_appraisal_outputs(&mut outputs); + let mut outputs = appraisal_outputs(assets, metrics); + sort_appraisal_outputs(&mut outputs); - assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 3.0); // Best (lowest) - assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0); - assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 7.0); // Worst (highest) + assert_approx_eq!(f64, outputs.get_index(0).unwrap().1.value(), 3.0); // Best (lowest) + assert_approx_eq!(f64, outputs.get_index(1).unwrap().1.value(), 5.0); + assert_approx_eq!(f64, outputs.get_index(2).unwrap().1.value(), 7.0); // Worst (highest) } /// Test sorting by NPV metric when invariant to asset properties #[rstest] - fn appraisal_sort_by_npv_metric(asset: Asset) { - let metrics: Vec> = vec![ - Box::new(NPVMetric::new(MoneyPerActivity(5.0))), - Box::new(NPVMetric::new(MoneyPerActivity(3.0))), - Box::new(NPVMetric::new(MoneyPerActivity(7.0))), - ]; + fn appraisal_sort_by_npv_metric(asset: Asset, process: Process) { + let process = Arc::new(process); + let assets = (0..3) + .map(|index| { + Asset::new_ready( + AgentID(format!("agent{index}").into()), + process.clone(), + asset.region_id().clone(), + AssetCapacity::single(asset.total_capacity()), + asset.commission_year(), + ) + .unwrap() + }) + .collect(); + let metrics = vec![npv_metric(5.0), npv_metric(3.0), npv_metric(7.0)]; - let mut outputs = - appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); - sort_and_filter_appraisal_outputs(&mut outputs); + let mut outputs = appraisal_outputs(assets, metrics); + sort_appraisal_outputs(&mut outputs); - assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 7.0); // Best (highest) - assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0); - assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 3.0); // Worst (lowest) + assert_approx_eq!(f64, outputs.get_index(0).unwrap().1.value(), 7.0); // Best (highest) + assert_approx_eq!(f64, outputs.get_index(1).unwrap().1.value(), 5.0); + assert_approx_eq!(f64, outputs.get_index(2).unwrap().1.value(), 3.0); // Worst (lowest) } /// 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(MoneyPerActivity(3.0))), - Box::new(LCOXMetric::new(MoneyPerActivity(3.0))), - ]; + fn appraisal_sort_by_mixed_metrics_panics(asset: Asset, process: Process) { + let process = Arc::new(process); + let assets = (0..3) + .map(|index| { + Asset::new_ready( + AgentID(format!("agent{index}").into()), + process.clone(), + asset.region_id().clone(), + AssetCapacity::single(asset.total_capacity()), + asset.commission_year(), + ) + .unwrap() + }) + .collect(); + let metrics = vec![lcox_metric(5.0), npv_metric(3.0), lcox_metric(3.0)]; - let mut outputs = - appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + let mut outputs = appraisal_outputs(assets, metrics); // This should panic when trying to compare different metric types - sort_and_filter_appraisal_outputs(&mut outputs); + sort_appraisal_outputs(&mut outputs); } - /// Test that when metrics are equal, commissioned assets are sorted by commission year (newer first) + /// Test that when metrics are equal, 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, - ) { + fn appraisal_sort_by_commission_year_when_metrics_equal(process: Process, region_id: RegionID) { let process_rc = Arc::new(process); let capacity = Capacity(10.0); let commission_years = [2015, 2020, 2010]; @@ -544,8 +505,8 @@ mod tests { let assets: Vec<_> = commission_years .iter() .map(|&year| { - Asset::new_commissioned( - agent_id.clone(), + Asset::new_ready( + AgentID(format!("agent{year}").into()), process_rc.clone(), region_id.clone(), AssetCapacity::single(capacity), @@ -556,19 +517,15 @@ mod tests { .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 metrics = vec![lcox_metric(5.0), lcox_metric(5.0), lcox_metric(5.0)]; let mut outputs = appraisal_outputs(assets, metrics); - sort_and_filter_appraisal_outputs(&mut outputs); + sort_appraisal_outputs(&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); + assert_eq!(outputs.get_index(0).unwrap().0.commission_year(), 2020); + assert_eq!(outputs.get_index(1).unwrap().0.commission_year(), 2015); + assert_eq!(outputs.get_index(2).unwrap().0.commission_year(), 2010); } /// Test that when metrics and commission years are equal, the original order is preserved @@ -593,18 +550,14 @@ mod tests { }) .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 metrics = vec![lcox_metric(5.0), lcox_metric(5.0), lcox_metric(5.0)]; let mut outputs = appraisal_outputs(assets.clone(), metrics); - sort_and_filter_appraisal_outputs(&mut outputs); + sort_appraisal_outputs(&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()))); + assert_eq!(output.0.agent_id(), Some(&AgentID(expected_id.into()))); } } @@ -618,55 +571,61 @@ mod tests { let process_rc = Arc::new(process); let capacity = Capacity(10.0); - // Create a mix of commissioned and candidate (non-commissioned) assets - let commissioned_asset_newer = Asset::new_commissioned( + // Create a mix of commissioned and ready (non-commissioned) assets + let commissioned_asset = Asset::new_commissioned( agent_id.clone(), process_rc.clone(), region_id.clone(), AssetCapacity::single(capacity), - 2020, + 2015, ) .unwrap(); - let commissioned_asset_older = Asset::new_commissioned( - agent_id.clone(), + let ready_asset1 = Asset::new_ready( + AgentID("agent2".into()), process_rc.clone(), region_id.clone(), AssetCapacity::single(capacity), - 2015, + 2020, + ) + .unwrap(); + let ready_asset2 = Asset::new_ready( + AgentID("agent3".into()), + process_rc.clone(), + region_id.clone(), + AssetCapacity::single(capacity), + 2020, + ) + .unwrap(); + let ready_asset3 = Asset::new_ready( + AgentID("agent4".into()), + process_rc, + region_id, + AssetCapacity::single(capacity), + 2020, ) .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(), - ]; + let assets = vec![ready_asset1, commissioned_asset, ready_asset2, ready_asset3]; // 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 metrics = vec![ + lcox_metric(5.0), + lcox_metric(5.0), + lcox_metric(5.0), + lcox_metric(5.0), ]; let mut outputs = appraisal_outputs(assets, metrics); - sort_and_filter_appraisal_outputs(&mut outputs); + sort_appraisal_outputs(&mut outputs); // Commissioned assets should be prioritised first - assert!(outputs[0].asset.is_commissioned()); - assert_eq!(outputs[0].asset.commission_year(), 2020); - assert!(outputs[1].asset.is_commissioned()); - assert_eq!(outputs[1].asset.commission_year(), 2015); + assert!(outputs.get_index(0).unwrap().0.is_commissioned()); + assert_eq!(outputs.get_index(0).unwrap().0.commission_year(), 2015); // Non-commissioned assets should come after - assert!(!outputs[2].asset.is_commissioned()); - assert!(!outputs[3].asset.is_commissioned()); + assert!(!outputs.get_index(2).unwrap().0.is_commissioned()); + assert!(!outputs.get_index(3).unwrap().0.is_commissioned()); } /// Test that appraisal metric is prioritised over asset properties when sorting @@ -680,73 +639,68 @@ mod tests { let capacity = Capacity(10.0); // Create a mix of commissioned and candidate (non-commissioned) assets - let commissioned_asset_newer = Asset::new_commissioned( + let commissioned_asset = Asset::new_commissioned( agent_id.clone(), process_rc.clone(), region_id.clone(), AssetCapacity::single(capacity), - 2020, + 2015, ) .unwrap(); - let commissioned_asset_older = Asset::new_commissioned( - agent_id.clone(), + let candidate_asset1 = Asset::new_ready( + AgentID("agent2".into()), process_rc.clone(), region_id.clone(), AssetCapacity::single(capacity), - 2015, + 2020, + ) + .unwrap(); + let candidate_asset2 = Asset::new_ready( + AgentID("agent3".into()), + process_rc.clone(), + region_id.clone(), + AssetCapacity::single(capacity), + 2020, + ) + .unwrap(); + let candidate_asset3 = Asset::new_ready( + AgentID("agent4".into()), + process_rc, + region_id, + AssetCapacity::single(capacity), + 2020, ) .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(), + candidate_asset1, + commissioned_asset, + candidate_asset2, + candidate_asset3, ]; // 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 best_metric_value = baseline_metric_value - 0.1; + let metrics = vec![ + lcox_metric(best_metric_value), + lcox_metric(baseline_metric_value), + lcox_metric(baseline_metric_value), + lcox_metric(baseline_metric_value), ]; let mut outputs = appraisal_outputs(assets, metrics); - sort_and_filter_appraisal_outputs(&mut outputs); + sort_appraisal_outputs(&mut outputs); // non-commissioned asset prioritised because it has a slightly better metric assert_approx_eq!( f64, - outputs[0].metric.as_ref().unwrap().value(), + outputs.get_index(0).unwrap().1.value(), best_metric_value ); } - /// Test that appraisal outputs with an invalid metric are filtered out - #[rstest] - fn appraisal_sort_filters_invalid_metric(asset: Asset) { - let output = AppraisalOutput { - asset: AssetRef::from(asset), - coefficients: objective_coeffs(), - activity: IndexMap::new(), - unmet_demand: IndexMap::new(), - metric: None, - }; - let mut outputs = vec![output]; - - sort_and_filter_appraisal_outputs(&mut outputs); - - // The invalid output should have been filtered out - assert_eq!(outputs.len(), 0); - } - /// Tests for counting number of equal metrics using identical assets so only metric values /// affect the count. #[rstest] @@ -757,17 +711,28 @@ mod tests { #[case(vec![5.0, 5.0, 9.0, 5.0], 1, "equality_does_not_resume_after_gap")] fn count_equal_best_lcox_metric( asset: Asset, + process: Process, #[case] metric_values: Vec, #[case] expected_count: usize, #[case] description: &str, ) { - let metrics: Vec> = metric_values - .into_iter() - .map(|v| Box::new(LCOXMetric::new(MoneyPerActivity(v))) as Box) + let process = Arc::new(process); + let metrics: Vec> = + metric_values.into_iter().map(lcox_metric).collect(); + + let assets = (0..metrics.len()) + .map(|index| { + Asset::new_ready( + AgentID(format!("agent{index}").into()), + process.clone(), + asset.region_id().clone(), + AssetCapacity::single(asset.total_capacity()), + asset.commission_year(), + ) + .unwrap() + }) .collect(); - - let outputs = - appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + let outputs = appraisal_outputs(assets, metrics); assert_eq!( count_equal_and_best_appraisal_outputs(&outputs), @@ -779,7 +744,7 @@ mod tests { /// Empty slice count should return 0. #[test] fn count_equal_best_empty_slice_returns_zero() { - let outputs: Vec = vec![]; + let outputs = AppraisalMetrics::new(); assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0); } @@ -809,8 +774,8 @@ mod tests { let outputs = appraisal_outputs( vec![commissioned, candidate], vec![ - Box::new(LCOXMetric::new(metric_value)), - Box::new(LCOXMetric::new(metric_value)), + lcox_metric(metric_value.value()), + lcox_metric(metric_value.value()), ], ); @@ -829,7 +794,7 @@ mod tests { let capacity = Capacity(10.0); let year = 2020; - let asset1 = Asset::new_commissioned( + let asset1 = Asset::new_ready( agent_id.clone(), process_rc.clone(), region_id.clone(), @@ -837,9 +802,9 @@ mod tests { year, ) .unwrap(); - let asset2 = Asset::new_commissioned( - agent_id.clone(), - process_rc.clone(), + let asset2 = Asset::new_ready( + AgentID("agent2".into()), + process_rc, region_id.clone(), AssetCapacity::single(capacity), year, @@ -850,8 +815,8 @@ mod tests { let outputs = appraisal_outputs( vec![asset1, asset2], vec![ - Box::new(LCOXMetric::new(metric_value)), - Box::new(LCOXMetric::new(metric_value)), + lcox_metric(metric_value.value()), + lcox_metric(metric_value.value()), ], ); diff --git a/src/simulation/investment/appraisal/coefficients.rs b/src/simulation/investment/appraisal/coefficients.rs index 7911a9ec8..0b6cb083c 100644 --- a/src/simulation/investment/appraisal/coefficients.rs +++ b/src/simulation/investment/appraisal/coefficients.rs @@ -10,70 +10,69 @@ use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; -/// Per-time-slice cost coefficients for an asset. -/// -/// These coefficients are calculated according to the agent's `ObjectiveType` and are used by the -/// investment appraisal routines. They comprise the activity coefficients (revenue minus operating -/// cost, derived from shadow prices) used in the appraisal optimisation, together with the market -/// costs (derived from market prices). -#[derive(Clone)] -pub struct ObjectiveCoefficients { - /// Cost per unit of activity in each time slice - pub activity_coefficients: IndexMap, - /// Market costs associated with asset for each time slice - pub market_costs: IndexMap, +/// Cost per unit of activity in each time slice. +pub type ActivityCoefficients = IndexMap; + +/// Market costs associated with an asset for each time slice. +pub type MarketCosts = IndexMap; + +/// Calculates activity coefficients for a set of assets. +pub fn calculate_activity_coefficients_for_assets( + model: &Model, + assets: &[AssetRef], + prices: &Prices, + year: u32, +) -> HashMap> { + assets + .iter() + .map(|asset| { + let coefficients = calculate_activity_coefficients_for_asset( + asset, + &model.time_slice_info, + prices, + year, + ); + (asset.clone(), Arc::new(coefficients)) + }) + .collect() } -/// Calculates cost coefficients for a set of assets for a given objective type. -/// -/// Returns a map from each asset to its [`ObjectiveCoefficients`], which holds a per-time-slice -/// activity coefficient and market cost. -/// -/// Activity coefficients are revenue from flows (including the primary output) minus operating -/// cost, calculated using shadow prices. A small positive epsilon is added to each activity -/// coefficient so that assets with near-zero net value still appear in dispatch. -/// -/// Market costs are calculated using market prices rather than shadow prices. For NPV they use the -/// same revenue-minus-operating-cost calculation as the activity coefficients. For LCOX the sign is -/// inverted (as the value represents a cost) and the primary output (commodity of interest) is -/// excluded. -pub fn calculate_coefficients_for_assets( +/// Calculates objective-specific market costs for a set of assets. +pub fn calculate_market_costs_for_assets( model: &Model, objective_type: &ObjectiveType, assets: &[AssetRef], prices: &Prices, year: u32, -) -> HashMap> { +) -> HashMap> { assets .iter() .map(|asset| { - let coefficient = calculate_coefficients_for_asset( + let costs = calculate_market_costs_for_asset( asset, objective_type, &model.time_slice_info, prices, year, ); - (asset.clone(), Arc::new(coefficient)) + (asset.clone(), Arc::new(costs)) }) .collect() } -/// Calculates cost coefficients for a single asset -pub fn calculate_coefficients_for_asset( +/// Calculates activity coefficients for a single asset. +pub fn calculate_activity_coefficients_for_asset( asset: &AssetRef, - objective_type: &ObjectiveType, time_slice_info: &TimeSliceInfo, prices: &Prices, year: u32, -) -> ObjectiveCoefficients { +) -> ActivityCoefficients { // Small constant added to each activity coefficient to ensure break-even/slightly negative // assets are still dispatched const EPSILON_ACTIVITY_COEFFICIENT: MoneyPerActivity = MoneyPerActivity(f64::EPSILON * 100.0); // Activity coefficients let mut activity_coefficients = IndexMap::new(); - let mut market_costs = IndexMap::new(); let primary_output_flow = asset.primary_output().unwrap(); let asset_region = asset.region_id(); for time_slice in time_slice_info.iter_ids() { @@ -93,7 +92,22 @@ pub fn calculate_coefficients_for_asset( time_slice.clone(), fallback_cost - net_operating_cost + EPSILON_ACTIVITY_COEFFICIENT, ); + } + + activity_coefficients +} +/// Calculates objective-specific market costs for a single asset. +pub fn calculate_market_costs_for_asset( + asset: &AssetRef, + objective_type: &ObjectiveType, + time_slice_info: &TimeSliceInfo, + prices: &Prices, + year: u32, +) -> MarketCosts { + let mut market_costs = IndexMap::new(); + for time_slice in time_slice_info.iter_ids() { + let operating_cost = asset.get_operating_cost(year, time_slice); let market_cost = match objective_type { ObjectiveType::LevelisedCostOfX => { calculate_asset_costs_for_lcox(asset, operating_cost, time_slice, &prices.market) @@ -105,10 +119,7 @@ pub fn calculate_coefficients_for_asset( market_costs.insert(time_slice.clone(), market_cost); } - ObjectiveCoefficients { - activity_coefficients, - market_costs, - } + market_costs } /// Calculate the revenue from all flows minus operating cost diff --git a/src/simulation/investment/appraisal/optimisation.rs b/src/simulation/investment/appraisal/optimisation.rs index 448abb093..68d3b3a27 100644 --- a/src/simulation/investment/appraisal/optimisation.rs +++ b/src/simulation/investment/appraisal/optimisation.rs @@ -1,6 +1,5 @@ //! Optimisation problem for investment tools. use super::DemandMap; -use super::ObjectiveCoefficients; use super::constraints::{add_activity_constraints, add_demand_constraints}; use crate::asset::AssetRef; use crate::commodity::Commodity; @@ -9,7 +8,7 @@ use crate::simulation::optimisation::ModelError; use crate::simulation::optimisation::apply_highs_options_from_toml; use crate::simulation::optimisation::solve_optimal; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; -use crate::units::{Activity, Dimensionless, Flow}; +use crate::units::{Activity, Dimensionless, Flow, MoneyPerActivity}; use anyhow::{Context, Result}; use highs::{RowProblem as Problem, Sense}; use indexmap::IndexMap; @@ -20,23 +19,31 @@ use indexmap::IndexMap; /// in which columns are added to the problem when extracting solution values. pub type Variable = highs::Col; -/// Map containing optimisation results and coefficients -pub struct ResultsMap { +/// The result of optimising the dispatch of a candidate investment. +pub struct AppraisalOptimisation { /// Activity variables in each time slice pub activity: IndexMap, /// Remaining unmet demand per time slice, computed post-solve pub unmet_demand: DemandMap, } +impl AppraisalOptimisation { + /// Returns whether the asset has positive activity in at least one time slice. + pub fn has_activity(&self) -> bool { + self.activity + .values() + .any(|activity| *activity != Activity(0.0)) + } +} + /// Adds activity variables to the problem, one per time slice. /// /// Returns a map from time slice to the corresponding decision variable. fn add_activity_vars( problem: &mut Problem, - cost_coefficients: &ObjectiveCoefficients, + activity_coefficients: &IndexMap, ) -> IndexMap { - cost_coefficients - .activity_coefficients + activity_coefficients .iter() .map(|(time_slice, cost)| { let var = problem.add_column(cost.value(), 0.0..); @@ -106,12 +113,12 @@ pub fn perform_optimisation( model: &Model, asset: &AssetRef, commodity: &Commodity, - coefficients: &ObjectiveCoefficients, + activity_coefficients: &IndexMap, demand: &DemandMap, -) -> Result { +) -> Result { // Create problem and add variables let mut problem = Problem::default(); - let activity_vars = add_activity_vars(&mut problem, coefficients); + let activity_vars = add_activity_vars(&mut problem, activity_coefficients); // Add constraints add_constraints( @@ -144,7 +151,7 @@ pub fn perform_optimisation( .collect(); let unmet_demand = compute_unmet_demand(demand, &activity, commodity, asset, &model.time_slice_info); - Ok(ResultsMap { + Ok(AppraisalOptimisation { activity, unmet_demand, })