diff --git a/benches/assets.rs b/benches/assets.rs index bc7bb2f92..1a470a003 100644 --- a/benches/assets.rs +++ b/benches/assets.rs @@ -11,8 +11,7 @@ use muse2::process::{Process, ProcessID}; use muse2::simulation::candidate_assets_for_next_year; use muse2::simulation::investment::{flatten_preset_demands_for_year, select_best_assets}; use muse2::simulation::market::{ - collect_agent_addition_limits, get_asset_options, get_demand_portion_for_market, - get_responsible_agents, + collect_agent_limits, get_asset_options, get_demand_portion_for_market, get_responsible_agents, }; use muse2::simulation::optimisation::DispatchRun; use muse2::simulation::prices::{Prices, calculate_prices}; @@ -137,6 +136,7 @@ fn build_synthetic_processes(templates: &[Arc], n: usize) -> Vec Option, ) -> Option { assert!( commodity_portion >= Dimensionless(0.0) && commodity_portion <= Dimensionless(1.0), @@ -92,11 +93,39 @@ impl Process { self.investment_constraints .get(&(region_id.clone(), commission_year)) - .and_then(|constraint| { - constraint - .get_addition_limit() - .map(|limit| limit * commodity_portion) - }) + .and_then(|constraint| get_limit(constraint).map(|limit| limit * commodity_portion)) + } + + /// 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, + commission_year: u32, + commodity_portion: Dimensionless, + ) -> Option { + self.agent_limit( + region_id, + commission_year, + commodity_portion, + ProcessInvestmentConstraint::get_addition_limit, + ) + } + + /// Calculate an agent's share of the total limit for this process in a region and year + /// based on commodity portion. + pub fn agent_total_limit( + &self, + region_id: &RegionID, + commission_year: u32, + commodity_portion: Dimensionless, + ) -> Option { + self.agent_limit( + region_id, + commission_year, + commodity_portion, + ProcessInvestmentConstraint::get_total_limit, + ) } } @@ -527,17 +556,20 @@ pub struct ProcessInvestmentConstraint { /// Addition constraint: Limit an agent can invest in the process, shared according to the /// agent's proportion of the process's primary commodity demand pub addition_limit: Option, + /// Total capacity limit for the process + pub total_capacity_limit: Option, } impl ProcessInvestmentConstraint { - /// Calculate the effective addition limit - /// - /// For now, this just returns `addition_limit`, but in the future when we add growth - /// limits and total capacity limits, this will have more complex logic which will depend on the - /// current total capacity. + /// Get the addition limit pub fn get_addition_limit(&self) -> Option { self.addition_limit } + + /// Get the total capacity limit allowed + pub fn get_total_limit(&self) -> Option { + self.total_capacity_limit + } } #[cfg(test)] @@ -546,7 +578,7 @@ mod tests { use crate::commodity::{ CommodityConstraintsMap, CommodityLevyMap, CommodityType, DemandMap, PricingStrategy, }; - use crate::fixture::{assert_error, region_id, time_slice, time_slice_info2}; + use crate::fixture::{assert_error, process, region_id, time_slice, time_slice_info2}; use crate::time_slice::TimeSliceLevel; use crate::time_slice::TimeSliceSelection; use float_cmp::assert_approx_eq; @@ -1167,4 +1199,71 @@ mod tests { "Availability limit for season winter clashes with time slice limits" ); } + + #[rstest] + #[case(Dimensionless(1.1))] + #[case(Dimensionless(-0.1))] + #[should_panic(expected = "commodity_portion must be between 0 and 1 inclusive")] + fn agent_limit_invalid_commodity_portion( + process: Process, + region_id: RegionID, + #[case] commodity_portion: Dimensionless, + ) { + process.agent_limit( + ®ion_id, + 2015, + commodity_portion, + ProcessInvestmentConstraint::get_addition_limit, + ); + } + + #[rstest] + fn agent_addition_limit_no_constraint(process: Process, region_id: RegionID) { + assert!( + process + .agent_addition_limit(®ion_id, 2015, Dimensionless(1.0)) + .is_none() + ); + } + + #[rstest] + fn agent_addition_limit_scaled_by_portion(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)), + total_capacity_limit: Some(crate::units::Capacity(100.0)), + }), + ); + + let result = process + .agent_addition_limit(®ion_id, 2015, Dimensionless(0.5)) + .unwrap(); + assert_eq!(result, Capacity(5.0)); + } + + #[rstest] + fn agent_total_limit_no_constraint(process: Process, region_id: RegionID) { + assert!( + process + .agent_total_limit(®ion_id, 2015, Dimensionless(1.0)) + .is_none() + ); + } + + #[rstest] + fn agent_total_limit_scaled_by_portion(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)), + total_capacity_limit: Some(crate::units::Capacity(100.0)), + }), + ); + + let result = process + .agent_total_limit(®ion_id, 2015, Dimensionless(0.5)) + .unwrap(); + assert_eq!(result, Capacity(50.0)); + } } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 06418126f..77b3a8d2a 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -354,6 +354,7 @@ 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, @@ -367,10 +368,10 @@ pub fn select_best_assets( // 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, - &remaining_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 units available for retention and replace assets with single units let mut available_retention_units = prepare_commissioned_assets_for_retention(&mut opt_assets); @@ -386,6 +387,14 @@ pub fn select_best_assets( &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 \ @@ -393,7 +402,6 @@ pub fn select_best_assets( 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 @@ -457,15 +465,18 @@ pub fn select_best_assets( best_output.asset.total_capacity() ); - // Record the selected asset and update the remaining selection state. - record_asset_selection( - best_output.asset, + // 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_units, - &mut best_assets, ); + // Record the selected asset + record_asset_selection(best_output.asset, &mut best_assets); + demand = best_output.unmet_demand; round += 1; } @@ -508,20 +519,35 @@ fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool demand.values().any(|flow| *flow > absolute_tolerance) } -/// Remove candidate assets whose process addition limit cannot fund one complete unit. -fn remove_candidates_exceeding_agent_addition_limits( +/// Remove assets that exceed the provided process limits for one complete unit. +fn remove_assets_exceeding_agent_limits( opt_assets: &mut Vec, - remaining_agent_addition_limits: &HashMap, + remaining_agent_limits: &HashMap, + only_candidates: bool, ) { opt_assets.retain(|asset| { - !asset.is_candidate() - || remaining_agent_addition_limits + (only_candidates && !asset.is_candidate()) + || remaining_agent_limits .get(asset.process_id()) .is_none_or(|limit| *limit >= asset.total_capacity()) }); } -/// Record a selected asset and update the remaining investment options and selection state. +// 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 capacity is subtracted from /// `remaining_agent_addition_limits` (if applicable) to ensure that process addition limits are not @@ -533,56 +559,52 @@ fn remove_candidates_exceeding_agent_addition_limits( /// * `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_units` - The commissioned units available for retention -/// * `best_assets` - The list of assets that have been selected so far -fn record_asset_selection( - best_asset: AssetRef, +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_units: &mut HashMap, - best_assets: &mut Vec, ) { - assert!( - best_asset.is_commissioned() || best_asset.is_candidate(), - "Invalid asset type" - ); + // 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. - 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 - // asset from the investment options. - if *remaining_capacity < best_asset.total_capacity() { - let old_idx = opt_assets - .iter() - .position(|asset| *asset == best_asset) - .unwrap(); - opt_assets.swap_remove(old_idx); - remaining_agent_addition_limits.remove(best_asset.process_id()); - } - } + subtract_capacity_from_remaining_limit(best_asset, remaining_agent_addition_limits); } else { // Commissioned assets: we've appraised a single unit, so remove one unit from the // available retention count for this asset. - let remaining = available_retention_units.get_mut(&best_asset).unwrap(); + 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. if *remaining == 0 { let old_idx = opt_assets .iter() - .position(|asset| *asset == best_asset) + .position(|asset| *asset == *best_asset) .unwrap(); opt_assets.swap_remove(old_idx); - available_retention_units.remove(&best_asset); + available_retention_units.remove(best_asset); } } +} + +/// Record a selected asset. +/// +/// # 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 increase its capacity if it's already there. if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset) { @@ -601,15 +623,16 @@ mod tests { use super::*; use crate::commodity::Commodity; use crate::fixture::{ - asset, process, process_activity_limits_map, process_flows_map, svd_commodity, time_slice, - time_slice_info, time_slice_info2, + agent_id, 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::rstest; + use rstest::{fixture, rstest}; + use std::sync::Arc; #[rstest] @@ -793,4 +816,107 @@ mod tests { 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().unit_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!(opt_assets.is_empty()); + } + + #[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_units = HashMap::from([(best_asset.clone(), 1)]); + + update_selection_state( + &best_asset, + &mut opt_assets, + &mut addition_limits, + &mut total_limits, + &mut retention_units, + ); + + if commissioned { + // Expect total_limits to reduce and retention_units to be removed + assert_eq!(total_limits[best_asset.process_id()], Capacity(0.0)); + assert!(!retention_units.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_units to remain unchanged + assert_eq!(retention_units[&best_asset], 1); + } + } } diff --git a/src/simulation/market.rs b/src/simulation/market.rs index 9ed4b6b64..0d987ace7 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -5,7 +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::process::{Process, ProcessID}; use crate::region::RegionID; use crate::simulation::investment::{ AllDemandMap, DemandMap, calculate_candidate_asset_capacity_scale, select_best_assets, @@ -194,14 +194,31 @@ pub fn select_assets_for_single_market( .collect::>(); // 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); + let agent_addition_limits = collect_agent_limits( + agent, + region_id, + commodity_id, + year, + commodity_portion, + Process::agent_addition_limit, + ); + + // Calculate the agent's share of total capacity limits for all processes + let agent_total_limits = collect_agent_limits( + agent, + region_id, + commodity_id, + year, + commodity_portion, + Process::agent_total_limit, + ); // Choose assets from among existing pool and candidates let best_assets = select_best_assets( model, opt_assets, agent_addition_limits, + agent_total_limits, commodity, agent, region_id, @@ -468,22 +485,34 @@ fn get_candidate_assets<'a>( }) } -/// Agent addition limits are based on process addition limits that have already been -/// 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( +/// 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 +/// determined by `get_agent_limit`, which should be one of [`Process::agent_addition_limit`] (the +/// agent's share of the annual addition limit) or [`Process::agent_total_limit`] (the agent's share +/// of the maximum total installed capacity). +/// +/// # Arguments +/// +/// * `agent` – Agent whose search space is queried. +/// * `region_id` – Region for which limits are calculated. +/// * `commodity_id` – Commodity for which limits are calculated. +/// * `year` – Milestone year being solved. +/// * `commodity_portion` – Agent's fractional share of commodity demand, used to scale limits. +/// * `get_agent_limit` – Method on [`Process`] that returns the limit value. +pub fn collect_agent_limits( agent: &Agent, region_id: &RegionID, commodity_id: &CommodityID, year: u32, commodity_portion: Dimensionless, + get_agent_limit: fn(&Process, &RegionID, u32, Dimensionless) -> Option, ) -> 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)) + get_agent_limit(process, region_id, year, commodity_portion) + .map(|limit| (process.id.clone(), limit)) }) .collect() } @@ -522,30 +551,43 @@ mod tests { } #[rstest] - fn collect_agent_addition_limits_uses_search_space(mut process: Process, region_id: RegionID) { + fn collect_agent_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)), + total_capacity_limit: Some(crate::units::Capacity(100.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( + let result = collect_agent_limits( &agent, ®ion_id, &commodity_id, 2015, Dimensionless(0.5), + Process::agent_addition_limit, ); assert_eq!(result.get(&process_id), Some(&crate::units::Capacity(5.0))); + + let result = collect_agent_limits( + &agent, + ®ion_id, + &commodity_id, + 2015, + Dimensionless(0.5), + Process::agent_total_limit, + ); + + assert_eq!(result.get(&process_id), Some(&crate::units::Capacity(50.0))); } #[rstest] - fn collect_agent_addition_limits_excludes_processes_without_limits( + fn collect_agent_limits_excludes_processes_without_limits( process: Process, region_id: RegionID, ) { @@ -553,12 +595,13 @@ mod tests { let process_id = process.id.clone(); let agent = agent_with_process(process, ®ion_id, &commodity_id); - let result = collect_agent_addition_limits( + let result = collect_agent_limits( &agent, ®ion_id, &commodity_id, 2015, Dimensionless(1.0), + Process::agent_addition_limit, ); assert!(!result.contains_key(&process_id));