From 03181f3dc3be01a5aa231f2b3dca58ec5a7e0fc2 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Aug 2026 13:28:53 +0100 Subject: [PATCH 1/5] Make addition limits map process-level --- src/asset.rs | 46 +-------- src/process.rs | 21 +++++ src/simulation/investment.rs | 70 +++++++------- src/simulation/market.rs | 174 +++++++++++++++-------------------- 4 files changed, 136 insertions(+), 175 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 5c1c04ed4..9d8a7ba7a 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -9,8 +9,8 @@ use crate::region::RegionID; use crate::simulation::PriceMap; use crate::time_slice::{TimeSliceID, TimeSliceSelection}; use crate::units::{ - Activity, ActivityPerCapacity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity, - MoneyPerCapacity, MoneyPerFlow, UnitType, Year, + Activity, ActivityPerCapacity, Capacity, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity, + MoneyPerFlow, UnitType, Year, }; use anyhow::{Context, Result, ensure}; use indexmap::IndexMap; @@ -845,27 +845,6 @@ impl Asset { pub fn num_units(&self) -> u32 { self.capacity().num_units() } - - /// For non-commissioned assets, get the maximum capacity permitted to be installed based on the - /// investment constraints for the asset's process. - /// - /// The limit is taken from the process's investment constraints for the asset's region and - /// commission year, and the portion of the commodity demand being considered. - pub fn max_installable_capacity(&self, commodity_portion: Dimensionless) -> Option { - assert!( - !self.is_commissioned(), - "max_installable_capacity can only be called on uncommissioned assets" - ); - assert!( - commodity_portion >= Dimensionless(0.0) && commodity_portion <= Dimensionless(1.0), - "commodity_portion must be between 0 and 1 inclusive" - ); - - self.process - .investment_constraints - .get(&(self.region_id.clone(), self.commission_year)) - .and_then(|c| c.get_addition_limit().map(|l| l * commodity_portion)) - } } #[allow(clippy::missing_fields_in_debug)] @@ -1625,27 +1604,6 @@ mod tests { ); } - #[rstest] - fn max_installable_capacity(mut process: Process, region_id: RegionID) { - // Set an addition limit of 3 for (region, year 2015) - process.investment_constraints.insert( - (region_id.clone(), 2015), - Arc::new(crate::process::ProcessInvestmentConstraint { - addition_limit: Some(Capacity(3.0)), - }), - ); - let process_rc = Arc::new(process); - - // Create a candidate asset with commission year 2015 - let asset = - Asset::new_candidate(process_rc.clone(), region_id.clone(), Capacity(1.0), 2015) - .unwrap(); - - // commodity_portion = 0.5 -> limit = 3 * 0.5 = 1.5 - let result = asset.max_installable_capacity(Dimensionless(0.5)); - assert_eq!(result, Some(Capacity(1.5))); - } - #[rstest] #[case::none(0)] #[case::some(2)] diff --git a/src/process.rs b/src/process.rs index 8cbc38093..772547249 100644 --- a/src/process.rs +++ b/src/process.rs @@ -76,6 +76,27 @@ impl Process { pub fn active_for_year(&self, year: u32) -> bool { self.years.contains(&year) } + + /// Calculate the agent's share of the addition limit for this process in a region and year. + pub fn agent_addition_limit( + &self, + region_id: &RegionID, + commission_year: u32, + commodity_portion: Dimensionless, + ) -> Option { + assert!( + commodity_portion >= Dimensionless(0.0) && commodity_portion <= Dimensionless(1.0), + "commodity_portion must be between 0 and 1 inclusive" + ); + + self.investment_constraints + .get(&(region_id.clone(), commission_year)) + .and_then(|constraint| { + constraint + .get_addition_limit() + .map(|limit| limit * commodity_portion) + }) + } } /// Defines the activity limits for a process in a given region and year diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 3f12bd1f5..0f29ab1ff 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -5,6 +5,7 @@ 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::{TimeSliceID, TimeSliceInfo, TimeSliceLevel, TimeSliceSelection}; @@ -352,7 +353,7 @@ fn log_on_equal_appraisal_outputs( pub fn select_best_assets( model: &Model, mut opt_assets: Vec, - candidate_investment_limits: HashMap, + agent_addition_limits: HashMap, commodity: &Commodity, agent: &Agent, region_id: &RegionID, @@ -363,12 +364,15 @@ pub fn select_best_assets( ) -> Result> { let objective_type = &agent.objectives[&year]; - // Remaining capacity limits for candidate assets - let mut remaining_candidate_capacities = candidate_investment_limits; - remove_candidates_exceeding_limits(&mut opt_assets, &remaining_candidate_capacities); + // Remaining capacity limits for candidate processes + let mut remaining_agent_addition_limits = agent_addition_limits; + remove_candidates_exceeding_agent_addition_limits( + &mut opt_assets, + &remaining_agent_addition_limits, + ); - // Store unit counts for commissioned assets and replace them with single units - let mut remaining_units = prepare_commissioned_assets_for_reappraisal(&mut opt_assets); + // Store commissioned units available for retention and replace assets with single units + let mut available_retention_units = prepare_commissioned_assets_for_retention(&mut opt_assets); // Calculate coefficients for all asset options according to the agent's objective let coefficients = @@ -456,8 +460,8 @@ pub fn select_best_assets( update_assets( best_output.asset, &mut opt_assets, - &mut remaining_candidate_capacities, - &mut remaining_units, + &mut remaining_agent_addition_limits, + &mut available_retention_units, &mut best_assets, ); @@ -482,8 +486,8 @@ pub fn select_best_assets( /// /// Assets are replaced in `assets` with an asset representing a single unit, as they are /// appraised one unit at a time. Returns a map from the asset to its original number of units. -fn prepare_commissioned_assets_for_reappraisal(assets: &mut [AssetRef]) -> HashMap { - let mut remaining_units = HashMap::new(); +fn prepare_commissioned_assets_for_retention(assets: &mut [AssetRef]) -> HashMap { + let mut available_retention_units = HashMap::new(); for asset in assets.iter_mut().filter(|asset| asset.is_commissioned()) { let num_units = asset.num_units(); @@ -492,10 +496,10 @@ fn prepare_commissioned_assets_for_reappraisal(assets: &mut [AssetRef]) -> HashM *asset = asset.clone().as_single_unit(); // Store remaining units - remaining_units.insert(asset.clone(), num_units); + available_retention_units.insert(asset.clone(), num_units); } - remaining_units + available_retention_units } /// Check whether there is any remaining demand that is unmet in any time slice @@ -503,15 +507,15 @@ fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool demand.values().any(|flow| *flow > absolute_tolerance) } -/// Remove candidate assets whose investment limit cannot fund one complete unit. -fn remove_candidates_exceeding_limits( +/// Remove candidate assets whose process addition limit cannot fund one complete unit. +fn remove_candidates_exceeding_agent_addition_limits( opt_assets: &mut Vec, - candidate_investment_limits: &HashMap, + remaining_agent_addition_limits: &HashMap, ) { opt_assets.retain(|asset| { !asset.is_candidate() - || candidate_investment_limits - .get(asset) + || remaining_agent_addition_limits + .get(asset.process_id()) .is_none_or(|limit| *limit >= asset.total_capacity()) }); } @@ -519,23 +523,23 @@ fn remove_candidates_exceeding_limits( /// Add the best asset to the list of selected assets, and update the remaining limits and options /// accordingly. /// -/// If the asset is a candidate, its capacity is subtracted from `remaining_candidate_capacities` -/// (if applicable). This is to ensure that any annual addition limits are not exceeded. If the -/// asset is a commissioned asset, one unit is subtracted from `remaining_units`. This is to ensure -/// that we do not select more units than are available for retention. +/// If the asset is a candidate, its capacity is subtracted from +/// `remaining_agent_addition_limits` (if applicable). This is to ensure that annual addition +/// limits are not exceeded. If the asset is commissioned, one unit is subtracted from +/// `available_retention_units` 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_candidate_capacities` - The remaining investment limits for candidate assets -/// * `remaining_units` - The remaining unit counts for commissioned assets +/// * `remaining_agent_addition_limits` - The remaining agent addition limits for processes +/// * `available_retention_units` - The commissioned units available for retention /// * `best_assets` - The list of assets that have been selected so far fn update_assets( best_asset: AssetRef, opt_assets: &mut Vec, - remaining_candidate_capacities: &mut HashMap, - remaining_units: &mut HashMap, + remaining_agent_addition_limits: &mut HashMap, + available_retention_units: &mut HashMap, best_assets: &mut Vec, ) { assert!( @@ -543,11 +547,13 @@ fn update_assets( "Invalid asset type" ); - // Update the remaining limits for the selected asset, if applicable, and remove it from the - // options if the limit is exhausted. + // 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. - if let Some(remaining_capacity) = remaining_candidate_capacities.get_mut(&best_asset) { + if let Some(remaining_capacity) = + remaining_agent_addition_limits.get_mut(best_asset.process_id()) + { *remaining_capacity -= best_asset.total_capacity(); // If there's not enough capacity remaining to install any more units, remove the @@ -558,13 +564,13 @@ fn update_assets( .position(|asset| *asset == best_asset) .unwrap(); opt_assets.swap_remove(old_idx); - remaining_candidate_capacities.remove(&best_asset); + remaining_agent_addition_limits.remove(best_asset.process_id()); } } } else { // Commissioned assets: we've appraised a single unit, so remove one unit from the - // remaining units count for this asset. - let remaining = remaining_units.get_mut(&best_asset).unwrap(); + // available retention count for this asset. + let remaining = available_retention_units.get_mut(&best_asset).unwrap(); *remaining = remaining.saturating_sub(1); // If all units have been selected, remove the asset from the investment options. @@ -574,7 +580,7 @@ fn update_assets( .position(|asset| *asset == best_asset) .unwrap(); opt_assets.swap_remove(old_idx); - remaining_units.remove(&best_asset); + available_retention_units.remove(&best_asset); } } diff --git a/src/simulation/market.rs b/src/simulation/market.rs index d44f983f7..2a1e6196d 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -5,6 +5,7 @@ use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef, AssetState}; 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::{ AllDemandMap, DemandMap, calculate_candidate_asset_capacity_scale, select_best_assets, @@ -192,15 +193,15 @@ pub fn select_assets_for_single_market( ) .collect::>(); - // Calculate investment limits for candidate assets - let candidate_investment_limits = - collect_investment_limits_for_candidates(&opt_assets, commodity_portion); + // Calculate the agent's share of addition limits for candidate processes + let agent_addition_limits = + collect_agent_addition_limits(agent, region_id, &commodity.id, year, commodity_portion); // Choose assets from among existing pool and candidates let best_assets = select_best_assets( model, opt_assets, - candidate_investment_limits, + agent_addition_limits, commodity, agent, region_id, @@ -294,7 +295,8 @@ pub fn select_assets_for_cycle( model.agents[agent_id].commodity_portions[&(commodity_id.clone(), year)] }); asset - .max_installable_capacity(agent_share) + .process() + .agent_addition_limit(asset.region_id(), asset.commission_year(), agent_share) .map(|max_capacity| (asset.clone(), max_capacity)) }) .collect::>(); @@ -466,20 +468,22 @@ fn get_candidate_assets<'a>( }) } -/// Investment limits are based on any annual addition limits specified by the process, scaled -/// according to the agent's portion of the commodity demand and the number of years elapsed since -/// the previous milestone year. -pub fn collect_investment_limits_for_candidates( - opt_assets: &[AssetRef], +/// Agent addition limits are based on process addition limits that have already been +/// scaled from annual input limits to the interval since the previous milestone year. +/// The resulting limit is then scaled according to the agent's portion of commodity demand. +pub fn collect_agent_addition_limits( + agent: &Agent, + region_id: &RegionID, + commodity_id: &CommodityID, + year: u32, commodity_portion: Dimensionless, -) -> HashMap { - opt_assets - .iter() - .filter(|asset| asset.is_candidate()) - .filter_map(|asset| { - asset - .max_installable_capacity(commodity_portion) - .map(|limit_capacity| (asset.clone(), limit_capacity)) +) -> HashMap { + agent + .iter_search_space(region_id, commodity_id, year) + .filter_map(|process| { + process + .agent_addition_limit(region_id, year, commodity_portion) + .map(|agent_limit| (process.id.clone(), agent_limit)) }) .collect() } @@ -487,104 +491,76 @@ pub fn collect_investment_limits_for_candidates( #[cfg(test)] mod tests { use super::*; - use crate::fixture::{ - asset, process, process_activity_limits_map, process_flows_map, process_parameter_map, - region_id, - }; - use crate::process::{ - Process, ProcessActivityLimitsMap, ProcessFlowsMap, ProcessInvestmentConstraint, - ProcessInvestmentConstraintsMap, ProcessParameterMap, - }; + use crate::agent::{Agent, AgentCommodityPortionsMap, AgentObjectiveMap, DecisionRule}; + use crate::fixture::{process, region_id}; + use crate::process::{Process, ProcessInvestmentConstraint}; use crate::region::RegionID; use crate::units::Dimensionless; - use crate::units::{ActivityPerCapacity, Capacity}; - use indexmap::IndexSet; - use rstest::{fixture, rstest}; - use std::slice::from_ref; + use rstest::rstest; + use std::collections::HashMap; use std::sync::Arc; - #[rstest] - fn collect_investment_limits_for_candidates_empty_list() { - let result = collect_investment_limits_for_candidates(&[], Dimensionless(1.0)); - assert!(result.is_empty()); - } - - #[fixture] - fn commissioned_asset(asset: Asset) -> AssetRef { - asset.into() - } - - #[fixture] - fn uncommissioned_asset_without_limit(process: Process, region_id: RegionID) -> AssetRef { - Asset::new_candidate(Arc::new(process), region_id, Capacity(10.0), 2015) - .unwrap() - .into() - } - - #[fixture] - fn uncommissioned_asset_with_limit( - region_id: RegionID, - process_activity_limits_map: ProcessActivityLimitsMap, - process_flows_map: ProcessFlowsMap, - process_parameter_map: ProcessParameterMap, - ) -> AssetRef { - let region_ids: IndexSet = [region_id.clone()].into(); - - let mut constraints = ProcessInvestmentConstraintsMap::new(); - - constraints.insert( - (region_id.clone(), 2015), - Arc::new(ProcessInvestmentConstraint { - addition_limit: Some(Capacity(10.0)), - }), + fn agent_with_process( + process: Process, + region_id: &RegionID, + commodity_id: &CommodityID, + ) -> Agent { + let mut search_space = HashMap::new(); + search_space.insert( + (commodity_id.clone(), region_id.clone(), 2015), + Arc::new(vec![Arc::new(process)]), ); - - let process = Process { - id: "constrained_process".into(), + Agent { + id: "agent1".into(), description: String::new(), - years: 2010..=2020, - activity_limits: process_activity_limits_map, - flows: process_flows_map, - parameters: process_parameter_map, - regions: region_ids, - primary_output: None, - capacity_to_activity: ActivityPerCapacity(1.0), - investment_constraints: constraints, - unit_size: None, - }; - - Asset::new_candidate(Arc::new(process), region_id, Capacity(15.0), 2015) - .unwrap() - .into() + commodity_portions: AgentCommodityPortionsMap::new(), + search_space, + decision_rule: DecisionRule::Single, + regions: [region_id.clone()].into(), + objectives: AgentObjectiveMap::new(), + } } #[rstest] - fn commissioned_assets_are_excluded(commissioned_asset: AssetRef) { - let result = collect_investment_limits_for_candidates( - from_ref(&commissioned_asset), - Dimensionless(1.0), + fn collect_agent_addition_limits_uses_search_space(mut process: Process, region_id: RegionID) { + process.investment_constraints.insert( + (region_id.clone(), 2015), + Arc::new(ProcessInvestmentConstraint { + addition_limit: Some(crate::units::Capacity(10.0)), + }), ); - - assert!(!result.contains_key(&commissioned_asset)); - } - - #[rstest] - fn candidate_assets_without_limits_are_excluded(uncommissioned_asset_without_limit: AssetRef) { - let result = collect_investment_limits_for_candidates( - from_ref(&uncommissioned_asset_without_limit), - Dimensionless(1.0), + let commodity_id = "commodity".into(); + let process_id = process.id.clone(); + let agent = agent_with_process(process, ®ion_id, &commodity_id); + + let result = collect_agent_addition_limits( + &agent, + ®ion_id, + &commodity_id, + 2015, + Dimensionless(0.5), ); - assert!(!result.contains_key(&uncommissioned_asset_without_limit)); + assert_eq!(result.get(&process_id), Some(&crate::units::Capacity(5.0))); } #[rstest] - fn candidate_assets_with_limits_are_included(uncommissioned_asset_with_limit: AssetRef) { - let result = collect_investment_limits_for_candidates( - from_ref(&uncommissioned_asset_with_limit), + fn collect_agent_addition_limits_excludes_processes_without_limits( + process: Process, + region_id: RegionID, + ) { + let commodity_id = "commodity".into(); + let process_id = process.id.clone(); + let agent = agent_with_process(process, ®ion_id, &commodity_id); + + let result = collect_agent_addition_limits( + &agent, + ®ion_id, + &commodity_id, + 2015, Dimensionless(1.0), ); - assert!(result.contains_key(&uncommissioned_asset_with_limit)); + assert!(!result.contains_key(&process_id)); } } From 30c9ce619d4733695586c20b6bd1f2ea634b6f1a Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Aug 2026 13:31:59 +0100 Subject: [PATCH 2/5] Rename function --- src/simulation/investment.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 0f29ab1ff..d87a73148 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -456,8 +456,8 @@ pub fn select_best_assets( best_output.asset.total_capacity() ); - // Update the assets and remaining limits - update_assets( + // Record the selected asset and update the remaining selection state. + record_asset_selection( best_output.asset, &mut opt_assets, &mut remaining_agent_addition_limits, @@ -520,8 +520,7 @@ fn remove_candidates_exceeding_agent_addition_limits( }); } -/// Add the best asset to the list of selected assets, and update the remaining limits and options -/// accordingly. +/// Record a selected asset and update the remaining investment options and selection state. /// /// If the asset is a candidate, its capacity is subtracted from /// `remaining_agent_addition_limits` (if applicable). This is to ensure that annual addition @@ -535,7 +534,7 @@ fn remove_candidates_exceeding_agent_addition_limits( /// * `remaining_agent_addition_limits` - The remaining agent addition limits for processes /// * `available_retention_units` - The commissioned units available for retention /// * `best_assets` - The list of assets that have been selected so far -fn update_assets( +fn record_asset_selection( best_asset: AssetRef, opt_assets: &mut Vec, remaining_agent_addition_limits: &mut HashMap, From 66d6c450b441822922c7985e62af9f5b735e354f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Aug 2026 13:36:07 +0100 Subject: [PATCH 3/5] Small improvements to comments --- src/process.rs | 3 ++- src/simulation/investment.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/process.rs b/src/process.rs index 772547249..c5669c0f1 100644 --- a/src/process.rs +++ b/src/process.rs @@ -77,7 +77,8 @@ impl Process { self.years.contains(&year) } - /// Calculate the agent's share of the addition limit for this process in a region and year. + /// Calculate an agent's share of the addition limit for this process in a region and year + /// based on commodity portion. pub fn agent_addition_limit( &self, region_id: &RegionID, diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index d87a73148..caeb09de1 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -364,7 +364,8 @@ pub fn select_best_assets( ) -> Result> { let objective_type = &agent.objectives[&year]; - // Remaining capacity limits for candidate processes + // 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; remove_candidates_exceeding_agent_addition_limits( &mut opt_assets, From f40fc6364db86524c6d1fb3777c4cbaec499054f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Aug 2026 13:44:36 +0100 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/simulation/market.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simulation/market.rs b/src/simulation/market.rs index 2a1e6196d..d5ccf0de9 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -469,7 +469,7 @@ fn get_candidate_assets<'a>( } /// Agent addition limits are based on process addition limits that have already been -/// scaled from annual input limits to the interval since the previous milestone year. +/// scaled from annual addition limits to the interval since the previous milestone year. /// The resulting limit is then scaled according to the agent's portion of commodity demand. pub fn collect_agent_addition_limits( agent: &Agent, From 012783d25cb57a808f2529b3ddb718b1100c0399 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Aug 2026 13:45:18 +0100 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/simulation/investment.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index caeb09de1..297f6c4d8 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -524,8 +524,8 @@ fn remove_candidates_exceeding_agent_addition_limits( /// Record a selected asset and update the remaining investment options and selection state. /// /// If the asset is a candidate, its capacity is subtracted from -/// `remaining_agent_addition_limits` (if applicable). This is to ensure that annual addition -/// limits are not exceeded. If the asset is commissioned, one unit is subtracted from +/// `remaining_agent_addition_limits` (if applicable) to ensure that process addition limits are not +/// exceeded. If the asset is commissioned, one unit is subtracted from /// `available_retention_units` to ensure that retention does not invent new capacity. /// /// # Arguments