From abf5fe52654c5055008c9a4e7d94f92c54b74ab5 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 24 Aug 2026 14:03:24 +0100 Subject: [PATCH 1/6] Split appraisal, metric calculation and decision --- src/simulation/investment.rs | 11 +- src/simulation/investment/appraisal.rs | 154 +++++++++++++++++-------- 2 files changed, 113 insertions(+), 52 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 297f6c4d8..2dc8ac92d 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -24,7 +24,7 @@ pub mod appraisal; use appraisal::coefficients::calculate_coefficients_for_assets; use appraisal::{ AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs, - sort_and_filter_appraisal_outputs, + make_investment_decision, remove_nonfeasible_appraisal_outputs, }; /// A map of demand across time slices for a specific market @@ -428,8 +428,8 @@ pub fn select_best_assets( &demand, )?; - // Sort by investment priority and discard non-feasible options - let num_nonfeasible = sort_and_filter_appraisal_outputs(&mut outputs); + // Discard non-feasible options + let num_nonfeasible = remove_nonfeasible_appraisal_outputs(&mut outputs); // 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 @@ -444,9 +444,14 @@ pub fn select_best_assets( break; } + // 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); + // Select the first option from the best options. let best_output = outputs.into_iter().next().unwrap(); // Log the selected asset diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 795f79ddb..25b88795a 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -1,17 +1,16 @@ //! 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 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; @@ -62,18 +61,33 @@ pub struct AppraisalOutput { pub coefficients: Arc, } +/// The result of optimising the dispatch of a candidate investment. +struct AppraisalOptimisation { + activity: IndexMap, + unmet_demand: DemandMap, +} + +impl AppraisalOptimisation { + fn from_results(results: optimisation::ResultsMap) -> Self { + Self { + activity: results.activity, + unmet_demand: results.unmet_demand, + } + } +} + impl AppraisalOutput { /// Create a new `AppraisalOutput` fn new( asset: AssetRef, - results: ResultsMap, + optimisation: AppraisalOptimisation, metric: Option, coefficients: Arc, ) -> Self { Self { asset, - activity: results.activity, - unmet_demand: results.unmet_demand, + activity: optimisation.activity, + unmet_demand: optimisation.unmet_demand, metric: metric.map(|m| Box::new(m) as Box), coefficients, } @@ -203,7 +217,19 @@ impl ComparableMetric for NPVMetric { /// `NPVMetric` implements the `MetricTrait` supertrait. impl MetricTrait for NPVMetric {} -/// Calculate LCOX for a hypothetical investment in the given asset. +/// Run the shared optimisation used by all appraisal metrics. +fn run_appraisal_optimisation( + model: &Model, + asset: &AssetRef, + commodity: &Commodity, + coefficients: &Arc, + demand: &DemandMap, +) -> Result { + let results = perform_optimisation(model, asset, commodity, coefficients, demand)?; + Ok(AppraisalOptimisation::from_results(results)) +} + +/// 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. @@ -213,43 +239,35 @@ impl MetricTrait for NPVMetric {} /// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand. /// The returned `metric` is the LCOX value (lower is 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)?; - + coefficients: Arc, +) -> AppraisalOutput { let cost_index = lcox( asset.total_capacity(), annual_fixed_cost(asset), - &results.activity, + &optimisation.activity, &coefficients.market_costs, ); - Ok(AppraisalOutput::new( + AppraisalOutput::new( asset.clone(), - results, + optimisation, cost_index.map(LCOXMetric::new), - coefficients.clone(), - )) + coefficients, + ) } -/// 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. 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)?; - + coefficients: Arc, +) -> AppraisalOutput { let annual_fixed_cost = annual_fixed_cost(asset); assert!( annual_fixed_cost >= MoneyPerCapacity(0.0), @@ -259,16 +277,16 @@ fn calculate_npv( let snas = snas( asset.total_capacity(), annual_fixed_cost, - &results.activity, + &optimisation.activity, &coefficients.market_costs, ); - Ok(AppraisalOutput::new( + AppraisalOutput::new( asset.clone(), - results, + optimisation, snas.map(NPVMetric::new), - coefficients.clone(), - )) + coefficients, + ) } /// Appraise the given investment with the specified objective type. @@ -285,11 +303,13 @@ pub fn appraise_investment( 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) + let optimisation = run_appraisal_optimisation(model, asset, commodity, coefficients, demand)?; + let coefficients = coefficients.clone(); + + Ok(match objective_type { + ObjectiveType::LevelisedCostOfX => calculate_lcox(optimisation, asset, coefficients), + ObjectiveType::NetPresentValue => calculate_npv(optimisation, asset, coefficients), + }) } /// Compare assets as a fallback if metrics are equal. @@ -302,30 +322,66 @@ 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. +/// Remove appraisal outputs with invalid metrics and return the number removed. +/// +/// An output with no metric is considered non-feasible. Options skipped before appraisal, such as +/// assets with zero capacity, are not included in this count. +pub fn remove_nonfeasible_appraisal_outputs(outputs: &mut Vec) -> usize { + let old_len = outputs.len(); + outputs.retain(|output| output.metric.is_some()); + old_len - outputs.len() +} + +/// 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(); - +fn sort_appraisal_outputs(outputs: &mut [AppraisalOutput]) { 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, }); +} + +/// 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: Vec, + 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).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") + } + } +} +/// Sort appraisal outputs by their investment priority and exclude non-feasible options. +/// +/// This low-level helper is retained for callers which need the complete sorted list. New +/// decision-making code should use [`remove_nonfeasible_appraisal_outputs`] followed by +/// [`make_investment_decision`]. +pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec) -> usize { + let num_nonfeasible = remove_nonfeasible_appraisal_outputs(outputs); + sort_appraisal_outputs(outputs); num_nonfeasible } From b2514fe49d5d51427a9a08d3511a5e84feab72ea Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 24 Aug 2026 14:07:47 +0100 Subject: [PATCH 2/6] Remiove `AppraisalOptimisation` struct --- src/simulation/investment/appraisal.rs | 37 +++---------------- .../investment/appraisal/optimisation.rs | 2 +- 2 files changed, 6 insertions(+), 33 deletions(-) diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 25b88795a..a4be9eea5 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -22,7 +22,7 @@ mod costs; mod optimisation; use coefficients::ObjectiveCoefficients; use float_cmp::{ApproxEq, F64Margin}; -use optimisation::perform_optimisation; +use optimisation::{ResultsMap, perform_optimisation}; /// Compares two values with approximate equality checking. /// @@ -61,26 +61,11 @@ pub struct AppraisalOutput { pub coefficients: Arc, } -/// The result of optimising the dispatch of a candidate investment. -struct AppraisalOptimisation { - activity: IndexMap, - unmet_demand: DemandMap, -} - -impl AppraisalOptimisation { - fn from_results(results: optimisation::ResultsMap) -> Self { - Self { - activity: results.activity, - unmet_demand: results.unmet_demand, - } - } -} - impl AppraisalOutput { /// Create a new `AppraisalOutput` fn new( asset: AssetRef, - optimisation: AppraisalOptimisation, + optimisation: ResultsMap, metric: Option, coefficients: Arc, ) -> Self { @@ -217,18 +202,6 @@ impl ComparableMetric for NPVMetric { /// `NPVMetric` implements the `MetricTrait` supertrait. impl MetricTrait for NPVMetric {} -/// Run the shared optimisation used by all appraisal metrics. -fn run_appraisal_optimisation( - model: &Model, - asset: &AssetRef, - commodity: &Commodity, - coefficients: &Arc, - demand: &DemandMap, -) -> Result { - let results = perform_optimisation(model, asset, commodity, coefficients, demand)?; - Ok(AppraisalOptimisation::from_results(results)) -} - /// Calculate LCOX from a completed appraisal optimisation. /// /// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can @@ -239,7 +212,7 @@ fn run_appraisal_optimisation( /// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand. /// The returned `metric` is the LCOX value (lower is better). fn calculate_lcox( - optimisation: AppraisalOptimisation, + optimisation: ResultsMap, asset: &AssetRef, coefficients: Arc, ) -> AppraisalOutput { @@ -264,7 +237,7 @@ fn calculate_lcox( /// /// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand. fn calculate_npv( - optimisation: AppraisalOptimisation, + optimisation: ResultsMap, asset: &AssetRef, coefficients: Arc, ) -> AppraisalOutput { @@ -303,7 +276,7 @@ pub fn appraise_investment( coefficients: &Arc, demand: &DemandMap, ) -> Result { - let optimisation = run_appraisal_optimisation(model, asset, commodity, coefficients, demand)?; + let optimisation = perform_optimisation(model, asset, commodity, coefficients, demand)?; let coefficients = coefficients.clone(); Ok(match objective_type { diff --git a/src/simulation/investment/appraisal/optimisation.rs b/src/simulation/investment/appraisal/optimisation.rs index 448abb093..801588e43 100644 --- a/src/simulation/investment/appraisal/optimisation.rs +++ b/src/simulation/investment/appraisal/optimisation.rs @@ -20,7 +20,7 @@ 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 +/// The result of optimising the dispatch of a candidate investment. pub struct ResultsMap { /// Activity variables in each time slice pub activity: IndexMap, From feb5814126499c1f4a384141269ad4a70d062d69 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 24 Aug 2026 14:26:58 +0100 Subject: [PATCH 3/6] Drop --- src/fixture.rs | 10 +-- src/output.rs | 2 +- src/simulation/investment.rs | 16 ++-- src/simulation/investment/appraisal.rs | 52 ++++++----- .../investment/appraisal/coefficients.rs | 87 +++++++++++-------- .../investment/appraisal/optimisation.rs | 12 ++- 6 files changed, 97 insertions(+), 82 deletions(-) diff --git a/src/fixture.rs b/src/fixture.rs index 1d215148a..884bf55f0 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::AppraisalOutput; use crate::simulation::investment::appraisal::LCOXMetric; -use crate::simulation::investment::appraisal::{ - AppraisalOutput, coefficients::ObjectiveCoefficients, -}; use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel}; use crate::units::{ Activity, ActivityPerCapacity, Capacity, Dimensionless, Flow, MoneyPerActivity, @@ -400,15 +398,11 @@ pub fn time_slice_info2() -> TimeSliceInfo { #[fixture] pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutput { 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_coefficients: Arc::new(activity_coefficients), activity, unmet_demand, metric: Some(Box::new(LCOXMetric::new(MoneyPerActivity(4.14)))), diff --git a/src/output.rs b/src/output.rs index fb68ed345..92c42e6fd 100644 --- a/src/output.rs +++ b/src/output.rs @@ -504,7 +504,7 @@ impl DebugDataWriter { ) -> Result<()> { for result in appraisal_results { for (time_slice, activity) in &result.activity { - let activity_coefficient = result.coefficients.activity_coefficients[time_slice]; + let activity_coefficient = result.activity_coefficients[time_slice]; let demand = demand[time_slice]; let unmet_demand = result.unmet_demand[time_slice]; let row = AppraisalResultsTimeSliceRow { diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 2dc8ac92d..1da0d8f37 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -21,7 +21,9 @@ 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, make_investment_decision, remove_nonfeasible_appraisal_outputs, @@ -375,9 +377,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; @@ -411,7 +416,8 @@ pub fn select_best_assets( asset, commodity, objective_type, - &coefficients[asset], + &activity_coefficients[asset], + &market_costs[asset], &demand, )?)) }) diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index a4be9eea5..40fa00ca1 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -20,7 +20,7 @@ pub mod coefficients; mod constraints; mod costs; mod optimisation; -use coefficients::ObjectiveCoefficients; +use coefficients::{ActivityCoefficients, MarketCosts}; use float_cmp::{ApproxEq, F64Margin}; use optimisation::{ResultsMap, perform_optimisation}; @@ -57,8 +57,8 @@ pub struct AppraisalOutput { 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, + /// Activity coefficients used in the appraisal optimisation + pub activity_coefficients: Arc, } impl AppraisalOutput { @@ -67,14 +67,14 @@ impl AppraisalOutput { asset: AssetRef, optimisation: ResultsMap, metric: Option, - coefficients: Arc, + activity_coefficients: Arc, ) -> Self { Self { asset, activity: optimisation.activity, unmet_demand: optimisation.unmet_demand, metric: metric.map(|m| Box::new(m) as Box), - coefficients, + activity_coefficients, } } /// Compare this appraisal to another on the basis of the comparison metric. @@ -214,20 +214,21 @@ impl MetricTrait for NPVMetric {} fn calculate_lcox( optimisation: ResultsMap, asset: &AssetRef, - coefficients: Arc, + activity_coefficients: Arc, + market_costs: Arc, ) -> AppraisalOutput { let cost_index = lcox( asset.total_capacity(), annual_fixed_cost(asset), &optimisation.activity, - &coefficients.market_costs, + &market_costs, ); AppraisalOutput::new( asset.clone(), optimisation, cost_index.map(LCOXMetric::new), - coefficients, + activity_coefficients, ) } @@ -239,7 +240,8 @@ fn calculate_lcox( fn calculate_npv( optimisation: ResultsMap, asset: &AssetRef, - coefficients: Arc, + activity_coefficients: Arc, + market_costs: Arc, ) -> AppraisalOutput { let annual_fixed_cost = annual_fixed_cost(asset); assert!( @@ -251,14 +253,14 @@ fn calculate_npv( asset.total_capacity(), annual_fixed_cost, &optimisation.activity, - &coefficients.market_costs, + &market_costs, ); AppraisalOutput::new( asset.clone(), optimisation, snas.map(NPVMetric::new), - coefficients, + activity_coefficients, ) } @@ -273,15 +275,22 @@ pub fn appraise_investment( asset: &AssetRef, commodity: &Commodity, objective_type: &ObjectiveType, - coefficients: &Arc, + activity_coefficients: &Arc, + market_costs: &Arc, demand: &DemandMap, ) -> Result { - let optimisation = perform_optimisation(model, asset, commodity, coefficients, demand)?; - let coefficients = coefficients.clone(); + let optimisation = + perform_optimisation(model, asset, commodity, activity_coefficients, demand)?; + let activity_coefficients = activity_coefficients.clone(); + let market_costs = market_costs.clone(); Ok(match objective_type { - ObjectiveType::LevelisedCostOfX => calculate_lcox(optimisation, asset, coefficients), - ObjectiveType::NetPresentValue => calculate_npv(optimisation, asset, coefficients), + ObjectiveType::LevelisedCostOfX => { + calculate_lcox(optimisation, asset, activity_coefficients, market_costs) + } + ObjectiveType::NetPresentValue => { + calculate_npv(optimisation, asset, activity_coefficients, market_costs) + } }) } @@ -462,11 +471,8 @@ 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(), - }) + fn objective_coeffs() -> Arc { + Arc::new(IndexMap::new()) } /// Creates appraisal from corresponding assets and metrics @@ -489,7 +495,7 @@ mod tests { .zip(metrics) .map(|(asset, metric)| AppraisalOutput { asset: AssetRef::from(asset), - coefficients: objective_coeffs(), + activity_coefficients: objective_coeffs(), activity: IndexMap::new(), unmet_demand: IndexMap::new(), metric: Some(metric), @@ -763,7 +769,7 @@ mod tests { fn appraisal_sort_filters_invalid_metric(asset: Asset) { let output = AppraisalOutput { asset: AssetRef::from(asset), - coefficients: objective_coeffs(), + activity_coefficients: objective_coeffs(), activity: IndexMap::new(), unmet_demand: IndexMap::new(), metric: None, 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 801588e43..c9e97afc3 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; @@ -33,10 +32,9 @@ pub struct ResultsMap { /// 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 +104,12 @@ pub fn perform_optimisation( model: &Model, asset: &AssetRef, commodity: &Commodity, - coefficients: &ObjectiveCoefficients, + activity_coefficients: &IndexMap, demand: &DemandMap, ) -> 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( From ab4715ca67a3117dd086785e97f3fcc16b9747a8 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 24 Aug 2026 16:18:40 +0100 Subject: [PATCH 4/6] Split appraisal optimisation and metrics --- src/fixture.rs | 30 +- src/output.rs | 84 ++-- src/process.rs | 2 +- src/simulation/investment.rs | 100 ++-- src/simulation/investment/appraisal.rs | 451 ++++++++---------- .../investment/appraisal/optimisation.rs | 15 +- 6 files changed, 359 insertions(+), 323 deletions(-) diff --git a/src/fixture.rs b/src/fixture.rs index 884bf55f0..3b63a5bae 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -14,8 +14,8 @@ use crate::process::{ ProcessInvestmentConstraintsMap, ProcessMap, ProcessParameter, ProcessParameterMap, }; use crate::region::RegionID; -use crate::simulation::investment::appraisal::AppraisalOutput; -use crate::simulation::investment::appraisal::LCOXMetric; +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, @@ -396,17 +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 activity = indexmap! { time_slice.clone() => Activity(10.0) }; let unmet_demand = indexmap! { time_slice.clone() => Flow(5.0) }; - AppraisalOutput { - asset: AssetRef::from(asset), - activity_coefficients: Arc::new(activity_coefficients), - 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 92c42e6fd..a552efb7b 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,19 +479,21 @@ impl DebugDataWriter { &mut self, milestone_year: u32, run_description: &str, - appraisal_results: &[AppraisalOutput], + appraisal_results: &AppraisalMetrics, ) -> Result<()> { - for result 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()), - }; - self.appraisal_results_writer.serialize(row)?; + for (asset, metrics) in appraisal_results { + for metric in metrics { + let row = AppraisalResultsRow { + milestone_year, + run_description: self.with_context(run_description), + 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)?; + } } Ok(()) @@ -499,20 +504,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.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 +607,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 +617,8 @@ impl DataWriter { wtr.write_appraisal_time_slice_results( milestone_year, run_description, - appraisal_results, + optimisations, + activity_coefficients, demand, )?; } @@ -710,7 +719,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 +1048,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 +1064,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 => vec![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 +1094,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 +1110,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/process.rs b/src/process.rs index c5669c0f1..84a91ad5a 100644 --- a/src/process.rs +++ b/src/process.rs @@ -37,7 +37,7 @@ pub type ProcessInvestmentConstraintsMap = HashMap<(RegionID, u32), Arc>; /// Represents a process within the simulation -#[derive(PartialEq, Debug)] +#[derive(Clone, PartialEq, Debug)] pub struct Process { /// A unique identifier for the process (e.g. GASDRV) pub id: ProcessID, diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 1da0d8f37..9ca467463 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -25,8 +25,8 @@ use appraisal::coefficients::{ calculate_activity_coefficients_for_assets, calculate_market_costs_for_assets, }; use appraisal::{ - AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs, - make_investment_decision, remove_nonfeasible_appraisal_outputs, + AppraisalMetrics, AppraisalOptimisation, calculate_metric, make_investment_decision, + perform_optimisation, }; /// A map of demand across time slices for a specific market @@ -315,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, @@ -324,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(), @@ -399,48 +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, - &activity_coefficients[asset], - &market_costs[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, - )?; - - // Discard non-feasible options - let num_nonfeasible = remove_nonfeasible_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. \ @@ -450,6 +445,30 @@ 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, vec![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)?; @@ -458,26 +477,29 @@ pub fn select_best_assets( log_on_equal_appraisal_outputs(&outputs, &agent.id, &commodity.id, region_id); // Select the first option from the best options. - let best_output = outputs.into_iter().next().unwrap(); + 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 40fa00ca1..72420c92f 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -2,11 +2,8 @@ use super::DemandMap; 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 crate::units::{MoneyPerActivity, MoneyPerCapacity}; use anyhow::{Result, bail}; use costs::annual_fixed_cost; use erased_serde::Serialize as ErasedSerialize; @@ -20,9 +17,10 @@ pub mod coefficients; mod constraints; mod costs; mod optimisation; -use coefficients::{ActivityCoefficients, MarketCosts}; +use coefficients::MarketCosts; use float_cmp::{ApproxEq, F64Margin}; -use optimisation::{ResultsMap, perform_optimisation}; +pub use optimisation::AppraisalOptimisation; +pub use optimisation::perform_optimisation; /// Compares two values with approximate equality checking. /// @@ -47,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 used in the appraisal optimisation - pub activity_coefficients: Arc, -} - -impl AppraisalOutput { - /// Create a new `AppraisalOutput` - fn new( - asset: AssetRef, - optimisation: ResultsMap, - metric: Option, - activity_coefficients: Arc, - ) -> Self { - Self { - asset, - activity: optimisation.activity, - unmet_demand: optimisation.unmet_demand, - metric: metric.map(|m| Box::new(m) as Box), - activity_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); @@ -199,9 +147,43 @@ impl ComparableMetric for NPVMetric { } } -/// `NPVMetric` implements the `MetricTrait` supertrait. impl MetricTrait for NPVMetric {} +/// Metric results keyed by candidate asset. +pub type AppraisalMetrics = IndexMap>>; + +#[cfg(test)] +#[derive(Clone, Copy)] +enum AppraisalMetric { + Lcox(Option), + Npv(Option), +} + +#[cfg(test)] +impl AppraisalMetric { + fn boxed(self) -> Option> { + match self { + Self::Lcox(value) => value.map(|value| Box::new(LCOXMetric::new(value)) as _), + Self::Npv(value) => value.map(|value| Box::new(NPVMetric::new(value)) as _), + } + } +} + +fn compare_asset_metrics( + (asset1, metrics1): (&AssetRef, &Vec>), + (asset2, metrics2): (&AssetRef, &Vec>), +) -> Ordering { + match metrics1 + .first() + .zip(metrics2.first()) + .map_or(Ordering::Greater, |(metric1, metric2)| { + metric1.compare(metric2.as_ref()) + }) { + 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 @@ -209,40 +191,31 @@ impl MetricTrait for NPVMetric {} /// /// # 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( - optimisation: ResultsMap, + optimisation: &AppraisalOptimisation, asset: &AssetRef, - activity_coefficients: Arc, - market_costs: Arc, -) -> AppraisalOutput { + market_costs: &MarketCosts, +) -> Option> { let cost_index = lcox( asset.total_capacity(), annual_fixed_cost(asset), &optimisation.activity, - &market_costs, + market_costs, ); - - AppraisalOutput::new( - asset.clone(), - optimisation, - cost_index.map(LCOXMetric::new), - activity_coefficients, - ) + cost_index.map(|cost| Box::new(LCOXMetric::new(cost)) as Box) } /// 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( - optimisation: ResultsMap, + optimisation: &AppraisalOptimisation, asset: &AssetRef, - activity_coefficients: Arc, - market_costs: Arc, -) -> AppraisalOutput { + market_costs: &MarketCosts, +) -> Option> { let annual_fixed_cost = annual_fixed_cost(asset); assert!( annual_fixed_cost >= MoneyPerCapacity(0.0), @@ -253,45 +226,28 @@ fn calculate_npv( asset.total_capacity(), annual_fixed_cost, &optimisation.activity, - &market_costs, + market_costs, ); - - AppraisalOutput::new( - asset.clone(), - optimisation, - snas.map(NPVMetric::new), - activity_coefficients, - ) + 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 optimisation result and its calculated metric. +pub fn calculate_metric( asset: &AssetRef, - commodity: &Commodity, objective_type: &ObjectiveType, - activity_coefficients: &Arc, market_costs: &Arc, - demand: &DemandMap, -) -> Result { - let optimisation = - perform_optimisation(model, asset, commodity, activity_coefficients, demand)?; - let activity_coefficients = activity_coefficients.clone(); - let market_costs = market_costs.clone(); - - Ok(match objective_type { - ObjectiveType::LevelisedCostOfX => { - calculate_lcox(optimisation, asset, activity_coefficients, market_costs) - } - ObjectiveType::NetPresentValue => { - calculate_npv(optimisation, asset, activity_coefficients, market_costs) - } - }) + 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. @@ -308,9 +264,9 @@ fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { /// /// An output with no metric is considered non-feasible. Options skipped before appraisal, such as /// assets with zero capacity, are not included in this count. -pub fn remove_nonfeasible_appraisal_outputs(outputs: &mut Vec) -> usize { +pub fn remove_nonfeasible_appraisal_outputs(outputs: &mut AppraisalMetrics) -> usize { let old_len = outputs.len(); - outputs.retain(|output| output.metric.is_some()); + outputs.retain(|_, metrics| metrics.first().is_some()); old_len - outputs.len() } @@ -321,12 +277,12 @@ pub fn remove_nonfeasible_appraisal_outputs(outputs: &mut Vec) /// and newer assets are preferred over older ones. The function does not guarantee that all ties /// will be resolved. /// -fn sort_appraisal_outputs(outputs: &mut [AppraisalOutput]) { - 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, metrics1), (asset2, metrics2)| { + compare_asset_metrics((asset1, metrics1), (asset2, metrics2)) }); + outputs.extend(sorted); } /// Make an investment decision according to the configured decision rule. @@ -334,9 +290,9 @@ fn sort_appraisal_outputs(outputs: &mut [AppraisalOutput]) { /// 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: Vec, + mut outputs: AppraisalMetrics, decision_rule: &DecisionRule, -) -> Result> { +) -> Result> { match decision_rule { DecisionRule::Single => { sort_appraisal_outputs(&mut outputs); @@ -345,7 +301,11 @@ pub fn make_investment_decision( } let num_best_outputs = count_equal_and_best_appraisal_outputs(&outputs) + 1; - let best_outputs = outputs.into_iter().take(num_best_outputs).collect(); + let best_outputs = outputs + .into_iter() + .take(num_best_outputs) + .map(|(asset, _)| asset) + .collect(); Ok(best_outputs) } @@ -361,7 +321,7 @@ pub fn make_investment_decision( /// This low-level helper is retained for callers which need the complete sorted list. New /// decision-making code should use [`remove_nonfeasible_appraisal_outputs`] followed by /// [`make_investment_decision`]. -pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec) -> usize { +pub fn sort_and_filter_appraisal_outputs(outputs: &mut AppraisalMetrics) -> usize { let num_nonfeasible = remove_nonfeasible_appraisal_outputs(outputs); sort_appraisal_outputs(outputs); num_nonfeasible @@ -369,15 +329,15 @@ pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec) -> /// 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() + let mut outputs = outputs.iter(); + let (best_asset, best_metrics) = outputs.next().unwrap(); + outputs .take_while(|output| { - output.compare_metric(&outputs[0]).is_eq() - && compare_asset_fallback(&output.asset, &outputs[0].asset).is_eq() + compare_asset_metrics((output.0, output.1), (best_asset, best_metrics)).is_eq() }) .count() } @@ -392,6 +352,7 @@ mod tests { use crate::region::RegionID; use crate::units::{Capacity, MoneyPerActivity}; use float_cmp::assert_approx_eq; + use indexmap::indexmap; use rstest::rstest; use std::sync::Arc; @@ -471,19 +432,12 @@ mod tests { assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); } - fn objective_coeffs() -> Arc { - Arc::new(IndexMap::new()) - } - /// Creates appraisal from corresponding assets and metrics /// /// # Panics /// /// Panics if `assets` and `metrics` have different lengths - fn appraisal_outputs( - assets: Vec, - metrics: Vec>, - ) -> Vec { + fn appraisal_outputs(assets: Vec, metrics: Vec) -> AppraisalMetrics { assert_eq!( assets.len(), metrics.len(), @@ -493,70 +447,75 @@ mod tests { assets .into_iter() .zip(metrics) - .map(|(asset, metric)| AppraisalOutput { - asset: AssetRef::from(asset), - activity_coefficients: objective_coeffs(), - activity: IndexMap::new(), - unmet_demand: IndexMap::new(), - metric: Some(metric), - }) + .map(|(asset, metric)| (AssetRef::from(asset), metric.boxed().into_iter().collect())) .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>, + metrics: Vec, asset: &Asset, - ) -> Vec { - let assets = vec![asset.clone(); metrics.len()]; + ) -> AppraisalMetrics { + let assets = (0..metrics.len()) + .map(|index| { + Asset::new_ready( + AgentID(format!("agent{index}").into()), + Arc::new(asset.process().clone()), + asset.region_id().clone(), + AssetCapacity::single(asset.total_capacity()), + asset.commission_year(), + ) + .unwrap() + }) + .collect(); appraisal_outputs(assets, metrics) } /// Test sorting by LCOX metric when invariant to asset properties #[rstest] fn appraisal_sort_by_lcox_metric(asset: Asset) { - let metrics: Vec> = vec![ - Box::new(LCOXMetric::new(MoneyPerActivity(5.0))), - Box::new(LCOXMetric::new(MoneyPerActivity(3.0))), - Box::new(LCOXMetric::new(MoneyPerActivity(7.0))), + let metrics = vec![ + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(3.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(7.0))), ]; let mut outputs = appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); sort_and_filter_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[0].value(), 3.0); // Best (lowest) + assert_approx_eq!(f64, outputs.get_index(1).unwrap().1[0].value(), 5.0); + assert_approx_eq!(f64, outputs.get_index(2).unwrap().1[0].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))), + let metrics = vec![ + AppraisalMetric::Npv(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Npv(Some(MoneyPerActivity(3.0))), + AppraisalMetric::Npv(Some(MoneyPerActivity(7.0))), ]; let mut outputs = appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); sort_and_filter_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[0].value(), 7.0); // Best (highest) + assert_approx_eq!(f64, outputs.get_index(1).unwrap().1[0].value(), 5.0); + assert_approx_eq!(f64, outputs.get_index(2).unwrap().1[0].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))), + let metrics = vec![ + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Npv(Some(MoneyPerActivity(3.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(3.0))), ]; let mut outputs = @@ -565,13 +524,9 @@ mod tests { sort_and_filter_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]; @@ -579,8 +534,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), @@ -591,19 +546,19 @@ 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![ + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), ]; let mut outputs = appraisal_outputs(assets, metrics); sort_and_filter_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 @@ -628,10 +583,10 @@ 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![ + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), ]; let mut outputs = appraisal_outputs(assets.clone(), metrics); @@ -639,7 +594,7 @@ mod tests { // 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()))); } } @@ -653,55 +608,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![ + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), ]; let mut outputs = appraisal_outputs(assets, metrics); sort_and_filter_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 @@ -715,42 +676,55 @@ 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![ + AppraisalMetric::Lcox(Some(MoneyPerActivity(best_metric_value))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(baseline_metric_value))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(baseline_metric_value))), + AppraisalMetric::Lcox(Some(MoneyPerActivity(baseline_metric_value))), ]; let mut outputs = appraisal_outputs(assets, metrics); @@ -759,7 +733,7 @@ mod tests { // 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[0].value(), best_metric_value ); } @@ -767,14 +741,7 @@ mod tests { /// 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), - activity_coefficients: objective_coeffs(), - activity: IndexMap::new(), - unmet_demand: IndexMap::new(), - metric: None, - }; - let mut outputs = vec![output]; + let mut outputs = indexmap! { AssetRef::from(asset) => Vec::new() }; sort_and_filter_appraisal_outputs(&mut outputs); @@ -796,9 +763,9 @@ mod tests { #[case] expected_count: usize, #[case] description: &str, ) { - let metrics: Vec> = metric_values + let metrics: Vec = metric_values .into_iter() - .map(|v| Box::new(LCOXMetric::new(MoneyPerActivity(v))) as Box) + .map(|v| AppraisalMetric::Lcox(Some(MoneyPerActivity(v)))) .collect(); let outputs = @@ -814,7 +781,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); } @@ -844,8 +811,8 @@ mod tests { let outputs = appraisal_outputs( vec![commissioned, candidate], vec![ - Box::new(LCOXMetric::new(metric_value)), - Box::new(LCOXMetric::new(metric_value)), + AppraisalMetric::Lcox(Some(metric_value)), + AppraisalMetric::Lcox(Some(metric_value)), ], ); @@ -864,7 +831,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(), @@ -872,9 +839,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, @@ -885,8 +852,8 @@ mod tests { let outputs = appraisal_outputs( vec![asset1, asset2], vec![ - Box::new(LCOXMetric::new(metric_value)), - Box::new(LCOXMetric::new(metric_value)), + AppraisalMetric::Lcox(Some(metric_value)), + AppraisalMetric::Lcox(Some(metric_value)), ], ); diff --git a/src/simulation/investment/appraisal/optimisation.rs b/src/simulation/investment/appraisal/optimisation.rs index c9e97afc3..68d3b3a27 100644 --- a/src/simulation/investment/appraisal/optimisation.rs +++ b/src/simulation/investment/appraisal/optimisation.rs @@ -20,13 +20,22 @@ use indexmap::IndexMap; pub type Variable = highs::Col; /// The result of optimising the dispatch of a candidate investment. -pub struct ResultsMap { +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. @@ -106,7 +115,7 @@ pub fn perform_optimisation( commodity: &Commodity, 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, activity_coefficients); @@ -142,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, }) From 4c9aa7f89d2a0c61800481c666ea894f70346121 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 24 Aug 2026 16:27:32 +0100 Subject: [PATCH 5/6] Switch back to a single metric --- src/output.rs | 26 ++++--- src/simulation/investment.rs | 4 +- src/simulation/investment/appraisal.rs | 94 +++++++++----------------- 3 files changed, 45 insertions(+), 79 deletions(-) diff --git a/src/output.rs b/src/output.rs index a552efb7b..b4f659d04 100644 --- a/src/output.rs +++ b/src/output.rs @@ -481,19 +481,17 @@ impl DebugDataWriter { run_description: &str, appraisal_results: &AppraisalMetrics, ) -> Result<()> { - for (asset, metrics) in appraisal_results { - for metric in metrics { - let row = AppraisalResultsRow { - milestone_year, - run_description: self.with_context(run_description), - 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)?; - } + for (asset, metric) in appraisal_results { + let row = AppraisalResultsRow { + milestone_year, + run_description: self.with_context(run_description), + 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)?; } Ok(()) @@ -1065,7 +1063,7 @@ mod tests { { let mut writer = DebugDataWriter::create(dir.path()).unwrap(); let (asset_ref, _, metric, _) = appraisal_output; - let metrics = indexmap! { asset_ref => vec![metric] }; + let metrics = indexmap! { asset_ref => metric }; writer .write_appraisal_results(milestone_year, &run_description, &metrics) .unwrap(); diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 9ca467463..5d0ac1be3 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -350,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, @@ -455,7 +455,7 @@ pub fn select_best_assets( &market_costs[&asset], &optimisations[&asset], ); - (asset, vec![metric]) + (asset, metric) }) .collect(); diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 72420c92f..f1faae34c 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -150,7 +150,7 @@ impl ComparableMetric for NPVMetric { impl MetricTrait for NPVMetric {} /// Metric results keyed by candidate asset. -pub type AppraisalMetrics = IndexMap>>; +pub type AppraisalMetrics = IndexMap>; #[cfg(test)] #[derive(Clone, Copy)] @@ -170,16 +170,11 @@ impl AppraisalMetric { } fn compare_asset_metrics( - (asset1, metrics1): (&AssetRef, &Vec>), - (asset2, metrics2): (&AssetRef, &Vec>), + (asset1, metric1): (&AssetRef, &dyn MetricTrait), + (asset2, metric2): (&AssetRef, &dyn MetricTrait), ) -> Ordering { - match metrics1 - .first() - .zip(metrics2.first()) - .map_or(Ordering::Greater, |(metric1, metric2)| { - metric1.compare(metric2.as_ref()) - }) { - Ordering::Equal => compare_asset_fallback(&**asset1, &**asset2), + match metric1.compare(metric2) { + Ordering::Equal => compare_asset_fallback(asset1, asset2), ordering => ordering, } } @@ -260,16 +255,6 @@ fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { .cmp(&(asset1.is_commissioned(), asset1.commission_year())) } -/// Remove appraisal outputs with invalid metrics and return the number removed. -/// -/// An output with no metric is considered non-feasible. Options skipped before appraisal, such as -/// assets with zero capacity, are not included in this count. -pub fn remove_nonfeasible_appraisal_outputs(outputs: &mut AppraisalMetrics) -> usize { - let old_len = outputs.len(); - outputs.retain(|_, metrics| metrics.first().is_some()); - old_len - outputs.len() -} - /// Sort appraisal outputs by their investment priority. /// /// Investment priority is primarily decided by appraisal metric. When appraisal metrics are equal, @@ -279,8 +264,8 @@ pub fn remove_nonfeasible_appraisal_outputs(outputs: &mut AppraisalMetrics) -> u /// fn sort_appraisal_outputs(outputs: &mut AppraisalMetrics) { let mut sorted: Vec<_> = outputs.drain(..).collect(); - sorted.sort_by(|(asset1, metrics1), (asset2, metrics2)| { - compare_asset_metrics((asset1, metrics1), (asset2, metrics2)) + sorted.sort_by(|(asset1, metric1), (asset2, metric2)| { + compare_asset_metrics((asset1, metric1.as_ref()), (asset2, metric2.as_ref())) }); outputs.extend(sorted); } @@ -316,17 +301,6 @@ pub fn make_investment_decision( } } -/// Sort appraisal outputs by their investment priority and exclude non-feasible options. -/// -/// This low-level helper is retained for callers which need the complete sorted list. New -/// decision-making code should use [`remove_nonfeasible_appraisal_outputs`] followed by -/// [`make_investment_decision`]. -pub fn sort_and_filter_appraisal_outputs(outputs: &mut AppraisalMetrics) -> usize { - let num_nonfeasible = remove_nonfeasible_appraisal_outputs(outputs); - sort_appraisal_outputs(outputs); - num_nonfeasible -} - /// 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: &AppraisalMetrics) -> usize { @@ -334,10 +308,11 @@ pub fn count_equal_and_best_appraisal_outputs(outputs: &AppraisalMetrics) -> usi return 0; } let mut outputs = outputs.iter(); - let (best_asset, best_metrics) = outputs.next().unwrap(); + let (best_asset, best_metric) = outputs.next().unwrap(); outputs - .take_while(|output| { - compare_asset_metrics((output.0, output.1), (best_asset, best_metrics)).is_eq() + .take_while(|(asset, metric)| { + compare_asset_metrics((asset, metric.as_ref()), (best_asset, best_metric.as_ref())) + .is_eq() }) .count() } @@ -352,7 +327,6 @@ mod tests { use crate::region::RegionID; use crate::units::{Capacity, MoneyPerActivity}; use float_cmp::assert_approx_eq; - use indexmap::indexmap; use rstest::rstest; use std::sync::Arc; @@ -447,7 +421,12 @@ mod tests { assets .into_iter() .zip(metrics) - .map(|(asset, metric)| (AssetRef::from(asset), metric.boxed().into_iter().collect())) + .map(|(asset, metric)| { + ( + AssetRef::from(asset), + metric.boxed().expect("test metrics should be valid"), + ) + }) .collect() } @@ -483,11 +462,11 @@ mod tests { let mut outputs = appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); - sort_and_filter_appraisal_outputs(&mut outputs); + sort_appraisal_outputs(&mut outputs); - assert_approx_eq!(f64, outputs.get_index(0).unwrap().1[0].value(), 3.0); // Best (lowest) - assert_approx_eq!(f64, outputs.get_index(1).unwrap().1[0].value(), 5.0); - assert_approx_eq!(f64, outputs.get_index(2).unwrap().1[0].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 @@ -501,11 +480,11 @@ mod tests { let mut outputs = appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); - sort_and_filter_appraisal_outputs(&mut outputs); + sort_appraisal_outputs(&mut outputs); - assert_approx_eq!(f64, outputs.get_index(0).unwrap().1[0].value(), 7.0); // Best (highest) - assert_approx_eq!(f64, outputs.get_index(1).unwrap().1[0].value(), 5.0); - assert_approx_eq!(f64, outputs.get_index(2).unwrap().1[0].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 @@ -521,7 +500,7 @@ mod tests { let mut outputs = appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); // 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, assets are sorted by commission year (newer first) @@ -553,7 +532,7 @@ mod tests { ]; 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.get_index(0).unwrap().0.commission_year(), 2020); @@ -590,7 +569,7 @@ mod tests { ]; 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) { @@ -654,7 +633,7 @@ mod tests { ]; 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.get_index(0).unwrap().0.is_commissioned()); @@ -728,27 +707,16 @@ mod tests { ]; 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.get_index(0).unwrap().1[0].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 mut outputs = indexmap! { AssetRef::from(asset) => Vec::new() }; - - 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] From 5ebcbf8982c534290d0f5833b8dc5c47f4dcf20d Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 24 Aug 2026 16:48:00 +0100 Subject: [PATCH 6/6] Small tests refactor --- src/process.rs | 2 +- src/simulation/investment/appraisal.rs | 177 ++++++++++++------------- 2 files changed, 87 insertions(+), 92 deletions(-) diff --git a/src/process.rs b/src/process.rs index 84a91ad5a..c5669c0f1 100644 --- a/src/process.rs +++ b/src/process.rs @@ -37,7 +37,7 @@ pub type ProcessInvestmentConstraintsMap = HashMap<(RegionID, u32), Arc>; /// Represents a process within the simulation -#[derive(Clone, PartialEq, Debug)] +#[derive(PartialEq, Debug)] pub struct Process { /// A unique identifier for the process (e.g. GASDRV) pub id: ProcessID, diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index f1faae34c..1194721ac 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -152,23 +152,6 @@ impl MetricTrait for NPVMetric {} /// Metric results keyed by candidate asset. pub type AppraisalMetrics = IndexMap>; -#[cfg(test)] -#[derive(Clone, Copy)] -enum AppraisalMetric { - Lcox(Option), - Npv(Option), -} - -#[cfg(test)] -impl AppraisalMetric { - fn boxed(self) -> Option> { - match self { - Self::Lcox(value) => value.map(|value| Box::new(LCOXMetric::new(value)) as _), - Self::Npv(value) => value.map(|value| Box::new(NPVMetric::new(value)) as _), - } - } -} - fn compare_asset_metrics( (asset1, metric1): (&AssetRef, &dyn MetricTrait), (asset2, metric2): (&AssetRef, &dyn MetricTrait), @@ -230,7 +213,7 @@ fn calculate_npv( /// /// # Returns /// -/// Returns the optimisation result and its calculated metric. +/// Returns the calculated metric. pub fn calculate_metric( asset: &AssetRef, objective_type: &ObjectiveType, @@ -406,12 +389,15 @@ mod tests { assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); } - /// Creates appraisal from corresponding assets and metrics + /// Creates appraisal from corresponding assets and metrics. /// /// # Panics /// /// Panics if `assets` and `metrics` have different lengths - fn appraisal_outputs(assets: Vec, metrics: Vec) -> AppraisalMetrics { + fn appraisal_outputs( + assets: Vec, + metrics: Vec>, + ) -> AppraisalMetrics { assert_eq!( assets.len(), metrics.len(), @@ -421,26 +407,27 @@ mod tests { assets .into_iter() .zip(metrics) - .map(|(asset, metric)| { - ( - AssetRef::from(asset), - metric.boxed().expect("test metrics should be valid"), - ) - }) + .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, - ) -> AppraisalMetrics { - let assets = (0..metrics.len()) + 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, process: Process) { + let process = Arc::new(process); + let assets = (0..3) .map(|index| { Asset::new_ready( AgentID(format!("agent{index}").into()), - Arc::new(asset.process().clone()), + process.clone(), asset.region_id().clone(), AssetCapacity::single(asset.total_capacity()), asset.commission_year(), @@ -448,20 +435,9 @@ mod tests { .unwrap() }) .collect(); - appraisal_outputs(assets, metrics) - } + let metrics = vec![lcox_metric(5.0), lcox_metric(3.0), lcox_metric(7.0)]; - /// Test sorting by LCOX metric when invariant to asset properties - #[rstest] - fn appraisal_sort_by_lcox_metric(asset: Asset) { - let metrics = vec![ - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(3.0))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(7.0))), - ]; - - let mut outputs = - appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + let mut outputs = appraisal_outputs(assets, metrics); sort_appraisal_outputs(&mut outputs); assert_approx_eq!(f64, outputs.get_index(0).unwrap().1.value(), 3.0); // Best (lowest) @@ -471,15 +447,23 @@ mod tests { /// Test sorting by NPV metric when invariant to asset properties #[rstest] - fn appraisal_sort_by_npv_metric(asset: Asset) { - let metrics = vec![ - AppraisalMetric::Npv(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Npv(Some(MoneyPerActivity(3.0))), - AppraisalMetric::Npv(Some(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); + let mut outputs = appraisal_outputs(assets, metrics); sort_appraisal_outputs(&mut outputs); assert_approx_eq!(f64, outputs.get_index(0).unwrap().1.value(), 7.0); // Best (highest) @@ -490,15 +474,23 @@ mod tests { /// 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![ - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Npv(Some(MoneyPerActivity(3.0))), - AppraisalMetric::Lcox(Some(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_appraisal_outputs(&mut outputs); } @@ -525,11 +517,7 @@ mod tests { .collect(); // All metrics have the same value - let metrics = vec![ - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(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_appraisal_outputs(&mut outputs); @@ -562,11 +550,7 @@ mod tests { }) .collect(); - let metrics = vec![ - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(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_appraisal_outputs(&mut outputs); @@ -626,10 +610,10 @@ mod tests { // All metrics have identical values to test fallback ordering let metrics = vec![ - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(5.0))), + lcox_metric(5.0), + lcox_metric(5.0), + lcox_metric(5.0), + lcox_metric(5.0), ]; let mut outputs = appraisal_outputs(assets, metrics); @@ -700,10 +684,10 @@ mod tests { let baseline_metric_value = 5.0; let best_metric_value = baseline_metric_value - 0.1; let metrics = vec![ - AppraisalMetric::Lcox(Some(MoneyPerActivity(best_metric_value))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(baseline_metric_value))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(baseline_metric_value))), - AppraisalMetric::Lcox(Some(MoneyPerActivity(baseline_metric_value))), + 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); @@ -727,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| AppraisalMetric::Lcox(Some(MoneyPerActivity(v)))) - .collect(); + let process = Arc::new(process); + let metrics: Vec> = + metric_values.into_iter().map(lcox_metric).collect(); - let outputs = - appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset); + 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(assets, metrics); assert_eq!( count_equal_and_best_appraisal_outputs(&outputs), @@ -779,8 +774,8 @@ mod tests { let outputs = appraisal_outputs( vec![commissioned, candidate], vec![ - AppraisalMetric::Lcox(Some(metric_value)), - AppraisalMetric::Lcox(Some(metric_value)), + lcox_metric(metric_value.value()), + lcox_metric(metric_value.value()), ], ); @@ -820,8 +815,8 @@ mod tests { let outputs = appraisal_outputs( vec![asset1, asset2], vec![ - AppraisalMetric::Lcox(Some(metric_value)), - AppraisalMetric::Lcox(Some(metric_value)), + lcox_metric(metric_value.value()), + lcox_metric(metric_value.value()), ], );