From f80719d0d4f1493297afcafef81b26b5cbb7d48e Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 4 Nov 2026 11:47:41 +0000 Subject: [PATCH 1/7] Move appraisal module to investment::competitive Pure move: appraisal.rs becomes competitive/metrics.rs and the appraisal submodules move alongside it, in preparation for supporting multiple asset selection algorithms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fixture.rs | 7 +++---- src/output.rs | 4 ++-- src/simulation/investment.rs | 6 +++--- src/simulation/investment/competitive.rs | 9 +++++++++ .../{appraisal => competitive}/coefficients.rs | 0 .../{appraisal => competitive}/constraints.rs | 2 +- .../{appraisal => competitive}/costs.rs | 0 .../{appraisal.rs => competitive/metrics.rs} | 16 +++++----------- .../{appraisal => competitive}/optimisation.rs | 4 ++-- 9 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 src/simulation/investment/competitive.rs rename src/simulation/investment/{appraisal => competitive}/coefficients.rs (100%) rename src/simulation/investment/{appraisal => competitive}/constraints.rs (99%) rename src/simulation/investment/{appraisal => competitive}/costs.rs (100%) rename src/simulation/investment/{appraisal.rs => competitive/metrics.rs} (99%) rename src/simulation/investment/{appraisal => competitive}/optimisation.rs (98%) diff --git a/src/fixture.rs b/src/fixture.rs index 6331a557c..01d65433e 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -15,10 +15,9 @@ 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::competitive::coefficients::ObjectiveCoefficients; +use crate::simulation::investment::competitive::metrics::AppraisalOutput; +use crate::simulation::investment::competitive::metrics::LCOXMetric; use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel, TimeSliceSelection}; use crate::units::{ Activity, ActivityPerCapacity, Capacity, Dimensionless, Flow, MoneyPerActivity, diff --git a/src/output.rs b/src/output.rs index 51446723f..a0498aca7 100644 --- a/src/output.rs +++ b/src/output.rs @@ -4,7 +4,7 @@ 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::competitive::metrics::AppraisalOutput; use crate::simulation::optimisation::{FlowMap, Solution}; use crate::simulation::prices::PriceMap; use crate::time_slice::{TimeSliceID, TimeSliceLevel, TimeSliceSelection}; @@ -728,7 +728,7 @@ mod tests { use crate::fixture::{ appraisal_output, asset, assets, commodity_id, multi_tranche_asset, region_id, time_slice, }; - use crate::simulation::investment::appraisal::AppraisalOutput; + use crate::simulation::investment::competitive::metrics::AppraisalOutput; use crate::time_slice::{TimeSliceID, TimeSliceLevel, TimeSliceSelection}; use indexmap::indexmap; use itertools::{Itertools, assert_equal}; diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 151204bfb..6710ea6ca 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -20,9 +20,9 @@ use rayon::prelude::*; use std::collections::HashMap; use strum::IntoEnumIterator; -pub mod appraisal; -use appraisal::coefficients::calculate_coefficients_for_assets; -use appraisal::{ +pub mod competitive; +use competitive::coefficients::calculate_coefficients_for_assets; +use competitive::metrics::{ AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs, sort_and_filter_appraisal_outputs, }; diff --git a/src/simulation/investment/competitive.rs b/src/simulation/investment/competitive.rs new file mode 100644 index 000000000..f54dc5210 --- /dev/null +++ b/src/simulation/investment/competitive.rs @@ -0,0 +1,9 @@ +//! The competitive asset selection algorithm. +//! +//! Candidate and existing assets compete in rounds: each round every option is appraised with a +//! mini dispatch optimisation, and the best option according to the agent's objective is selected. +pub mod coefficients; +mod constraints; +mod costs; +pub mod metrics; +mod optimisation; diff --git a/src/simulation/investment/appraisal/coefficients.rs b/src/simulation/investment/competitive/coefficients.rs similarity index 100% rename from src/simulation/investment/appraisal/coefficients.rs rename to src/simulation/investment/competitive/coefficients.rs diff --git a/src/simulation/investment/appraisal/constraints.rs b/src/simulation/investment/competitive/constraints.rs similarity index 99% rename from src/simulation/investment/appraisal/constraints.rs rename to src/simulation/investment/competitive/constraints.rs index a11cb3997..924df5bdc 100644 --- a/src/simulation/investment/appraisal/constraints.rs +++ b/src/simulation/investment/competitive/constraints.rs @@ -1,9 +1,9 @@ //! Constraints for the optimisation problem. -use super::DemandMap; use super::optimisation::Variable; use crate::asset::AssetRef; use crate::commodity::Commodity; use crate::model::Model; +use crate::simulation::investment::DemandMap; use crate::time_slice::{Season, TimeSliceID, TimeSliceInfo, TimeSliceSelection}; use crate::units::{MoneyPerCapacityPerYear, Year}; use highs::RowProblem as Problem; diff --git a/src/simulation/investment/appraisal/costs.rs b/src/simulation/investment/competitive/costs.rs similarity index 100% rename from src/simulation/investment/appraisal/costs.rs rename to src/simulation/investment/competitive/costs.rs diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/competitive/metrics.rs similarity index 99% rename from src/simulation/investment/appraisal.rs rename to src/simulation/investment/competitive/metrics.rs index 795f79ddb..68e17dfdb 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/competitive/metrics.rs @@ -1,30 +1,24 @@ //! Calculation for investment tools such as Levelised Cost of X (LCOX) and Net Present Value (NPV). -use super::DemandMap; +use super::coefficients::ObjectiveCoefficients; +use super::costs::annual_fixed_cost; +use super::optimisation::{ResultsMap, perform_optimisation}; use crate::agent::ObjectiveType; use crate::asset::{Asset, AssetRef}; use crate::commodity::Commodity; use crate::finance::{lcox, snas}; use crate::model::Model; +use crate::simulation::investment::DemandMap; use crate::time_slice::TimeSliceID; use crate::units::{Activity, MoneyPerActivity, MoneyPerCapacity}; use anyhow::Result; -use costs::annual_fixed_cost; use erased_serde::Serialize as ErasedSerialize; +use float_cmp::{ApproxEq, F64Margin}; use indexmap::IndexMap; -use optimisation::ResultsMap; use serde::Serialize; use std::any::Any; use std::cmp::Ordering; use std::sync::Arc; -pub mod coefficients; -mod constraints; -mod costs; -mod optimisation; -use coefficients::ObjectiveCoefficients; -use float_cmp::{ApproxEq, F64Margin}; -use optimisation::perform_optimisation; - /// Compares two values with approximate equality checking. /// /// Returns `Ordering::Equal` if the values are approximately equal diff --git a/src/simulation/investment/appraisal/optimisation.rs b/src/simulation/investment/competitive/optimisation.rs similarity index 98% rename from src/simulation/investment/appraisal/optimisation.rs rename to src/simulation/investment/competitive/optimisation.rs index 5d4e4dc12..f20861e43 100644 --- a/src/simulation/investment/appraisal/optimisation.rs +++ b/src/simulation/investment/competitive/optimisation.rs @@ -1,12 +1,12 @@ //! Optimisation problem for investment tools. -use super::DemandMap; -use super::ObjectiveCoefficients; +use super::coefficients::ObjectiveCoefficients; use super::constraints::{ add_activity_constraints, add_demand_constraints, add_utilisation_peak_constraints, }; use crate::asset::AssetRef; use crate::commodity::Commodity; use crate::model::Model; +use crate::simulation::investment::DemandMap; use crate::simulation::optimisation::ModelError; use crate::simulation::optimisation::apply_highs_options_from_toml; use crate::simulation::optimisation::solve_optimal; From ab1f146fc55245315217acd9324afc13f6ec39e9 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 4 Nov 2026 11:50:12 +0000 Subject: [PATCH 2/7] Move competitive selection loop and option building into competitive module select_best_assets, its helpers and the candidate/tranche logic move from investment.rs and market.rs into investment/competitive.rs. No behaviour change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- benches/assets.rs | 5 +- src/simulation/investment.rs | 498 +------------------ src/simulation/investment/competitive.rs | 588 +++++++++++++++++++++++ src/simulation/market.rs | 81 +--- 4 files changed, 600 insertions(+), 572 deletions(-) diff --git a/benches/assets.rs b/benches/assets.rs index ba6414777..72ba6e787 100644 --- a/benches/assets.rs +++ b/benches/assets.rs @@ -9,9 +9,10 @@ use muse2::model::Model; use muse2::output::DataWriter; use muse2::process::{Process, ProcessID}; use muse2::simulation::candidate_assets_for_next_year; -use muse2::simulation::investment::{collect_preset_demands_for_year, select_best_assets}; +use muse2::simulation::investment::collect_preset_demands_for_year; +use muse2::simulation::investment::competitive::{get_asset_options, select_best_assets}; use muse2::simulation::market::{ - collect_agent_limits, get_asset_options, get_demand_portion_for_market, get_responsible_agents, + collect_agent_limits, get_demand_portion_for_market, get_responsible_agents, }; use muse2::simulation::optimisation::DispatchRun; use muse2::simulation::prices::{Prices, calculate_prices}; diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 6710ea6ca..81ef98ce4 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -1,31 +1,23 @@ //! Code for performing agent investment. use super::optimisation::{DispatchRun, FlowMap}; -use crate::agent::{Agent, AgentID}; use crate::asset::{Asset, AssetRef}; use crate::commodity::{Commodity, CommodityID, CommodityMap}; use crate::model::Model; use crate::output::DataWriter; -use crate::process::ProcessID; use crate::region::RegionID; use crate::simulation::prices::Prices; use crate::time_slice::{TimeSliceInfo, TimeSliceLevel, TimeSliceSelection}; use crate::timeit::InvestmentTimer; use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerCapacity}; -use anyhow::{Result, ensure}; +use anyhow::Result; use context_manager; use indexmap::IndexMap; use itertools::Itertools; -use log::{debug, warn}; -use rayon::prelude::*; +use log::debug; use std::collections::HashMap; use strum::IntoEnumIterator; pub mod competitive; -use competitive::coefficients::calculate_coefficients_for_assets; -use competitive::metrics::{ - AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs, - sort_and_filter_appraisal_outputs, -}; /// A map of demand across time-slice selections for a specific market pub type DemandMap = IndexMap; @@ -182,34 +174,6 @@ pub fn update_net_demand_map(demand: &mut AllDemandMap, flows: &FlowMap, assets: } } -/// Calculates a characteristic capacity scale for a candidate asset. -/// -/// The returned value is the capacity that would satisfy the total annual demand assuming the asset -/// operates at its maximum annual activity for the entire year. It ignores finer-grained activity -/// constraints and temporal variations in demand. -/// -/// For processes that do not define a tranche size, this value is later scaled by -/// `capacity_tranche_fraction` to set the capacity of one candidate investment tranche. -/// -/// If the asset has zero maximum annual supply, zero capacity is returned. This indicates that the -/// asset is non-feasible, and will be excluded from consideration by `select_best_assets`. -pub fn calculate_candidate_asset_capacity_scale( - asset: &Asset, - commodity: &Commodity, - demand: &DemandMap, -) -> Capacity { - let coeff = asset.get_flow(&commodity.id).unwrap().coeff; - let max_annual_supply_per_capacity = *asset - .get_activity_per_capacity_limits_for_selection(&TimeSliceSelection::Annual) - .end() - * coeff; - if max_annual_supply_per_capacity < FlowPerCapacity::EPSILON { - return Capacity(0.0); - } - let annual_demand = demand.values().copied().sum::(); - annual_demand / max_annual_supply_per_capacity -} - /// Returns the minimum installed capacity required for `asset` to satisfy the demand that it can /// potentially serve, accounting for its activity constraints. /// @@ -288,331 +252,20 @@ pub fn get_demand_limiting_capacity( capacity } -/// Print debug message if there are multiple equally good outputs -fn log_on_equal_appraisal_outputs( - outputs: &[AppraisalOutput], - agent_id: &AgentID, - commodity_id: &CommodityID, - region_id: &RegionID, -) { - if outputs.is_empty() { - return; - } - - let num_identical = count_equal_and_best_appraisal_outputs(outputs); - - if num_identical > 0 { - let asset_details = outputs[..=num_identical] - .iter() - .map(|output| { - let asset = &output.asset; - format!( - "Process ID: '{}' (State: {}{}, Commission year: {})", - asset.process_id(), - asset.state(), - asset - .id() - .map(|id| format!(", Asset ID: {id}")) - .unwrap_or_default(), - asset.commission_year() - ) - }) - .join(", "); - debug!( - "Found equally good appraisals for Agent ID: {agent_id}, Commodity: '{commodity_id}', \ - Region: {region_id}. Options: [{asset_details}]. Selecting first option.", - ); - } -} - -/// Get the best assets for meeting demand for the given commodity -#[allow(clippy::too_many_arguments)] -pub fn select_best_assets( - model: &Model, - mut opt_assets: Vec, - agent_addition_limits: HashMap, - agent_total_limits: HashMap, - commodity: &Commodity, - agent: &Agent, - region_id: &RegionID, - prices: &Prices, - mut demand: DemandMap, - year: u32, - writer: &mut DataWriter, -) -> Result> { - let objective_type = &agent.objectives[&year]; - - // Remaining addition limits for candidate processes - // Initialised as the full agent addition limits, and reduced as candidate assets are selected - let mut remaining_agent_addition_limits = agent_addition_limits; - - // Remaining total capacity for all assets - // Initialised as full agent total limits, and reduced as each asset is selection - let mut remaining_agent_total_limits = agent_total_limits; - - // Store commissioned tranches available for retention and replace assets with single tranches - let mut available_retention_tranches = - 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); - - // Iteratively select the best asset until demand is met - let mut round = 0; - let mut best_assets: Vec = Vec::new(); - while is_any_remaining_demand( - &demand, - model.parameters.remaining_demand_absolute_tolerance, - ) { - // Remove assets that would exceed the remaining limits for their processes from the options - // The addition limit applies only to candidate assets, the total limit applies to all assets - remove_assets_exceeding_agent_limits( - &mut opt_assets, - &remaining_agent_addition_limits, - true, - ); - remove_assets_exceeding_agent_limits(&mut opt_assets, &remaining_agent_total_limits, false); - ensure!( - !opt_assets.is_empty(), - "Failed to meet demand for commodity '{}' in region '{}' with provided investment \ - options. This may be due to overly restrictive process investment constraints.", - commodity.id, - 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 - .par_iter() - .map(|asset| -> Result> { - // Skip assets with zero capacity - if asset.total_capacity() <= Capacity(0.0) { - return Ok(None); - } - - Ok(Some(appraise_investment( - model, - asset, - commodity, - objective_type, - &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, - commodity.time_slice_level, - )?; - - // Sort by investment priority and discard non-feasible options - let num_nonfeasible = sort_and_filter_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 - // warning. - if outputs.is_empty() { - warn!( - "Investment appraisal completed with unmet demand for commodity '{}', region '{}', \ - year '{}', agent '{}'. {} non-feasible investments were not considered. \ - This unmet demand may still be satisfied during the full system dispatch.", - commodity.id, region_id, year, agent.id, num_nonfeasible - ); - break; - } - - // 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(); - - // Log the selected asset - debug!( - "Selected {} asset '{}' (capacity: {})", - best_output.asset.state(), - best_output.asset.process_id(), - best_output.asset.total_capacity() - ); - - // Update the remaining selection state - update_selection_state( - &best_output.asset, - &mut opt_assets, - &mut remaining_agent_addition_limits, - &mut remaining_agent_total_limits, - &mut available_retention_tranches, - ); - - // Record the selected asset - record_asset_selection(best_output.asset, &mut best_assets); - - demand = best_output.unmet_demand; - round += 1; - } - - // Convert Candidate assets to Ready - // At this point we also assign the agent ID to the asset - for asset in &mut best_assets { - if asset.is_candidate() { - asset - .make_mut() - .select_candidate_for_investment(agent.id.clone()); - } - } - - Ok(best_assets) -} - -/// Prepare existing assets for reappraisal. -/// -/// Assets are replaced in `assets` with an asset representing a single tranche, as they are -/// appraised one tranche at a time. Returns a map from the asset to its original number of tranches. -fn prepare_commissioned_assets_for_retention(assets: &mut [AssetRef]) -> HashMap { - let mut available_retention_tranches = HashMap::new(); - - for asset in assets.iter_mut().filter(|asset| asset.is_commissioned()) { - let num_tranches = asset.num_tranches(); - - // Replace with single tranche as we appraise one tranche at a time - *asset = asset.clone().as_single_tranche(); - - // Store remaining tranches - available_retention_tranches.insert(asset.clone(), num_tranches); - } - - available_retention_tranches -} - -/// Check whether there is any remaining demand that is unmet in any time slice -fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool { - demand.values().any(|flow| *flow > absolute_tolerance) -} - -/// Remove assets that exceed the provided process limits for one complete tranche. -fn remove_assets_exceeding_agent_limits( - opt_assets: &mut Vec, - remaining_agent_limits: &HashMap, - only_candidates: bool, -) { - opt_assets.retain(|asset| { - (only_candidates && !asset.is_candidate()) - || remaining_agent_limits - .get(asset.process_id()) - .is_none_or(|limit| *limit >= asset.total_capacity()) - }); -} - -// Update remaining process capacity limit with the capacity of a selected asset -fn subtract_capacity_from_remaining_limit( - best_asset: &AssetRef, - remaining_agent_limits: &mut HashMap, -) { - if let Some(remaining_capacity) = remaining_agent_limits.get_mut(best_asset.process_id()) { - *remaining_capacity -= best_asset.total_capacity(); - assert!( - *remaining_capacity >= Capacity(0.0), - "Remaining Capacity has fallen below zero" - ); - } -} - -/// Update the remaining investment options and selection state. -/// -/// If the asset is a candidate, its one-tranche capacity is subtracted from -/// `remaining_agent_addition_limits` (if applicable) to ensure that process addition limits are not -/// exceeded. If the asset is commissioned, one tranche is subtracted from -/// `available_retention_tranches` to ensure that retention does not invent new capacity. -/// -/// # Arguments -/// -/// * `best_asset` - The asset that has been selected as the best option in this round -/// * `opt_assets` - The list of remaining asset options to be considered in future rounds -/// * `remaining_agent_addition_limits` - The remaining agent addition limits for processes -/// * `remaining_agent_total_limit` - The remaining capacity for processes -/// * `available_retention_tranches` - The commissioned tranches available for retention -fn update_selection_state( - best_asset: &AssetRef, - opt_assets: &mut Vec, - remaining_agent_addition_limits: &mut HashMap, - remaining_agent_total_limits: &mut HashMap, - available_retention_tranches: &mut HashMap, -) { - // Subtract asset capacity from the total capacity limit, if applicable. - subtract_capacity_from_remaining_limit(best_asset, remaining_agent_total_limits); - - // Update the remaining agent addition limit for the selected asset, if applicable, and remove it - // from the options if the limit is exhausted. - if best_asset.is_candidate() { - // Candidate assets: remove capacity from the investment limit, if applicable. - subtract_capacity_from_remaining_limit(best_asset, remaining_agent_addition_limits); - } else { - // Commissioned assets: we've appraised a single tranche, so remove one tranche from the - // available retention count for this asset. - let remaining = available_retention_tranches.get_mut(best_asset).unwrap(); - *remaining = remaining.saturating_sub(1); - - // If all tranches have been selected, remove the asset from the investment options. - if *remaining == 0 { - let old_idx = opt_assets - .iter() - .position(|asset| *asset == *best_asset) - .unwrap(); - opt_assets.swap_remove(old_idx); - available_retention_tranches.remove(best_asset); - } - } -} - -/// Record a selected asset. Candidate selections represent one tranche; repeated selections of the -/// same candidate increase the resulting asset's number of tranches while preserving its tranche -/// size. -/// -/// # Arguments -/// -/// * `best_asset` - The asset that has been selected as the best option in this round -/// * `best_assets` - The list of assets that have been selected so far -fn record_asset_selection(best_asset: AssetRef, best_assets: &mut Vec) { - assert!( - best_asset.is_commissioned() || best_asset.is_candidate(), - "Invalid asset type" - ); - - // Add the selected asset to the list of best assets, or add one tranche if it's already there. - if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset) { - // If the asset is already selected, add the additional required tranche - existing_asset - .make_mut() - .increase_capacity(best_asset.capacity()); - } else { - // Otherwise add it to the list of best assets. Selected assets are unmothballed. - best_assets.push(best_asset.with_no_mothballed_tranches()); - } -} - #[cfg(test)] mod tests { use super::*; use crate::commodity::Commodity; use crate::fixture::{ - agent_id, asset, process, process_activity_limits_map, process_flows_map, svd_commodity, - time_slice, time_slice_info, time_slice_info2, + asset, process, process_activity_limits_map, process_flows_map, svd_commodity, time_slice, + time_slice_info, time_slice_info2, }; use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow}; use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection}; use crate::units::Dimensionless; use crate::units::{Flow, FlowPerActivity, MoneyPerFlow}; use indexmap::indexmap; - use rstest::{fixture, rstest}; + use rstest::rstest; use std::sync::Arc; @@ -759,145 +412,4 @@ mod tests { assert_eq!(result, Capacity(20.0)); } - - #[rstest] - #[case(Flow(10.0), Dimensionless(1.0), Capacity(10.0))] // normal: demand / (limit * coeff) = 10 / (1 * 1) = 10 - #[case(Flow(0.0), Dimensionless(1.0), Capacity(0.0))] // zero demand → zero capacity - #[case(Flow(10.0), Dimensionless(0.5), Capacity(20.0))] // activity limit < 1: 10 / (0.5 * 1) = 20 - #[case(Flow(10.0), Dimensionless(0.0), Capacity(0.0))] // activity limit = 0 → zero capacity - fn calculate_asset_capacity_scale_works( - time_slice: TimeSliceID, - time_slice_info: TimeSliceInfo, - svd_commodity: Commodity, - mut process: Process, - #[case] demand_value: Flow, - #[case] activity_limit: Dimensionless, - #[case] expected: Capacity, - ) { - let commodity_rc = Arc::new(svd_commodity); - let process_flow = ProcessFlow { - commodity: Arc::clone(&commodity_rc), - coeff: FlowPerActivity(1.0), - kind: FlowType::Fixed, - cost: MoneyPerFlow(0.0), - }; - process.flows = process_flows_map( - process.regions.clone(), - Arc::new(indexmap! { commodity_rc.id.clone() => process_flow }), - ); - - let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info); - limits.add_time_slice_limit(time_slice.clone(), Dimensionless(0.0)..=activity_limit); - process.activity_limits = process_activity_limits_map(process.regions.clone(), limits); - - let asset = asset(process); - let demand = indexmap! { TimeSliceSelection::Single(time_slice)=> demand_value }; - assert_eq!( - calculate_candidate_asset_capacity_scale(&asset, &commodity_rc, &demand), - expected - ); - } - - #[rstest] - fn subtract_capacity_from_remaining_limit_works(asset: Asset) { - let mut remaining_agent_limits = - HashMap::from([(asset.process_id().clone(), Capacity(10.0))]); - subtract_capacity_from_remaining_limit( - &AssetRef::from(asset.clone()), - &mut remaining_agent_limits, - ); - - assert_eq!( - remaining_agent_limits[asset.process_id()].clone(), - Capacity(8.0) - ); - } - - #[fixture] - fn commissioned_asset(agent_id: AgentID, asset: Asset) -> Asset { - Asset::new_commissioned( - agent_id, - Arc::new(asset.process().clone()), - asset.region_id().clone(), - asset.capacity(), - asset.commission_year(), - ) - .unwrap() - } - - #[fixture] - fn candidate_asset(asset: Asset) -> Asset { - Asset::new_candidate( - Arc::new(asset.process().clone()), - asset.region_id().clone(), - asset.capacity().tranche_size(), - asset.commission_year(), - ) - .unwrap() - } - - #[rstest] - fn remove_assets_exceeding_agent_limits_works( - commissioned_asset: Asset, - candidate_asset: Asset, - ) { - let mut opt_assets = vec![ - commissioned_asset.clone().into(), - candidate_asset.clone().into(), - ]; - let limits = HashMap::from([(commissioned_asset.process_id().clone(), Capacity(1.0))]); - - // Check only the candidate asset is removed when `only_candidates` is true - remove_assets_exceeding_agent_limits(&mut opt_assets, &limits, true); - assert_eq!(opt_assets.len(), 1); - assert_eq!(opt_assets[0], commissioned_asset.clone().into()); - - // Check all assets are removed when `only_candidates` is false - opt_assets.push(candidate_asset.clone().into()); - remove_assets_exceeding_agent_limits(&mut opt_assets, &limits, false); - assert_eq!(opt_assets, []); - } - - #[rstest] - #[case(false)] - #[case(true)] - fn update_selection_state_works( - commissioned_asset: Asset, - candidate_asset: Asset, - #[case] commissioned: bool, - ) { - let best_asset: AssetRef = if commissioned { - commissioned_asset.clone().into() - } else { - candidate_asset.clone().into() - }; - let mut opt_assets = vec![best_asset.clone()]; - let mut addition_limits = HashMap::from([(best_asset.process_id().clone(), Capacity(2.0))]); - let mut total_limits = HashMap::from([(best_asset.process_id().clone(), Capacity(2.0))]); - let mut retention_tranches = HashMap::from([(best_asset.clone(), 1)]); - - update_selection_state( - &best_asset, - &mut opt_assets, - &mut addition_limits, - &mut total_limits, - &mut retention_tranches, - ); - - if commissioned { - // Expect total_limits to reduce and retention_tranches to be removed - assert_eq!(total_limits[best_asset.process_id()], Capacity(0.0)); - assert!(!retention_tranches.contains_key(&best_asset)); - - // Expect addition_limits to remain unchanged - assert_eq!(addition_limits[best_asset.process_id()], Capacity(2.0)); - } else { - // Expect total_limits and addition_limits to reduce - assert_eq!(total_limits[best_asset.process_id()], Capacity(0.0)); - assert_eq!(addition_limits[best_asset.process_id()], Capacity(0.0)); - - // Expect retention_tranches to remain unchanged - assert_eq!(retention_tranches[&best_asset], 1); - } - } } diff --git a/src/simulation/investment/competitive.rs b/src/simulation/investment/competitive.rs index f54dc5210..1e01bef6b 100644 --- a/src/simulation/investment/competitive.rs +++ b/src/simulation/investment/competitive.rs @@ -7,3 +7,591 @@ mod constraints; mod costs; pub mod metrics; mod optimisation; + +use crate::agent::{Agent, AgentID}; +use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef}; +use crate::commodity::{Commodity, CommodityID}; +use crate::model::Model; +use crate::output::DataWriter; +use crate::process::ProcessID; +use crate::region::RegionID; +use crate::simulation::investment::DemandMap; +use crate::simulation::prices::Prices; +use crate::time_slice::TimeSliceSelection; +use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity}; +use anyhow::{Result, ensure}; +use coefficients::calculate_coefficients_for_assets; +use itertools::{Itertools, chain}; +use log::{debug, warn}; +use metrics::{ + AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs, + sort_and_filter_appraisal_outputs, +}; +use rayon::prelude::*; +use std::collections::HashMap; + +/// Get options from existing and potential assets for the given parameters +pub fn get_asset_options<'a>( + all_existing_assets: &'a [AssetRef], + demand: &'a DemandMap, + agent: &'a Agent, + commodity: &'a Commodity, + region_id: &'a RegionID, + year: u32, + capacity_tranche_fraction: Dimensionless, +) -> impl Iterator + 'a { + // Get existing assets which produce the commodity of interest + let existing_assets = all_existing_assets + .iter() + .filter_agent(&agent.id) + .filter_region(region_id) + .filter_primary_producers_of(&commodity.id) + .cloned(); + + // Get candidates assets which produce the commodity of interest + let candidate_assets = get_candidate_assets( + demand, + agent, + region_id, + commodity, + year, + capacity_tranche_fraction, + ); + + chain(existing_assets, candidate_assets) +} + +/// Get candidate assets which produce a particular commodity for a given agent +/// +/// Each candidate represents one investment tranche with a defined capacity. +/// - For processes with a defined `tranche_size`, the capacity is set to `tranche_size`. +/// - For processes without a defined `tranche_size`, the capacity is calculated based on the total +/// demand for the commodity and the asset's maximum annual production per unit capacity +/// (see `calculate_candidate_asset_capacity_scale`), then multiplied by +/// `capacity_tranche_fraction` to infer the tranche size. +fn get_candidate_assets<'a>( + demand: &'a DemandMap, + agent: &'a Agent, + region_id: &'a RegionID, + commodity: &'a Commodity, + year: u32, + capacity_tranche_fraction: Dimensionless, +) -> impl Iterator + 'a { + agent + .iter_search_space(region_id, &commodity.id, year) + .map(move |process| { + // Create asset with zero capacity, which will be updated below + let mut asset = + Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year) + .unwrap(); + + // Set capacity of the candidate for investment appraisal + let tranche_size = if let Some(tranche_size) = asset.process().tranche_size { + // For processes with a defined tranche size, take this + tranche_size + } else { + // Otherwise, infer the tranche size from demand and the capacity_tranche_fraction. + let capacity_scale = + calculate_candidate_asset_capacity_scale(&asset, commodity, demand); + capacity_scale * capacity_tranche_fraction + }; + let asset_capacity = AssetCapacity::single(tranche_size); + asset.set_capacity(asset_capacity); + asset.into() + }) +} + +/// Calculates a characteristic capacity scale for a candidate asset. +/// +/// The returned value is the capacity that would satisfy the total annual demand assuming the asset +/// operates at its maximum annual activity for the entire year. It ignores finer-grained activity +/// constraints and temporal variations in demand. +/// +/// For processes that do not define a tranche size, this value is later scaled by +/// `capacity_tranche_fraction` to set the capacity of one candidate investment tranche. +/// +/// If the asset has zero maximum annual supply, zero capacity is returned. This indicates that the +/// asset is non-feasible, and will be excluded from consideration by `select_best_assets`. +pub fn calculate_candidate_asset_capacity_scale( + asset: &Asset, + commodity: &Commodity, + demand: &DemandMap, +) -> Capacity { + let coeff = asset.get_flow(&commodity.id).unwrap().coeff; + let max_annual_supply_per_capacity = *asset + .get_activity_per_capacity_limits_for_selection(&TimeSliceSelection::Annual) + .end() + * coeff; + if max_annual_supply_per_capacity < FlowPerCapacity::EPSILON { + return Capacity(0.0); + } + let annual_demand = demand.values().copied().sum::(); + annual_demand / max_annual_supply_per_capacity +} + +/// Print debug message if there are multiple equally good outputs +fn log_on_equal_appraisal_outputs( + outputs: &[AppraisalOutput], + agent_id: &AgentID, + commodity_id: &CommodityID, + region_id: &RegionID, +) { + if outputs.is_empty() { + return; + } + + let num_identical = count_equal_and_best_appraisal_outputs(outputs); + + if num_identical > 0 { + let asset_details = outputs[..=num_identical] + .iter() + .map(|output| { + let asset = &output.asset; + format!( + "Process ID: '{}' (State: {}{}, Commission year: {})", + asset.process_id(), + asset.state(), + asset + .id() + .map(|id| format!(", Asset ID: {id}")) + .unwrap_or_default(), + asset.commission_year() + ) + }) + .join(", "); + debug!( + "Found equally good appraisals for Agent ID: {agent_id}, Commodity: '{commodity_id}', \ + Region: {region_id}. Options: [{asset_details}]. Selecting first option.", + ); + } +} + +/// Get the best assets for meeting demand for the given commodity +#[allow(clippy::too_many_arguments)] +pub fn select_best_assets( + model: &Model, + mut opt_assets: Vec, + agent_addition_limits: HashMap, + agent_total_limits: HashMap, + commodity: &Commodity, + agent: &Agent, + region_id: &RegionID, + prices: &Prices, + mut demand: DemandMap, + year: u32, + writer: &mut DataWriter, +) -> Result> { + let objective_type = &agent.objectives[&year]; + + // Remaining addition limits for candidate processes + // Initialised as the full agent addition limits, and reduced as candidate assets are selected + let mut remaining_agent_addition_limits = agent_addition_limits; + + // Remaining total capacity for all assets + // Initialised as full agent total limits, and reduced as each asset is selection + let mut remaining_agent_total_limits = agent_total_limits; + + // Store commissioned tranches available for retention and replace assets with single tranches + let mut available_retention_tranches = + 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); + + // Iteratively select the best asset until demand is met + let mut round = 0; + let mut best_assets: Vec = Vec::new(); + while is_any_remaining_demand( + &demand, + model.parameters.remaining_demand_absolute_tolerance, + ) { + // Remove assets that would exceed the remaining limits for their processes from the options + // The addition limit applies only to candidate assets, the total limit applies to all assets + remove_assets_exceeding_agent_limits( + &mut opt_assets, + &remaining_agent_addition_limits, + true, + ); + remove_assets_exceeding_agent_limits(&mut opt_assets, &remaining_agent_total_limits, false); + ensure!( + !opt_assets.is_empty(), + "Failed to meet demand for commodity '{}' in region '{}' with provided investment \ + options. This may be due to overly restrictive process investment constraints.", + commodity.id, + 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 + .par_iter() + .map(|asset| -> Result> { + // Skip assets with zero capacity + if asset.total_capacity() <= Capacity(0.0) { + return Ok(None); + } + + Ok(Some(appraise_investment( + model, + asset, + commodity, + objective_type, + &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, + commodity.time_slice_level, + )?; + + // Sort by investment priority and discard non-feasible options + let num_nonfeasible = sort_and_filter_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 + // warning. + if outputs.is_empty() { + warn!( + "Investment appraisal completed with unmet demand for commodity '{}', region '{}', \ + year '{}', agent '{}'. {} non-feasible investments were not considered. \ + This unmet demand may still be satisfied during the full system dispatch.", + commodity.id, region_id, year, agent.id, num_nonfeasible + ); + break; + } + + // 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(); + + // Log the selected asset + debug!( + "Selected {} asset '{}' (capacity: {})", + best_output.asset.state(), + best_output.asset.process_id(), + best_output.asset.total_capacity() + ); + + // Update the remaining selection state + update_selection_state( + &best_output.asset, + &mut opt_assets, + &mut remaining_agent_addition_limits, + &mut remaining_agent_total_limits, + &mut available_retention_tranches, + ); + + // Record the selected asset + record_asset_selection(best_output.asset, &mut best_assets); + + demand = best_output.unmet_demand; + round += 1; + } + + // Convert Candidate assets to Ready + // At this point we also assign the agent ID to the asset + for asset in &mut best_assets { + if asset.is_candidate() { + asset + .make_mut() + .select_candidate_for_investment(agent.id.clone()); + } + } + + Ok(best_assets) +} + +/// Prepare existing assets for reappraisal. +/// +/// Assets are replaced in `assets` with an asset representing a single tranche, as they are +/// appraised one tranche at a time. Returns a map from the asset to its original number of tranches. +fn prepare_commissioned_assets_for_retention(assets: &mut [AssetRef]) -> HashMap { + let mut available_retention_tranches = HashMap::new(); + + for asset in assets.iter_mut().filter(|asset| asset.is_commissioned()) { + let num_tranches = asset.num_tranches(); + + // Replace with single tranche as we appraise one tranche at a time + *asset = asset.clone().as_single_tranche(); + + // Store remaining tranches + available_retention_tranches.insert(asset.clone(), num_tranches); + } + + available_retention_tranches +} + +/// Check whether there is any remaining demand that is unmet in any time slice +fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool { + demand.values().any(|flow| *flow > absolute_tolerance) +} + +/// Remove assets that exceed the provided process limits for one complete tranche. +fn remove_assets_exceeding_agent_limits( + opt_assets: &mut Vec, + remaining_agent_limits: &HashMap, + only_candidates: bool, +) { + opt_assets.retain(|asset| { + (only_candidates && !asset.is_candidate()) + || remaining_agent_limits + .get(asset.process_id()) + .is_none_or(|limit| *limit >= asset.total_capacity()) + }); +} + +// Update remaining process capacity limit with the capacity of a selected asset +fn subtract_capacity_from_remaining_limit( + best_asset: &AssetRef, + remaining_agent_limits: &mut HashMap, +) { + if let Some(remaining_capacity) = remaining_agent_limits.get_mut(best_asset.process_id()) { + *remaining_capacity -= best_asset.total_capacity(); + assert!( + *remaining_capacity >= Capacity(0.0), + "Remaining Capacity has fallen below zero" + ); + } +} + +/// Update the remaining investment options and selection state. +/// +/// If the asset is a candidate, its one-tranche capacity is subtracted from +/// `remaining_agent_addition_limits` (if applicable) to ensure that process addition limits are not +/// exceeded. If the asset is commissioned, one tranche is subtracted from +/// `available_retention_tranches` to ensure that retention does not invent new capacity. +/// +/// # Arguments +/// +/// * `best_asset` - The asset that has been selected as the best option in this round +/// * `opt_assets` - The list of remaining asset options to be considered in future rounds +/// * `remaining_agent_addition_limits` - The remaining agent addition limits for processes +/// * `remaining_agent_total_limit` - The remaining capacity for processes +/// * `available_retention_tranches` - The commissioned tranches available for retention +fn update_selection_state( + best_asset: &AssetRef, + opt_assets: &mut Vec, + remaining_agent_addition_limits: &mut HashMap, + remaining_agent_total_limits: &mut HashMap, + available_retention_tranches: &mut HashMap, +) { + // Subtract asset capacity from the total capacity limit, if applicable. + subtract_capacity_from_remaining_limit(best_asset, remaining_agent_total_limits); + + // Update the remaining agent addition limit for the selected asset, if applicable, and remove it + // from the options if the limit is exhausted. + if best_asset.is_candidate() { + // Candidate assets: remove capacity from the investment limit, if applicable. + subtract_capacity_from_remaining_limit(best_asset, remaining_agent_addition_limits); + } else { + // Commissioned assets: we've appraised a single tranche, so remove one tranche from the + // available retention count for this asset. + let remaining = available_retention_tranches.get_mut(best_asset).unwrap(); + *remaining = remaining.saturating_sub(1); + + // If all tranches have been selected, remove the asset from the investment options. + if *remaining == 0 { + let old_idx = opt_assets + .iter() + .position(|asset| *asset == *best_asset) + .unwrap(); + opt_assets.swap_remove(old_idx); + available_retention_tranches.remove(best_asset); + } + } +} + +/// Record a selected asset. Candidate selections represent one tranche; repeated selections of the +/// same candidate increase the resulting asset's number of tranches while preserving its tranche +/// size. +/// +/// # Arguments +/// +/// * `best_asset` - The asset that has been selected as the best option in this round +/// * `best_assets` - The list of assets that have been selected so far +fn record_asset_selection(best_asset: AssetRef, best_assets: &mut Vec) { + assert!( + best_asset.is_commissioned() || best_asset.is_candidate(), + "Invalid asset type" + ); + + // Add the selected asset to the list of best assets, or add one tranche if it's already there. + if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset) { + // If the asset is already selected, add the additional required tranche + existing_asset + .make_mut() + .increase_capacity(best_asset.capacity()); + } else { + // Otherwise add it to the list of best assets. Selected assets are unmothballed. + best_assets.push(best_asset.with_no_mothballed_tranches()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixture::{ + agent_id, asset, process, process_activity_limits_map, process_flows_map, svd_commodity, + time_slice, time_slice_info, + }; + use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow}; + use crate::time_slice::{TimeSliceID, TimeSliceInfo}; + use crate::units::{FlowPerActivity, MoneyPerFlow}; + use indexmap::indexmap; + use rstest::{fixture, rstest}; + use std::sync::Arc; + + #[rstest] + #[case(Flow(10.0), Dimensionless(1.0), Capacity(10.0))] // normal: demand / (limit * coeff) = 10 / (1 * 1) = 10 + #[case(Flow(0.0), Dimensionless(1.0), Capacity(0.0))] // zero demand → zero capacity + #[case(Flow(10.0), Dimensionless(0.5), Capacity(20.0))] // activity limit < 1: 10 / (0.5 * 1) = 20 + #[case(Flow(10.0), Dimensionless(0.0), Capacity(0.0))] // activity limit = 0 → zero capacity + fn calculate_asset_capacity_scale_works( + time_slice: TimeSliceID, + time_slice_info: TimeSliceInfo, + svd_commodity: Commodity, + mut process: Process, + #[case] demand_value: Flow, + #[case] activity_limit: Dimensionless, + #[case] expected: Capacity, + ) { + let commodity_rc = Arc::new(svd_commodity); + let process_flow = ProcessFlow { + commodity: Arc::clone(&commodity_rc), + coeff: FlowPerActivity(1.0), + kind: FlowType::Fixed, + cost: MoneyPerFlow(0.0), + }; + process.flows = process_flows_map( + process.regions.clone(), + Arc::new(indexmap! { commodity_rc.id.clone() => process_flow }), + ); + + let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info); + limits.add_time_slice_limit(time_slice.clone(), Dimensionless(0.0)..=activity_limit); + process.activity_limits = process_activity_limits_map(process.regions.clone(), limits); + + let asset = asset(process); + let demand = indexmap! { TimeSliceSelection::Single(time_slice)=> demand_value }; + assert_eq!( + calculate_candidate_asset_capacity_scale(&asset, &commodity_rc, &demand), + expected + ); + } + + #[rstest] + fn subtract_capacity_from_remaining_limit_works(asset: Asset) { + let mut remaining_agent_limits = + HashMap::from([(asset.process_id().clone(), Capacity(10.0))]); + subtract_capacity_from_remaining_limit( + &AssetRef::from(asset.clone()), + &mut remaining_agent_limits, + ); + + assert_eq!( + remaining_agent_limits[asset.process_id()].clone(), + Capacity(8.0) + ); + } + + #[fixture] + fn commissioned_asset(agent_id: AgentID, asset: Asset) -> Asset { + Asset::new_commissioned( + agent_id, + Arc::new(asset.process().clone()), + asset.region_id().clone(), + asset.capacity(), + asset.commission_year(), + ) + .unwrap() + } + + #[fixture] + fn candidate_asset(asset: Asset) -> Asset { + Asset::new_candidate( + Arc::new(asset.process().clone()), + asset.region_id().clone(), + asset.capacity().tranche_size(), + asset.commission_year(), + ) + .unwrap() + } + + #[rstest] + fn remove_assets_exceeding_agent_limits_works( + commissioned_asset: Asset, + candidate_asset: Asset, + ) { + let mut opt_assets = vec![ + commissioned_asset.clone().into(), + candidate_asset.clone().into(), + ]; + let limits = HashMap::from([(commissioned_asset.process_id().clone(), Capacity(1.0))]); + + // Check only the candidate asset is removed when `only_candidates` is true + remove_assets_exceeding_agent_limits(&mut opt_assets, &limits, true); + assert_eq!(opt_assets.len(), 1); + assert_eq!(opt_assets[0], commissioned_asset.clone().into()); + + // Check all assets are removed when `only_candidates` is false + opt_assets.push(candidate_asset.clone().into()); + remove_assets_exceeding_agent_limits(&mut opt_assets, &limits, false); + assert_eq!(opt_assets, []); + } + + #[rstest] + #[case(false)] + #[case(true)] + fn update_selection_state_works( + commissioned_asset: Asset, + candidate_asset: Asset, + #[case] commissioned: bool, + ) { + let best_asset: AssetRef = if commissioned { + commissioned_asset.clone().into() + } else { + candidate_asset.clone().into() + }; + let mut opt_assets = vec![best_asset.clone()]; + let mut addition_limits = HashMap::from([(best_asset.process_id().clone(), Capacity(2.0))]); + let mut total_limits = HashMap::from([(best_asset.process_id().clone(), Capacity(2.0))]); + let mut retention_tranches = HashMap::from([(best_asset.clone(), 1)]); + + update_selection_state( + &best_asset, + &mut opt_assets, + &mut addition_limits, + &mut total_limits, + &mut retention_tranches, + ); + + if commissioned { + // Expect total_limits to reduce and retention_tranches to be removed + assert_eq!(total_limits[best_asset.process_id()], Capacity(0.0)); + assert!(!retention_tranches.contains_key(&best_asset)); + + // Expect addition_limits to remain unchanged + assert_eq!(addition_limits[best_asset.process_id()], Capacity(2.0)); + } else { + // Expect total_limits and addition_limits to reduce + assert_eq!(total_limits[best_asset.process_id()], Capacity(0.0)); + assert_eq!(addition_limits[best_asset.process_id()], Capacity(0.0)); + + // Expect retention_tranches to remain unchanged + assert_eq!(retention_tranches[&best_asset], 1); + } + } +} diff --git a/src/simulation/market.rs b/src/simulation/market.rs index eb25275a1..2c4ea869f 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -1,21 +1,19 @@ //! Code for creating sets of markets. use super::optimisation::{DispatchRun, FlowMap}; use crate::agent::Agent; -use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef}; +use crate::asset::AssetRef; use crate::commodity::{Commodity, CommodityID}; use crate::model::Model; use crate::output::DataWriter; use crate::process::{Process, ProcessID}; use crate::region::RegionID; -use crate::simulation::investment::{ - AllDemandMap, DemandMap, calculate_candidate_asset_capacity_scale, select_best_assets, - update_net_demand_map, -}; +use crate::simulation::investment::competitive::{get_asset_options, select_best_assets}; +use crate::simulation::investment::{AllDemandMap, DemandMap, update_net_demand_map}; use crate::simulation::prices::Prices; use crate::time_slice::TimeSliceInfo; use crate::units::{Capacity, Dimensionless, Flow}; use anyhow::{Context, Result}; -use itertools::{Itertools, chain}; +use itertools::Itertools; use log::debug; use std::collections::HashMap; use std::fmt::Display; @@ -442,77 +440,6 @@ where }) } -/// Get options from existing and potential assets for the given parameters -pub fn get_asset_options<'a>( - all_existing_assets: &'a [AssetRef], - demand: &'a DemandMap, - agent: &'a Agent, - commodity: &'a Commodity, - region_id: &'a RegionID, - year: u32, - capacity_tranche_fraction: Dimensionless, -) -> impl Iterator + 'a { - // Get existing assets which produce the commodity of interest - let existing_assets = all_existing_assets - .iter() - .filter_agent(&agent.id) - .filter_region(region_id) - .filter_primary_producers_of(&commodity.id) - .cloned(); - - // Get candidates assets which produce the commodity of interest - let candidate_assets = get_candidate_assets( - demand, - agent, - region_id, - commodity, - year, - capacity_tranche_fraction, - ); - - chain(existing_assets, candidate_assets) -} - -/// Get candidate assets which produce a particular commodity for a given agent -/// -/// Each candidate represents one investment tranche with a defined capacity. -/// - For processes with a defined `tranche_size`, the capacity is set to `tranche_size`. -/// - For processes without a defined `tranche_size`, the capacity is calculated based on the total -/// demand for the commodity and the asset's maximum annual production per unit capacity -/// (see `calculate_candidate_asset_capacity_scale`), then multiplied by -/// `capacity_tranche_fraction` to infer the tranche size. -fn get_candidate_assets<'a>( - demand: &'a DemandMap, - agent: &'a Agent, - region_id: &'a RegionID, - commodity: &'a Commodity, - year: u32, - capacity_tranche_fraction: Dimensionless, -) -> impl Iterator + 'a { - agent - .iter_search_space(region_id, &commodity.id, year) - .map(move |process| { - // Create asset with zero capacity, which will be updated below - let mut asset = - Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year) - .unwrap(); - - // Set capacity of the candidate for investment appraisal - let tranche_size = if let Some(tranche_size) = asset.process().tranche_size { - // For processes with a defined tranche size, take this - tranche_size - } else { - // Otherwise, infer the tranche size from demand and the capacity_tranche_fraction. - let capacity_scale = - calculate_candidate_asset_capacity_scale(&asset, commodity, demand); - capacity_scale * capacity_tranche_fraction - }; - let asset_capacity = AssetCapacity::single(tranche_size); - asset.set_capacity(asset_capacity); - asset.into() - }) -} - /// Collects capacity limits for all processes in the agent's search space for a given market. /// /// Processes without a defined limit are excluded from the returned map. The limit type is From 9ff7512611a0125a2cf081af749a088fddde857a Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 4 Nov 2026 11:51:44 +0000 Subject: [PATCH 3/7] Add competitive::select_assets as the entry point for asset selection Option building and process exclusion move inside the algorithm, so select_assets_for_single_market only computes demand portions and limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/simulation/investment/competitive.rs | 68 ++++++++++++++++++++++++ src/simulation/market.rs | 30 +++-------- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/simulation/investment/competitive.rs b/src/simulation/investment/competitive.rs index 1e01bef6b..045735808 100644 --- a/src/simulation/investment/competitive.rs +++ b/src/simulation/investment/competitive.rs @@ -30,6 +30,74 @@ use metrics::{ use rayon::prelude::*; use std::collections::HashMap; +/// Select assets for an agent to meet its share of demand for a commodity in a region. +/// +/// Existing assets of the agent which produce the commodity compete with candidate assets from the +/// agent's search space. Processes in `excluded_processes` are not considered. +/// +/// # Arguments +/// +/// * `model` - The model +/// * `agent` - The agent selecting assets +/// * `commodity` - The commodity for which demand must be met +/// * `region_id` - The region +/// * `demand` - The agent's share of demand for the commodity in the region +/// * `existing_assets` - The asset pool (commissioned and otherwise) +/// * `excluded_processes` - Processes which may not be selected +/// * `agent_addition_limits` - The agent's share of addition limits for candidate processes +/// * `agent_total_limits` - The agent's share of total capacity limits for processes +/// * `prices` - Commodity prices from the previous full system dispatch +/// * `year` - Current milestone year +/// * `writer` - Data writer +/// +/// # Returns +/// +/// The assets selected (including retained commissioned assets), or an error. +#[allow(clippy::too_many_arguments)] +pub fn select_assets( + model: &Model, + agent: &Agent, + commodity: &Commodity, + region_id: &RegionID, + demand: DemandMap, + existing_assets: &[AssetRef], + excluded_processes: &[ProcessID], + agent_addition_limits: HashMap, + agent_total_limits: HashMap, + prices: &Prices, + year: u32, + writer: &mut DataWriter, +) -> Result> { + // Existing and candidate assets from which to choose + let mut opt_assets = get_asset_options( + existing_assets, + &demand, + agent, + commodity, + region_id, + year, + model.parameters.capacity_tranche_fraction, + ) + .collect::>(); + + // Exclude certain processes from the options + opt_assets.retain(|asset| !excluded_processes.contains(asset.process_id())); + + select_best_assets( + model, + opt_assets, + agent_addition_limits, + agent_total_limits, + commodity, + agent, + region_id, + prices, + demand, + year, + writer, + ) +} + /// Get options from existing and potential assets for the given parameters pub fn get_asset_options<'a>( all_existing_assets: &'a [AssetRef], diff --git a/src/simulation/market.rs b/src/simulation/market.rs index 2c4ea869f..ad21870db 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -7,7 +7,7 @@ use crate::model::Model; use crate::output::DataWriter; use crate::process::{Process, ProcessID}; use crate::region::RegionID; -use crate::simulation::investment::competitive::{get_asset_options, select_best_assets}; +use crate::simulation::investment::competitive::select_assets; use crate::simulation::investment::{AllDemandMap, DemandMap, update_net_demand_map}; use crate::simulation::prices::Prices; use crate::time_slice::TimeSliceInfo; @@ -185,21 +185,6 @@ pub fn select_assets_for_single_market( commodity_portion, ); - // Existing and candidate assets from which to choose - let mut opt_assets = get_asset_options( - existing_assets, - &demand_portion_for_market, - agent, - commodity, - region_id, - year, - model.parameters.capacity_tranche_fraction, - ) - .collect::>(); - - // Exclude certain processes from the options - opt_assets.retain(|asset| !excluded_processes.contains(asset.process_id())); - // Calculate the agent's share of addition limits for candidate processes let agent_addition_limits = collect_agent_limits( agent, @@ -221,16 +206,17 @@ pub fn select_assets_for_single_market( ); // Choose assets from among existing pool and candidates - let best_assets = select_best_assets( + let best_assets = select_assets( model, - opt_assets, - agent_addition_limits, - agent_total_limits, - commodity, agent, + commodity, region_id, - prices, demand_portion_for_market, + existing_assets, + excluded_processes, + agent_addition_limits, + agent_total_limits, + prices, year, writer, )?; From 23d5c9973314e36bfc427db5eeaea255bffc28bf Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 4 Nov 2026 11:53:02 +0000 Subject: [PATCH 4/7] Add generic debug row writer to DataWriter Allows modules such as asset selection algorithms to own their debug files, which are created lazily on first write. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/output.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/output.rs b/src/output.rs index a0498aca7..f3b3ec345 100644 --- a/src/output.rs +++ b/src/output.rs @@ -13,6 +13,8 @@ use anyhow::{Context, Result, ensure}; use csv; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::fs; use std::fs::File; use std::path::{Path, PathBuf}; @@ -265,6 +267,7 @@ struct AppraisalResultsTimeSliceRow { /// For writing extra debug information about the model struct DebugDataWriter { context: Option, + output_path: PathBuf, unmet_demand_file_path: PathBuf, commodity_balance_duals_writer: csv::Writer, unmet_demand_writer: Option>, @@ -272,6 +275,8 @@ struct DebugDataWriter { appraisal_results_writer: csv::Writer, appraisal_results_time_slice_writer: csv::Writer, dispatch_asset_writer: csv::Writer, + /// Writers for debug files owned by other modules, created on first use + custom_writers: HashMap<&'static str, csv::Writer>, } impl DebugDataWriter { @@ -288,6 +293,7 @@ impl DebugDataWriter { Ok(Self { context: None, + output_path: output_path.to_path_buf(), unmet_demand_file_path: output_path.join(UNMET_DEMAND_FILE_NAME), commodity_balance_duals_writer: new_writer(COMMODITY_BALANCE_DUALS_FILE_NAME)?, unmet_demand_writer: None, @@ -297,6 +303,7 @@ impl DebugDataWriter { APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME, )?, dispatch_asset_writer: new_writer(ACTIVITY_ASSET_DISPATCH)?, + custom_writers: HashMap::new(), }) } @@ -538,6 +545,27 @@ impl DebugDataWriter { Ok(()) } + /// Write rows to a debug file owned by another module + /// + /// The file is created on first use, even if `rows` is empty. + fn write_custom_rows( + &mut self, + file_name: &'static str, + rows: impl IntoIterator, + ) -> Result<()> { + let writer = match self.custom_writers.entry(file_name) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + entry.insert(csv::Writer::from_path(self.output_path.join(file_name))?) + } + }; + for row in rows { + writer.serialize(row)?; + } + + Ok(()) + } + /// Flush the underlying streams fn flush(&mut self) -> Result<()> { if let Some(wrt) = &mut self.unmet_demand_writer { @@ -548,6 +576,9 @@ impl DebugDataWriter { self.appraisal_results_writer.flush()?; self.appraisal_results_time_slice_writer.flush()?; self.dispatch_asset_writer.flush()?; + for wrt in self.custom_writers.values_mut() { + wrt.flush()?; + } Ok(()) } @@ -608,6 +639,39 @@ impl DataWriter { Ok(()) } + /// Whether debug files are being written + pub fn debug_enabled(&self) -> bool { + self.debug.is_some() + } + + /// Prepend the current debug context (if any) to a run description + pub fn debug_run_description(&self, run_description: &str) -> String { + match &self.debug { + Some(wtr) => wtr.with_context(run_description), + None => run_description.to_string(), + } + } + + /// Write rows to a custom debug CSV file in the output directory + /// + /// This allows other modules (e.g. asset selection algorithms) to own their debug files. The + /// file is created the first time it is written to (even if `rows` is empty). Does nothing if + /// debug output is disabled. + /// + /// Files for asset selection algorithms should be named + /// `debug_asset_selection_[_].csv`. + pub fn write_debug_rows( + &mut self, + file_name: &'static str, + rows: impl IntoIterator, + ) -> Result<()> { + if let Some(wtr) = &mut self.debug { + wtr.write_custom_rows(file_name, rows)?; + } + + Ok(()) + } + /// Write debug info about the investment appraisal pub fn write_appraisal_debug_info( &mut self, @@ -1130,6 +1194,52 @@ mod tests { assert_equal(records, iter::once(expected)); } + #[rstest] + #[case(true)] + #[case(false)] + fn write_debug_rows(#[case] debug: bool) { + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestRow { + run_description: String, + value: u32, + } + + let dir = tempdir().unwrap(); + let file_name = "debug_test.csv"; + let rows = [ + TestRow { + run_description: "a".into(), + value: 1, + }, + TestRow { + run_description: "b".into(), + value: 2, + }, + ]; + + { + let mut writer = DataWriter::create(dir.path(), dir.path(), debug).unwrap(); + assert_eq!(writer.debug_enabled(), debug); + + // Rows written across multiple calls end up in the same file + writer.write_debug_rows(file_name, &rows[..1]).unwrap(); + writer.write_debug_rows(file_name, &rows[1..]).unwrap(); + writer.flush().unwrap(); + } + + let path = dir.path().join(file_name); + if debug { + let records: Vec = csv::Reader::from_path(path) + .unwrap() + .into_deserialize() + .try_collect() + .unwrap(); + assert_equal(records, rows); + } else { + assert!(!path.exists()); + } + } + #[rstest] fn write_appraisal_time_slice_results( asset: Asset, From af8873440472ad0fb24d535a5905853a2b8733d0 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 4 Nov 2026 11:58:10 +0000 Subject: [PATCH 5/7] Make competitive algorithm own its debug files Remove appraisal-specific code from the output module. The competitive algorithm now writes debug_asset_selection_competitive.csv and debug_asset_selection_competitive_time_slices.csv via DataWriter::write_debug_rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fixture.rs | 29 +-- src/output.rs | 222 +----------------- src/simulation/investment/competitive.rs | 5 +- .../investment/competitive/debug.rs | 197 ++++++++++++++++ ... => debug_asset_selection_competitive.csv} | 0 ...set_selection_competitive_time_slices.csv} | 0 6 files changed, 208 insertions(+), 245 deletions(-) create mode 100644 src/simulation/investment/competitive/debug.rs rename tests/data/simple/{debug_appraisal_results.csv => debug_asset_selection_competitive.csv} (100%) rename tests/data/simple/{debug_appraisal_results_time_slices.csv => debug_asset_selection_competitive_time_slices.csv} (100%) diff --git a/src/fixture.rs b/src/fixture.rs index 01d65433e..a8ff01f94 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -4,7 +4,7 @@ use crate::agent::{ Agent, AgentCommodityPortionsMap, AgentID, AgentMap, AgentObjectiveMap, AgentSearchSpaceMap, DecisionRule, }; -use crate::asset::{Asset, AssetCapacity, AssetPool, AssetRef}; +use crate::asset::{Asset, AssetCapacity, AssetPool}; use crate::commodity::{ Commodity, CommodityConstraintsMap, CommodityID, CommodityLevyMap, CommodityType, DemandMap, PricingStrategy, @@ -15,13 +15,10 @@ use crate::process::{ ProcessInvestmentConstraintsMap, ProcessMap, ProcessParameter, ProcessParameterMap, }; use crate::region::RegionID; -use crate::simulation::investment::competitive::coefficients::ObjectiveCoefficients; -use crate::simulation::investment::competitive::metrics::AppraisalOutput; -use crate::simulation::investment::competitive::metrics::LCOXMetric; -use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel, TimeSliceSelection}; +use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel}; use crate::units::{ - Activity, ActivityPerCapacity, Capacity, Dimensionless, Flow, MoneyPerActivity, - MoneyPerCapacity, MoneyPerCapacityPerYear, Year, + ActivityPerCapacity, Capacity, Dimensionless, MoneyPerActivity, MoneyPerCapacity, + MoneyPerCapacityPerYear, Year, }; use anyhow::Result; use indexmap::indexmap; @@ -400,24 +397,6 @@ 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! { TimeSliceSelection::Single(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)))), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/output.rs b/src/output.rs index f3b3ec345..d8aea2b79 100644 --- a/src/output.rs +++ b/src/output.rs @@ -4,10 +4,9 @@ use crate::asset::{Asset, AssetID, AssetRef}; use crate::commodity::CommodityID; use crate::process::ProcessID; use crate::region::RegionID; -use crate::simulation::investment::competitive::metrics::AppraisalOutput; use crate::simulation::optimisation::{FlowMap, Solution}; use crate::simulation::prices::PriceMap; -use crate::time_slice::{TimeSliceID, TimeSliceLevel, TimeSliceSelection}; +use crate::time_slice::TimeSliceID; use crate::units::{Activity, Capacity, Flow, Money, MoneyPerActivity, MoneyPerFlow}; use anyhow::{Context, Result, ensure}; use csv; @@ -46,12 +45,6 @@ const UNMET_DEMAND_FILE_NAME: &str = "debug_unmet_demand.csv"; /// The output file name for extra solver output values const SOLVER_VALUES_FILE_NAME: &str = "debug_solver.csv"; -/// The output file name for appraisal results -const APPRAISAL_RESULTS_FILE_NAME: &str = "debug_appraisal_results.csv"; - -/// The output file name for appraisal time slice results -const APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME: &str = "debug_appraisal_results_time_slices.csv"; - /// Get the default output directory for the model pub fn get_output_dir(model_dir: &Path, results_root: PathBuf) -> Result { // Get the model name from the dir path. This ends up being convoluted because we need to check @@ -236,34 +229,6 @@ struct SolverValuesRow { objective_value: Money, } -/// Represents the appraisal results in a row of the appraisal results CSV file -#[derive(Serialize, Deserialize, Debug, PartialEq)] -struct AppraisalResultsRow { - milestone_year: u32, - run_description: String, - asset_id: Option, - process_id: ProcessID, - region_id: RegionID, - capacity: Capacity, - metric: Option, -} - -/// Represents the appraisal results in a row of the appraisal results CSV file -#[derive(Serialize, Deserialize, Debug, PartialEq)] -struct AppraisalResultsTimeSliceRow { - milestone_year: u32, - run_description: String, - asset_id: Option, - process_id: ProcessID, - region_id: RegionID, - time_slice: TimeSliceID, - activity: Activity, - activity_coefficient: MoneyPerActivity, - time_slice_level: TimeSliceLevel, - demand_for_selection: Flow, - unmet_demand_for_selection: Flow, -} - /// For writing extra debug information about the model struct DebugDataWriter { context: Option, @@ -272,8 +237,6 @@ struct DebugDataWriter { commodity_balance_duals_writer: csv::Writer, unmet_demand_writer: Option>, solver_values_writer: csv::Writer, - appraisal_results_writer: csv::Writer, - appraisal_results_time_slice_writer: csv::Writer, dispatch_asset_writer: csv::Writer, /// Writers for debug files owned by other modules, created on first use custom_writers: HashMap<&'static str, csv::Writer>, @@ -298,10 +261,6 @@ impl DebugDataWriter { commodity_balance_duals_writer: new_writer(COMMODITY_BALANCE_DUALS_FILE_NAME)?, unmet_demand_writer: None, solver_values_writer: new_writer(SOLVER_VALUES_FILE_NAME)?, - appraisal_results_writer: new_writer(APPRAISAL_RESULTS_FILE_NAME)?, - appraisal_results_time_slice_writer: new_writer( - APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME, - )?, dispatch_asset_writer: new_writer(ACTIVITY_ASSET_DISPATCH)?, custom_writers: HashMap::new(), }) @@ -481,70 +440,6 @@ impl DebugDataWriter { Ok(()) } - /// Write appraisal results to file - fn write_appraisal_results( - &mut self, - milestone_year: u32, - run_description: &str, - appraisal_results: &[AppraisalOutput], - ) -> 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)?; - } - - Ok(()) - } - - /// Write appraisal results to file - fn write_appraisal_time_slice_results( - &mut self, - milestone_year: u32, - run_description: &str, - appraisal_results: &[AppraisalOutput], - demand: &IndexMap, - time_slice_level: TimeSliceLevel, - ) -> Result<()> { - for result in appraisal_results { - for (time_slice, activity) in &result.activity { - let activity_coefficient = result.coefficients.activity_coefficients[time_slice]; - // Map the individual time slice back to its containing selection so we can look - // up selection-level demand and unmet demand. - let selection = match time_slice_level { - TimeSliceLevel::Annual => TimeSliceSelection::Annual, - TimeSliceLevel::Season => TimeSliceSelection::Season(time_slice.season.clone()), - TimeSliceLevel::DayNight => TimeSliceSelection::Single(time_slice.clone()), - }; - let demand = demand[&selection]; - let unmet_demand = result.unmet_demand[&selection]; - 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(), - time_slice: time_slice.clone(), - activity: *activity, - activity_coefficient, - time_slice_level, - demand_for_selection: demand, - unmet_demand_for_selection: unmet_demand, - }; - self.appraisal_results_time_slice_writer.serialize(row)?; - } - } - - Ok(()) - } - /// Write rows to a debug file owned by another module /// /// The file is created on first use, even if `rows` is empty. @@ -573,8 +468,6 @@ impl DebugDataWriter { } self.commodity_balance_duals_writer.flush()?; self.solver_values_writer.flush()?; - self.appraisal_results_writer.flush()?; - self.appraisal_results_time_slice_writer.flush()?; self.dispatch_asset_writer.flush()?; for wrt in self.custom_writers.values_mut() { wrt.flush()?; @@ -672,29 +565,6 @@ impl DataWriter { Ok(()) } - /// Write debug info about the investment appraisal - pub fn write_appraisal_debug_info( - &mut self, - milestone_year: u32, - run_description: &str, - appraisal_results: &[AppraisalOutput], - demand: &IndexMap, - time_slice_level: TimeSliceLevel, - ) -> Result<()> { - if let Some(wtr) = &mut self.debug { - wtr.write_appraisal_results(milestone_year, run_description, appraisal_results)?; - wtr.write_appraisal_time_slice_results( - milestone_year, - run_description, - appraisal_results, - demand, - time_slice_level, - )?; - } - - Ok(()) - } - /// Append newly commissioned asset definitions to the assets CSV file pub fn write_assets(&mut self, assets: &[AssetRef]) -> Result<()> { for asset in assets { @@ -789,11 +659,8 @@ impl DataWriter { mod tests { use super::*; use crate::asset::AssetPool; - use crate::fixture::{ - appraisal_output, asset, assets, commodity_id, multi_tranche_asset, region_id, time_slice, - }; - use crate::simulation::investment::competitive::metrics::AppraisalOutput; - use crate::time_slice::{TimeSliceID, TimeSliceLevel, TimeSliceSelection}; + use crate::fixture::{assets, commodity_id, multi_tranche_asset, region_id, time_slice}; + use crate::time_slice::TimeSliceID; use indexmap::indexmap; use itertools::{Itertools, assert_equal}; use rstest::rstest; @@ -1160,40 +1027,6 @@ mod tests { assert_equal(records, iter::once(expected)); } - #[rstest] - fn write_appraisal_results(asset: Asset, appraisal_output: AppraisalOutput) { - let milestone_year = 2020; - let run_description = "test_run".to_string(); - let dir = tempdir().unwrap(); - - // Write appraisal results - { - let mut writer = DebugDataWriter::create(dir.path()).unwrap(); - writer - .write_appraisal_results(milestone_year, &run_description, &[appraisal_output]) - .unwrap(); - writer.flush().unwrap(); - } - - // Read back and compare - let expected = AppraisalResultsRow { - milestone_year, - run_description, - asset_id: None, - process_id: asset.process_id().clone(), - region_id: asset.region_id().clone(), - capacity: Capacity(2.0), - metric: Some(4.14), - }; - let records: Vec = - csv::Reader::from_path(dir.path().join(APPRAISAL_RESULTS_FILE_NAME)) - .unwrap() - .into_deserialize() - .try_collect() - .unwrap(); - assert_equal(records, iter::once(expected)); - } - #[rstest] #[case(true)] #[case(false)] @@ -1240,55 +1073,6 @@ mod tests { } } - #[rstest] - fn write_appraisal_time_slice_results( - asset: Asset, - appraisal_output: AppraisalOutput, - time_slice: TimeSliceID, - ) { - let milestone_year = 2020; - let run_description = "test_run".to_string(); - let dir = tempdir().unwrap(); - let demand = indexmap! {TimeSliceSelection::Single(time_slice.clone()) => Flow(100.0) }; - - // Write appraisal time slice results - { - let mut writer = DebugDataWriter::create(dir.path()).unwrap(); - writer - .write_appraisal_time_slice_results( - milestone_year, - &run_description, - &[appraisal_output], - &demand, - TimeSliceLevel::DayNight, - ) - .unwrap(); - writer.flush().unwrap(); - } - - // Read back and compare - let expected = AppraisalResultsTimeSliceRow { - milestone_year, - run_description, - asset_id: None, - process_id: asset.process_id().clone(), - region_id: asset.region_id().clone(), - time_slice: time_slice.clone(), - activity: Activity(10.0), - activity_coefficient: MoneyPerActivity(0.5), - time_slice_level: TimeSliceLevel::DayNight, - demand_for_selection: Flow(100.0), - unmet_demand_for_selection: Flow(5.0), - }; - let records: Vec = - csv::Reader::from_path(dir.path().join(APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME)) - .unwrap() - .into_deserialize() - .try_collect() - .unwrap(); - assert_equal(records, iter::once(expected)); - } - #[test] fn create_output_directory_new_directory() { let temp_dir = tempdir().unwrap(); diff --git a/src/simulation/investment/competitive.rs b/src/simulation/investment/competitive.rs index 045735808..c406634e8 100644 --- a/src/simulation/investment/competitive.rs +++ b/src/simulation/investment/competitive.rs @@ -5,6 +5,7 @@ pub mod coefficients; mod constraints; mod costs; +mod debug; pub mod metrics; mod optimisation; @@ -21,6 +22,7 @@ use crate::time_slice::TimeSliceSelection; use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity}; use anyhow::{Result, ensure}; use coefficients::calculate_coefficients_for_assets; +use debug::write_debug_info; use itertools::{Itertools, chain}; use log::{debug, warn}; use metrics::{ @@ -316,7 +318,8 @@ pub fn select_best_assets( .collect(); // Save appraisal results - writer.write_appraisal_debug_info( + write_debug_info( + writer, year, &format!("{} {} round {}", commodity.id, agent.id, round), &outputs, diff --git a/src/simulation/investment/competitive/debug.rs b/src/simulation/investment/competitive/debug.rs new file mode 100644 index 000000000..7f38812fd --- /dev/null +++ b/src/simulation/investment/competitive/debug.rs @@ -0,0 +1,197 @@ +//! Debug output for the competitive asset selection algorithm. +use super::metrics::AppraisalOutput; +use crate::asset::AssetID; +use crate::output::DataWriter; +use crate::process::ProcessID; +use crate::region::RegionID; +use crate::simulation::investment::DemandMap; +use crate::time_slice::{TimeSliceID, TimeSliceLevel, TimeSliceSelection}; +use crate::units::{Activity, Capacity, Flow, MoneyPerActivity}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// The output file name for appraisal results +const APPRAISAL_RESULTS_FILE_NAME: &str = "debug_asset_selection_competitive.csv"; + +/// The output file name for appraisal time slice results +const APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME: &str = + "debug_asset_selection_competitive_time_slices.csv"; + +/// Represents the appraisal results in a row of the appraisal results CSV file +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct AppraisalResultsRow { + milestone_year: u32, + run_description: String, + asset_id: Option, + process_id: ProcessID, + region_id: RegionID, + capacity: Capacity, + metric: Option, +} + +/// Represents the appraisal results in a row of the appraisal results time slice CSV file +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct AppraisalResultsTimeSliceRow { + milestone_year: u32, + run_description: String, + asset_id: Option, + process_id: ProcessID, + region_id: RegionID, + time_slice: TimeSliceID, + activity: Activity, + activity_coefficient: MoneyPerActivity, + time_slice_level: TimeSliceLevel, + demand_for_selection: Flow, + unmet_demand_for_selection: Flow, +} + +/// Write debug info about a round of investment appraisal +pub fn write_debug_info( + writer: &mut DataWriter, + milestone_year: u32, + run_description: &str, + appraisal_results: &[AppraisalOutput], + demand: &DemandMap, + time_slice_level: TimeSliceLevel, +) -> Result<()> { + if !writer.debug_enabled() { + return Ok(()); + } + let run_description = writer.debug_run_description(run_description); + + let rows = appraisal_results.iter().map(|result| AppraisalResultsRow { + milestone_year, + run_description: run_description.clone(), + 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()), + }); + writer.write_debug_rows(APPRAISAL_RESULTS_FILE_NAME, rows)?; + + let time_slice_rows = appraisal_results.iter().flat_map(|result| { + let run_description = &run_description; + result.activity.iter().map(move |(time_slice, activity)| { + // Map the individual time slice back to its containing selection so we can look + // up selection-level demand and unmet demand. + let selection = match time_slice_level { + TimeSliceLevel::Annual => TimeSliceSelection::Annual, + TimeSliceLevel::Season => TimeSliceSelection::Season(time_slice.season.clone()), + TimeSliceLevel::DayNight => TimeSliceSelection::Single(time_slice.clone()), + }; + AppraisalResultsTimeSliceRow { + milestone_year, + run_description: run_description.clone(), + asset_id: result.asset.id(), + process_id: result.asset.process_id().clone(), + region_id: result.asset.region_id().clone(), + time_slice: time_slice.clone(), + activity: *activity, + activity_coefficient: result.coefficients.activity_coefficients[time_slice], + time_slice_level, + demand_for_selection: demand[&selection], + unmet_demand_for_selection: result.unmet_demand[&selection], + } + }) + }); + writer.write_debug_rows(APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME, time_slice_rows) +} + +#[cfg(test)] +mod tests { + use super::super::coefficients::ObjectiveCoefficients; + use super::super::metrics::LCOXMetric; + use super::*; + use crate::asset::{Asset, AssetRef}; + use crate::fixture::{asset, time_slice}; + use indexmap::indexmap; + use itertools::{Itertools, assert_equal}; + use rstest::{fixture, rstest}; + use std::iter; + use std::path::Path; + use std::sync::Arc; + use tempfile::tempdir; + + #[fixture] + 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! { TimeSliceSelection::Single(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)))), + } + } + + fn read_rows Deserialize<'de>>(dir: &Path, file_name: &str) -> Vec { + csv::Reader::from_path(dir.join(file_name)) + .unwrap() + .into_deserialize() + .try_collect() + .unwrap() + } + + #[rstest] + fn write_debug_info_works( + asset: Asset, + appraisal_output: AppraisalOutput, + time_slice: TimeSliceID, + ) { + let milestone_year = 2020; + let run_description = "test_run".to_string(); + let dir = tempdir().unwrap(); + let demand = indexmap! { TimeSliceSelection::Single(time_slice.clone()) => Flow(100.0) }; + + { + let mut writer = DataWriter::create(dir.path(), dir.path(), true).unwrap(); + write_debug_info( + &mut writer, + milestone_year, + &run_description, + &[appraisal_output], + &demand, + TimeSliceLevel::DayNight, + ) + .unwrap(); + writer.flush().unwrap(); + } + + let expected = AppraisalResultsRow { + milestone_year, + run_description: run_description.clone(), + asset_id: None, + process_id: asset.process_id().clone(), + region_id: asset.region_id().clone(), + capacity: Capacity(2.0), + metric: Some(4.14), + }; + let records: Vec = read_rows(dir.path(), APPRAISAL_RESULTS_FILE_NAME); + assert_equal(records, iter::once(expected)); + + let expected = AppraisalResultsTimeSliceRow { + milestone_year, + run_description, + asset_id: None, + process_id: asset.process_id().clone(), + region_id: asset.region_id().clone(), + time_slice: time_slice.clone(), + activity: Activity(10.0), + activity_coefficient: MoneyPerActivity(0.5), + time_slice_level: TimeSliceLevel::DayNight, + demand_for_selection: Flow(100.0), + unmet_demand_for_selection: Flow(5.0), + }; + let records: Vec = + read_rows(dir.path(), APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME); + assert_equal(records, iter::once(expected)); + } +} diff --git a/tests/data/simple/debug_appraisal_results.csv b/tests/data/simple/debug_asset_selection_competitive.csv similarity index 100% rename from tests/data/simple/debug_appraisal_results.csv rename to tests/data/simple/debug_asset_selection_competitive.csv diff --git a/tests/data/simple/debug_appraisal_results_time_slices.csv b/tests/data/simple/debug_asset_selection_competitive_time_slices.csv similarity index 100% rename from tests/data/simple/debug_appraisal_results_time_slices.csv rename to tests/data/simple/debug_asset_selection_competitive_time_slices.csv From 23cf12d6a3287528a17e2d2b4253d9e6155109ee Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 4 Nov 2026 12:00:39 +0000 Subject: [PATCH 6/7] Add agent_type parameter to agents.csv The optional agent_type column selects the asset selection algorithm for each agent. Currently the only option is `competitive` (the default), so existing models are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- schemas/input/agents.yaml | 6 +++++ src/agent.rs | 12 +++++++++ src/fixture.rs | 3 ++- src/input/agent.rs | 45 +++++++++++++++++++++++++++++++-- src/input/agent/search_space.rs | 3 ++- src/simulation/market.rs | 12 ++++++--- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/schemas/input/agents.yaml b/schemas/input/agents.yaml index b87f3ee22..80d0f2b5f 100644 --- a/schemas/input/agents.yaml +++ b/schemas/input/agents.yaml @@ -11,6 +11,12 @@ fields: type: string description: The region(s) in which the agent operates notes: One or more region IDs, separated by semicolons or the string `all` + - name: agent_type + type: string + enum: [competitive] + description: The asset selection algorithm used by the agent + notes: | + Optional (defaults to `competitive`). Currently the only supported value is `competitive`. - name: decision_rule type: string description: The decision rule applied to objectives diff --git a/src/agent.rs b/src/agent.rs index db5e6d851..01e5dbd0e 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -38,6 +38,8 @@ pub struct Agent { pub commodity_portions: AgentCommodityPortionsMap, /// The processes that the agent will consider investing in. pub search_space: AgentSearchSpaceMap, + /// The algorithm that the agent uses to select assets. + pub agent_type: AgentType, /// The decision rule that the agent uses to decide investment. pub decision_rule: DecisionRule, /// The regions in which this agent operates. @@ -64,6 +66,16 @@ impl Agent { } } +/// The type of an agent, which determines the asset selection algorithm that it uses +#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentType { + /// Existing and candidate assets compete in rounds, with the best option according to the + /// agent's objective selected in each round + #[default] + Competitive, +} + /// The decision rule for a particular objective #[derive(Debug, Clone, PartialEq)] pub enum DecisionRule { diff --git a/src/fixture.rs b/src/fixture.rs index a8ff01f94..f179bdde5 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -2,7 +2,7 @@ use crate::agent::{ Agent, AgentCommodityPortionsMap, AgentID, AgentMap, AgentObjectiveMap, AgentSearchSpaceMap, - DecisionRule, + AgentType, DecisionRule, }; use crate::asset::{Asset, AssetCapacity, AssetPool}; use crate::commodity::{ @@ -338,6 +338,7 @@ pub fn agents(region_ids: IndexSet) -> AgentMap { description: String::new(), commodity_portions: AgentCommodityPortionsMap::new(), search_space: AgentSearchSpaceMap::new(), + agent_type: AgentType::default(), decision_rule: DecisionRule::Single, regions: region_ids, objectives: AgentObjectiveMap::new(), diff --git a/src/input/agent.rs b/src/input/agent.rs index 9b89e04e6..a22c6d1cd 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -2,7 +2,7 @@ use super::{input_err_msg, read_csv}; use crate::agent::{ Agent, AgentCommodityPortionsMap, AgentID, AgentMap, AgentObjectiveMap, AgentSearchSpaceMap, - DecisionRule, + AgentType, DecisionRule, }; use crate::commodity::CommodityMap; use crate::process::ProcessMap; @@ -30,6 +30,8 @@ struct AgentRaw { description: String, /// The region(s) in which the agent operates. regions: String, + /// The algorithm that the agent uses to select assets (defaults to `competitive`). + agent_type: Option, /// The decision rule that the agent uses to decide investment. decision_rule: String, /// The tolerance around the main objective to consider secondary objectives. @@ -143,6 +145,7 @@ where description: agent_raw.description, commodity_portions: AgentCommodityPortionsMap::new(), search_space: AgentSearchSpaceMap::new(), + agent_type: agent_raw.agent_type.unwrap_or_default(), decision_rule, regions, objectives: AgentObjectiveMap::new(), @@ -161,7 +164,9 @@ where mod tests { use super::*; use crate::agent::DecisionRule; - use std::iter; + use rstest::rstest; + use std::{fs, iter}; + use tempfile::tempdir; #[test] fn read_agents_file_from_iter_works() { @@ -170,6 +175,7 @@ mod tests { let agent = AgentRaw { id: "agent".into(), description: String::new(), + agent_type: None, decision_rule: "single".into(), decision_lexico_tolerance: None, regions: "GBR".into(), @@ -179,6 +185,7 @@ mod tests { description: String::new(), commodity_portions: AgentCommodityPortionsMap::new(), search_space: AgentSearchSpaceMap::new(), + agent_type: AgentType::Competitive, decision_rule: DecisionRule::Single, regions: IndexSet::from(["GBR".into()]), objectives: AgentObjectiveMap::new(), @@ -192,6 +199,7 @@ mod tests { AgentRaw { id: "agent".into(), description: String::new(), + agent_type: None, decision_rule: "single".into(), decision_lexico_tolerance: None, regions: "GBR".into(), @@ -199,6 +207,7 @@ mod tests { AgentRaw { id: "agent".into(), description: String::new(), + agent_type: None, decision_rule: "single".into(), decision_lexico_tolerance: None, regions: "GBR".into(), @@ -210,10 +219,42 @@ mod tests { let agent = AgentRaw { id: "agent".into(), description: String::new(), + agent_type: None, decision_rule: "lexico".into(), decision_lexico_tolerance: None, regions: "GBR".into(), }; read_agents_file_from_iter(iter::once(agent), ®ion_ids).unwrap_err(); } + + #[rstest] + #[case( + "id,description,regions,decision_rule\nagent,,GBR,single\n", + Some(AgentType::Competitive) + )] + #[case( + "id,description,regions,decision_rule,agent_type\nagent,,GBR,single,\n", + Some(AgentType::Competitive) + )] + #[case( + "id,description,regions,decision_rule,agent_type\nagent,,GBR,single,competitive\n", + Some(AgentType::Competitive) + )] + #[case( + "id,description,regions,decision_rule,agent_type\nagent,,GBR,single,invalid\n", + None + )] + fn read_agents_file_agent_type(#[case] csv: &str, #[case] expected: Option) { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(AGENT_FILE_NAME), csv).unwrap(); + + let region_ids = IndexSet::from(["GBR".into()]); + let result = read_agents_file(dir.path(), ®ion_ids); + match expected { + Some(agent_type) => assert_eq!(result.unwrap()["agent"].agent_type, agent_type), + None => { + result.unwrap_err(); + } + } + } } diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index 6216b0072..c115f975a 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -259,7 +259,7 @@ fn get_producers_map(agents: &AgentMap, processes: &ProcessMap) -> ProducersMap mod tests { use super::*; use crate::{ - agent::{AgentCommodityPortionsMap, AgentObjectiveMap, DecisionRule}, + agent::{AgentCommodityPortionsMap, AgentObjectiveMap, AgentType, DecisionRule}, fixture::{ agent_id, assert_error, assert_patched_runs_ok_simple, assert_validate_fails_with_simple, commodity_id, process, processes, region_id, @@ -291,6 +291,7 @@ mod tests { description: String::new(), commodity_portions: AgentCommodityPortionsMap::new(), search_space: AgentSearchSpaceMap::new(), + agent_type: AgentType::default(), decision_rule: DecisionRule::Single, regions: iter::once(region_id).collect(), objectives: AgentObjectiveMap::new(), diff --git a/src/simulation/market.rs b/src/simulation/market.rs index ad21870db..4e5c3c579 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -1,13 +1,13 @@ //! Code for creating sets of markets. use super::optimisation::{DispatchRun, FlowMap}; -use crate::agent::Agent; +use crate::agent::{Agent, AgentType}; use crate::asset::AssetRef; use crate::commodity::{Commodity, CommodityID}; use crate::model::Model; use crate::output::DataWriter; use crate::process::{Process, ProcessID}; use crate::region::RegionID; -use crate::simulation::investment::competitive::select_assets; +use crate::simulation::investment::competitive; use crate::simulation::investment::{AllDemandMap, DemandMap, update_net_demand_map}; use crate::simulation::prices::Prices; use crate::time_slice::TimeSliceInfo; @@ -206,6 +206,9 @@ pub fn select_assets_for_single_market( ); // Choose assets from among existing pool and candidates + let select_assets = match agent.agent_type { + AgentType::Competitive => competitive::select_assets, + }; let best_assets = select_assets( model, agent, @@ -461,7 +464,9 @@ pub fn collect_agent_limits( #[cfg(test)] mod tests { use super::*; - use crate::agent::{Agent, AgentCommodityPortionsMap, AgentObjectiveMap, DecisionRule}; + use crate::agent::{ + Agent, AgentCommodityPortionsMap, AgentObjectiveMap, AgentType, DecisionRule, + }; use crate::fixture::{process, region_id}; use crate::process::{Process, ProcessInvestmentConstraint}; use crate::region::RegionID; @@ -485,6 +490,7 @@ mod tests { description: String::new(), commodity_portions: AgentCommodityPortionsMap::new(), search_space, + agent_type: AgentType::default(), decision_rule: DecisionRule::Single, regions: [region_id.clone()].into(), objectives: AgentObjectiveMap::new(), From c241bd6b80cd094a7673fb05a6e4a5460554b6f9 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 9 Nov 2026 15:41:01 +0000 Subject: [PATCH 7/7] Privacy audit --- src/simulation/investment/competitive.rs | 8 ++++---- .../investment/competitive/coefficients.rs | 6 +++--- src/simulation/investment/competitive/metrics.rs | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/simulation/investment/competitive.rs b/src/simulation/investment/competitive.rs index c406634e8..2f3e470ed 100644 --- a/src/simulation/investment/competitive.rs +++ b/src/simulation/investment/competitive.rs @@ -2,11 +2,11 @@ //! //! Candidate and existing assets compete in rounds: each round every option is appraised with a //! mini dispatch optimisation, and the best option according to the agent's objective is selected. -pub mod coefficients; +mod coefficients; mod constraints; mod costs; mod debug; -pub mod metrics; +mod metrics; mod optimisation; use crate::agent::{Agent, AgentID}; @@ -182,7 +182,7 @@ fn get_candidate_assets<'a>( /// /// If the asset has zero maximum annual supply, zero capacity is returned. This indicates that the /// asset is non-feasible, and will be excluded from consideration by `select_best_assets`. -pub fn calculate_candidate_asset_capacity_scale( +fn calculate_candidate_asset_capacity_scale( asset: &Asset, commodity: &Commodity, demand: &DemandMap, @@ -251,7 +251,7 @@ pub fn select_best_assets( year: u32, writer: &mut DataWriter, ) -> Result> { - let objective_type = &agent.objectives[&year]; + let objective_type = agent.objectives[&year]; // Remaining addition limits for candidate processes // Initialised as the full agent addition limits, and reduced as candidate assets are selected diff --git a/src/simulation/investment/competitive/coefficients.rs b/src/simulation/investment/competitive/coefficients.rs index 7911a9ec8..e7fde07d6 100644 --- a/src/simulation/investment/competitive/coefficients.rs +++ b/src/simulation/investment/competitive/coefficients.rs @@ -39,7 +39,7 @@ pub struct ObjectiveCoefficients { /// excluded. pub fn calculate_coefficients_for_assets( model: &Model, - objective_type: &ObjectiveType, + objective_type: ObjectiveType, assets: &[AssetRef], prices: &Prices, year: u32, @@ -60,9 +60,9 @@ pub fn calculate_coefficients_for_assets( } /// Calculates cost coefficients for a single asset -pub fn calculate_coefficients_for_asset( +fn calculate_coefficients_for_asset( asset: &AssetRef, - objective_type: &ObjectiveType, + objective_type: ObjectiveType, time_slice_info: &TimeSliceInfo, prices: &Prices, year: u32, diff --git a/src/simulation/investment/competitive/metrics.rs b/src/simulation/investment/competitive/metrics.rs index 68e17dfdb..21aac7d3c 100644 --- a/src/simulation/investment/competitive/metrics.rs +++ b/src/simulation/investment/competitive/metrics.rs @@ -80,7 +80,7 @@ impl AppraisalOutput { /// 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 { + fn compare_metric(&self, other: &Self) -> Ordering { let (metric1, metric2) = self .metric .as_deref() @@ -126,7 +126,7 @@ pub trait ComparableMetric: Any + Send + Sync { #[derive(Debug, Clone, Serialize)] pub struct LCOXMetric { /// The calculated cost value for this LCOX metric - pub cost: MoneyPerActivity, + cost: MoneyPerActivity, } impl LCOXMetric { @@ -163,14 +163,14 @@ impl MetricTrait for LCOXMetric {} /// In the NPV appraisal tool we compare options using the Specific Net Annualised Surplus (SNAS) /// expressed per unit activity. Higher values indicate more profitable investments. #[derive(Debug, Clone, Serialize)] -pub struct NPVMetric { +struct NPVMetric { /// The calculated SNAS value for this metric - pub snas: MoneyPerActivity, + snas: MoneyPerActivity, } impl NPVMetric { /// Creates a new `NPVMetric` with the given SNAS value. - pub fn new(snas: MoneyPerActivity) -> Self { + fn new(snas: MoneyPerActivity) -> Self { Self { snas } } } @@ -275,7 +275,7 @@ pub fn appraise_investment( model: &Model, asset: &AssetRef, commodity: &Commodity, - objective_type: &ObjectiveType, + objective_type: ObjectiveType, coefficients: &Arc, demand: &DemandMap, ) -> Result {