From b2c34889e8bacff95c0a540a5fe4b8c52a32ccb8 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Fri, 23 Jan 2026 14:00:02 +0000 Subject: [PATCH 01/22] Apply investment limits based on agent demand share --- src/input/process/investment_constraints.rs | 41 ++++++++-------- src/process.rs | 18 ++++++- src/simulation/investment.rs | 52 ++++++++++++++++++--- 3 files changed, 83 insertions(+), 28 deletions(-) diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index aaacf07bf..7d461d7da 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -5,6 +5,7 @@ use crate::process::{ ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap, }; use crate::region::parse_region_str; +use crate::units::Capacity; use crate::year::parse_year_str; use anyhow::{Context, Result, ensure}; use itertools::iproduct; @@ -21,7 +22,7 @@ struct ProcessInvestmentConstraintRaw { process_id: String, regions: String, commission_years: String, - addition_limit: f64, + addition_limit: Capacity, } impl ProcessInvestmentConstraintRaw { @@ -29,7 +30,7 @@ impl ProcessInvestmentConstraintRaw { fn validate(&self) -> Result<()> { // Validate that value is finite ensure!( - self.addition_limit.is_finite() && self.addition_limit >= 0.0, + self.addition_limit.is_finite() && self.addition_limit >= Capacity(0.0), "Invalid value for addition constraint: '{}'; must be non-negative and finite.", self.addition_limit ); @@ -136,7 +137,7 @@ mod tests { use crate::region::RegionID; use rstest::rstest; - fn validate_raw_constraint(addition_limit: f64) -> Result<()> { + fn validate_raw_constraint(addition_limit: Capacity) -> Result<()> { let constraint = ProcessInvestmentConstraintRaw { process_id: "test_process".into(), regions: "ALL".into(), @@ -155,7 +156,7 @@ mod tests { process_id: "process1".into(), regions: "GBR".into(), commission_years: "ALL".into(), // Should apply to milestone years [2012, 2016] - addition_limit: 100.0, + addition_limit: Capacity(100.0), }]; let result = read_process_investment_constraints_from_iter( @@ -201,19 +202,19 @@ mod tests { process_id: "process1".into(), regions: "GBR".into(), commission_years: "2010".into(), - addition_limit: 100.0, + addition_limit: Capacity(100.0), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "ALL".into(), commission_years: "2015".into(), - addition_limit: 200.0, + addition_limit: Capacity(200.0), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "USA".into(), commission_years: "2020".into(), - addition_limit: 50.0, + addition_limit: Capacity(50.0), }, ]; @@ -238,25 +239,25 @@ mod tests { let gbr_2010 = process_constraints .get(&(gbr_region.clone(), 2010)) .expect("GBR 2010 constraint should exist"); - assert_eq!(gbr_2010.addition_limit, Some(100.0)); + assert_eq!(gbr_2010.addition_limit, Some(Capacity(100.0))); // Check GBR 2015 constraint (from ALL regions) let gbr_2015 = process_constraints .get(&(gbr_region, 2015)) .expect("GBR 2015 constraint should exist"); - assert_eq!(gbr_2015.addition_limit, Some(200.0)); + assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0))); // Check USA 2015 constraint (from ALL regions) let usa_2015 = process_constraints .get(&(usa_region.clone(), 2015)) .expect("USA 2015 constraint should exist"); - assert_eq!(usa_2015.addition_limit, Some(200.0)); + assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0))); // Check USA 2020 constraint let usa_2020 = process_constraints .get(&(usa_region, 2020)) .expect("USA 2020 constraint should exist"); - assert_eq!(usa_2020.addition_limit, Some(50.0)); + assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0))); // Verify total number of constraints (2 GBR + 2 USA = 4) assert_eq!(process_constraints.len(), 4); @@ -272,7 +273,7 @@ mod tests { process_id: "process1".into(), regions: "ALL".into(), commission_years: "ALL".into(), - addition_limit: 75.0, + addition_limit: Capacity(75.0), }]; // Read constraints into the map @@ -297,12 +298,12 @@ mod tests { let gbr_constraint = process_constraints .get(&(gbr_region.clone(), year)) .unwrap_or_else(|| panic!("GBR {year} constraint should exist")); - assert_eq!(gbr_constraint.addition_limit, Some(75.0)); + assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0))); let usa_constraint = process_constraints .get(&(usa_region.clone(), year)) .unwrap_or_else(|| panic!("USA {year} constraint should exist")); - assert_eq!(usa_constraint.addition_limit, Some(75.0)); + assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0))); } // Verify total number of constraints (2 regions × 3 years = 6) @@ -319,7 +320,7 @@ mod tests { process_id: "process1".into(), regions: "GBR".into(), commission_years: "2025".into(), // Outside milestone years (2010-2020) - addition_limit: 100.0, + addition_limit: Capacity(100.0), }]; // Should fail with milestone year validation error @@ -337,15 +338,15 @@ mod tests { #[test] fn validate_addition_with_finite_value() { // Valid: addition constraint with positive value - let valid = validate_raw_constraint(10.0); + let valid = validate_raw_constraint(Capacity(10.0)); valid.unwrap(); // Valid: addition constraint with zero value - let valid = validate_raw_constraint(0.0); + let valid = validate_raw_constraint(Capacity(0.0)); valid.unwrap(); // Not valid: addition constraint with negative value - let invalid = validate_raw_constraint(-10.0); + let invalid = validate_raw_constraint(Capacity(-10.0)); assert_error!( invalid, "Invalid value for addition constraint: '-10'; must be non-negative and finite." @@ -355,14 +356,14 @@ mod tests { #[test] fn validate_addition_rejects_infinite() { // Invalid: infinite value - let invalid = validate_raw_constraint(f64::INFINITY); + let invalid = validate_raw_constraint(Capacity(f64::INFINITY)); assert_error!( invalid, "Invalid value for addition constraint: 'inf'; must be non-negative and finite." ); // Invalid: NaN value - let invalid = validate_raw_constraint(f64::NAN); + let invalid = validate_raw_constraint(Capacity(f64::NAN)); assert_error!( invalid, "Invalid value for addition constraint: 'NaN'; must be non-negative and finite." diff --git a/src/process.rs b/src/process.rs index f12986e16..5ff89007e 100644 --- a/src/process.rs +++ b/src/process.rs @@ -502,7 +502,23 @@ pub struct ProcessInvestmentConstraint { /// Addition constraint: Yearly limit an agent can invest /// in the process, shared according to the agent's /// proportion of the processes primary commodity demand - pub addition_limit: Option, + pub addition_limit: Option, +} + +impl ProcessInvestmentConstraint { + /// Get the addition limit, if any + /// + /// 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. + pub fn get_addition_limit(&self) -> Option { + self.addition_limit + } + + /// Get the addition limit for an agent based on their portion of the primary commodity demand + pub fn get_addition_limit_for_agent(&self, agent_portion: Dimensionless) -> Option { + self.get_addition_limit().map(|limit| limit * agent_portion) + } } #[cfg(test)] diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index d7a927923..8bc02c041 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -668,6 +668,44 @@ fn warn_on_equal_appraisal_outputs( } } +/// Get investment limits for candidate assets for a given agent in a given market and year +/// +/// Investment limits are based on demand for the commodity (capacity cannot exceed that needed to +/// meet demand), and any addition limits specified by the process (scaled according to the agent's +/// portion of the commodity demand). +fn get_investment_limits_for_candidates( + opt_assets: &[AssetRef], + commodity: &Commodity, + agent: &Agent, + region_id: &RegionID, + year: u32, +) -> HashMap { + let agent_portion = agent.commodity_portions[&(commodity.id.clone(), year)]; + let key = (region_id.clone(), year); + + opt_assets + .iter() + .filter(|asset| !asset.is_commissioned()) + .map(|asset| { + // Demand-limiting capacity (pre-calculated when creating candidate) + let mut cap = asset.capacity(); + + // Further capped by addition limits of the process, if specified + if let Some(limit) = asset + .process() + .investment_constraints + .get(&key) + .and_then(|c| c.get_addition_limit_for_agent(agent_portion)) + { + let limit_capacity = AssetCapacity::from_capacity(limit, asset.unit_size()); + cap = cap.min(limit_capacity); + } + + (asset.clone(), cap) + }) + .collect() +} + /// Get the best assets for meeting demand for the given commodity #[allow(clippy::too_many_arguments)] fn select_best_assets( @@ -687,13 +725,11 @@ fn select_best_assets( let coefficients = calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year); - let mut remaining_candidate_capacity = HashMap::from_iter( - opt_assets - .iter() - .filter(|asset| !asset.is_commissioned()) - .map(|asset| (asset.clone(), asset.capacity())), - ); + // Calculate investment limits for candidate assets + let mut remaining_candidate_capacity = + get_investment_limits_for_candidates(&opt_assets, commodity, agent, region_id, 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) { @@ -710,6 +746,8 @@ fn select_best_assets( // Appraise all options let mut outputs_for_opts = Vec::new(); for asset in &opt_assets { + // For candidates, determine the maximum capacity that can be invested in this round, + // according to the tranche size and remaining capacity limits. let max_capacity = (!asset.is_commissioned()).then(|| { let max_capacity = asset .capacity() @@ -788,7 +826,7 @@ fn select_best_assets( best_output.capacity.total_capacity() ); - // Update the assets + // Update the assets and remaining candidate capacity update_assets( best_output.asset, best_output.capacity, From 6dbc3e6ca621cec726e397e778d09448708fdcb1 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Fri, 23 Jan 2026 15:40:16 +0000 Subject: [PATCH 02/22] Scale limit based on number of years --- src/process.rs | 5 ----- src/simulation/investment.rs | 37 ++++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/process.rs b/src/process.rs index 5ff89007e..1a22663f5 100644 --- a/src/process.rs +++ b/src/process.rs @@ -514,11 +514,6 @@ impl ProcessInvestmentConstraint { pub fn get_addition_limit(&self) -> Option { self.addition_limit } - - /// Get the addition limit for an agent based on their portion of the primary commodity demand - pub fn get_addition_limit_for_agent(&self, agent_portion: Dimensionless) -> Option { - self.get_addition_limit().map(|limit| limit * agent_portion) - } } #[cfg(test)] diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 8bc02c041..42d7ff071 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -668,21 +668,35 @@ fn warn_on_equal_appraisal_outputs( } } -/// Get investment limits for candidate assets for a given agent in a given market and year +/// Calculate investment limits for candidate assets for a given agent in a given market and year /// /// Investment limits are based on demand for the commodity (capacity cannot exceed that needed to /// meet demand), and any addition limits specified by the process (scaled according to the agent's /// portion of the commodity demand). -fn get_investment_limits_for_candidates( +fn calculate_investment_limits_for_candidates( opt_assets: &[AssetRef], commodity: &Commodity, agent: &Agent, region_id: &RegionID, year: u32, + milestone_years: &[u32], ) -> HashMap { + // Check that the year is a milestone year and not the first milestone year (since we must + // calculate the number of years since the previous milestone year below) + assert!(milestone_years.contains(&year) && year != milestone_years[0],); + + // Calculate number of years elapsed since previous milestone year + let previous_milestone_year = *milestone_years + .iter() + .rev() + .skip_while(|&&y| y != year) + .nth(1) + .unwrap(); + let years_elapsed = Dimensionless((year - previous_milestone_year) as f64); + + // Calculate limits for each candidate asset let agent_portion = agent.commodity_portions[&(commodity.id.clone(), year)]; let key = (region_id.clone(), year); - opt_assets .iter() .filter(|asset| !asset.is_commissioned()) @@ -691,11 +705,16 @@ fn get_investment_limits_for_candidates( let mut cap = asset.capacity(); // Further capped by addition limits of the process, if specified + // These are scaled according to the agent's portion of the commodity demand and the + // number of years elapsed since the previous milestone year. if let Some(limit) = asset .process() .investment_constraints .get(&key) - .and_then(|c| c.get_addition_limit_for_agent(agent_portion)) + .and_then(|c| { + c.get_addition_limit() + .map(|l| l * years_elapsed * agent_portion) + }) { let limit_capacity = AssetCapacity::from_capacity(limit, asset.unit_size()); cap = cap.min(limit_capacity); @@ -726,8 +745,14 @@ fn select_best_assets( calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year); // Calculate investment limits for candidate assets - let mut remaining_candidate_capacity = - get_investment_limits_for_candidates(&opt_assets, commodity, agent, region_id, year); + let mut remaining_candidate_capacity = calculate_investment_limits_for_candidates( + &opt_assets, + commodity, + agent, + region_id, + year, + &model.parameters.milestone_years, + ); // Iteratively select the best asset until demand is met let mut round = 0; From e2bfc4b2bd65a9d36cb70bea5583b2bed8764284 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Fri, 23 Jan 2026 15:49:28 +0000 Subject: [PATCH 03/22] Update example models --- examples/muse1_default/process_investment_constraints.csv | 6 ++++++ examples/two_regions/process_investment_constraints.csv | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 examples/muse1_default/process_investment_constraints.csv create mode 100644 examples/two_regions/process_investment_constraints.csv diff --git a/examples/muse1_default/process_investment_constraints.csv b/examples/muse1_default/process_investment_constraints.csv new file mode 100644 index 000000000..c8f9aeca3 --- /dev/null +++ b/examples/muse1_default/process_investment_constraints.csv @@ -0,0 +1,6 @@ +process_id,regions,commission_years,addition_limit +gassupply1,R1,all,10 +gasCCGT,R1,all,10 +windturbine,R1,all,10 +gasboiler,R1,all,10 +heatpump,R1,all,10 diff --git a/examples/two_regions/process_investment_constraints.csv b/examples/two_regions/process_investment_constraints.csv new file mode 100644 index 000000000..09b9404eb --- /dev/null +++ b/examples/two_regions/process_investment_constraints.csv @@ -0,0 +1,6 @@ +process_id,regions,commission_years,addition_limit +gassupply1,R1;R2,all,10 +gasCCGT,R1;R2,all,10 +windturbine,R1;R2,all,10 +gasboiler,R1;R2,all,10 +heatpump,R1;R2,all,10 From 6c28f0f450fc4eeca983160674196a4c00278bd7 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Fri, 23 Jan 2026 16:47:17 +0000 Subject: [PATCH 04/22] Streamline arguments to calculate_investment_limits_for_candidates --- src/simulation/investment.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 42d7ff071..9f81e3c80 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -675,9 +675,7 @@ fn warn_on_equal_appraisal_outputs( /// portion of the commodity demand). fn calculate_investment_limits_for_candidates( opt_assets: &[AssetRef], - commodity: &Commodity, - agent: &Agent, - region_id: &RegionID, + agent_portion: Dimensionless, year: u32, milestone_years: &[u32], ) -> HashMap { @@ -695,12 +693,14 @@ fn calculate_investment_limits_for_candidates( let years_elapsed = Dimensionless((year - previous_milestone_year) as f64); // Calculate limits for each candidate asset - let agent_portion = agent.commodity_portions[&(commodity.id.clone(), year)]; - let key = (region_id.clone(), year); opt_assets .iter() .filter(|asset| !asset.is_commissioned()) .map(|asset| { + // Sanity check: if the year does not match the asset's commission year, then + // something is wrong + assert_eq!(asset.commission_year(), year); + // Demand-limiting capacity (pre-calculated when creating candidate) let mut cap = asset.capacity(); @@ -710,7 +710,7 @@ fn calculate_investment_limits_for_candidates( if let Some(limit) = asset .process() .investment_constraints - .get(&key) + .get(&(asset.region_id().clone(), year)) .and_then(|c| { c.get_addition_limit() .map(|l| l * years_elapsed * agent_portion) @@ -745,11 +745,10 @@ fn select_best_assets( calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year); // Calculate investment limits for candidate assets + let agent_portion = agent.commodity_portions[&(commodity.id.clone(), year)]; let mut remaining_candidate_capacity = calculate_investment_limits_for_candidates( &opt_assets, - commodity, - agent, - region_id, + agent_portion, year, &model.parameters.milestone_years, ); @@ -774,12 +773,11 @@ fn select_best_assets( // For candidates, determine the maximum capacity that can be invested in this round, // according to the tranche size and remaining capacity limits. let max_capacity = (!asset.is_commissioned()).then(|| { - let max_capacity = asset + let tranche_capacity = asset .capacity() .apply_limit_factor(model.parameters.capacity_limit_factor); - let remaining_capacity = remaining_candidate_capacity[asset]; - max_capacity.min(remaining_capacity) + tranche_capacity.min(remaining_capacity) }); // Skip any assets from groups we've already seen From 864b199e7ccb580b737b07e8cdbddd3e4a07d49f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 26 Jan 2026 10:36:32 +0000 Subject: [PATCH 05/22] Small tidy ups --- src/process.rs | 4 ++-- src/simulation/investment.rs | 20 +++++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/process.rs b/src/process.rs index 1a22663f5..80602f48a 100644 --- a/src/process.rs +++ b/src/process.rs @@ -506,12 +506,12 @@ pub struct ProcessInvestmentConstraint { } impl ProcessInvestmentConstraint { - /// Get the addition limit, if any + /// Calculate the overall annual addition limit, if any /// /// 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. - pub fn get_addition_limit(&self) -> Option { + pub fn get_annual_addition_limit(&self) -> Option { self.addition_limit } } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 9f81e3c80..956eae52c 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -668,7 +668,7 @@ fn warn_on_equal_appraisal_outputs( } } -/// Calculate investment limits for candidate assets for a given agent in a given market and year +/// Calculate investment limits for an agent's candidate assets in a given year /// /// Investment limits are based on demand for the commodity (capacity cannot exceed that needed to /// meet demand), and any addition limits specified by the process (scaled according to the agent's @@ -679,17 +679,15 @@ fn calculate_investment_limits_for_candidates( year: u32, milestone_years: &[u32], ) -> HashMap { - // Check that the year is a milestone year and not the first milestone year (since we must - // calculate the number of years since the previous milestone year below) - assert!(milestone_years.contains(&year) && year != milestone_years[0],); + // Ensure `year` is a milestone year and not the first milestone (we need the previous one) + let pos = milestone_years + .iter() + .position(|&y| y == year) + .expect("`year` must be a milestone year"); + assert!(pos > 0); // Calculate number of years elapsed since previous milestone year - let previous_milestone_year = *milestone_years - .iter() - .rev() - .skip_while(|&&y| y != year) - .nth(1) - .unwrap(); + let previous_milestone_year = milestone_years[pos - 1]; let years_elapsed = Dimensionless((year - previous_milestone_year) as f64); // Calculate limits for each candidate asset @@ -712,7 +710,7 @@ fn calculate_investment_limits_for_candidates( .investment_constraints .get(&(asset.region_id().clone(), year)) .and_then(|c| { - c.get_addition_limit() + c.get_annual_addition_limit() .map(|l| l * years_elapsed * agent_portion) }) { From 4ac9e84bbd1f135dde342bdf5cd032df25e1e68f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 26 Jan 2026 10:59:35 +0000 Subject: [PATCH 06/22] Add tests --- src/simulation/investment.rs | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 956eae52c..f1e61691a 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -936,14 +936,17 @@ mod tests { use super::*; use crate::agent::AgentID; use crate::asset::Asset; + use crate::asset::AssetRef; use crate::commodity::Commodity; use crate::fixture::{ agent_id, asset, process, process_activity_limits_map, process_flows_map, region_id, svd_commodity, time_slice, time_slice_info, time_slice_info2, }; + use crate::process::ProcessInvestmentConstraint; use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow}; use crate::region::RegionID; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; + use crate::units::Dimensionless; use crate::units::{Capacity, Flow, FlowPerActivity, MoneyPerFlow}; use indexmap::indexmap; use rstest::rstest; @@ -1058,4 +1061,58 @@ mod tests { assert!(compare_asset_fallback(&asset3, &asset2).is_lt()); assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); } + + #[rstest] + #[case(Some(Capacity(3.0)), Capacity(10.0), Dimensionless(0.5), Capacity(7.5))] // Expected limit: min(10, 3 * 5 * 0.5) = 7.5 + #[case( + Some(Capacity(3.0)), + Capacity(10.0), + Dimensionless(1.0), + Capacity(10.0) + )] // Expected limit: min(10, 3 * 5 * 1.0) = 10 + #[case(None, Capacity(10.0), Dimensionless(0.5), Capacity(10.0))] // Expected limit: 10 (no addition limit) + fn calculate_investment_limits_for_candidates_parametrized( + mut process: Process, + region_id: RegionID, + #[case] process_addition_limit: Option, + #[case] demand_limiting_capacity: Capacity, + #[case] agent_share: Dimensionless, + #[case] expected_capacity: Capacity, + ) { + // Setup: year with previous milestone 5 years earlier + let year = 2020u32; + let milestone_years = vec![2015u32, 2020u32]; + + // Optionally add an annual addition limit for this process/region/year + if let Some(limit) = process_addition_limit { + process.investment_constraints.insert( + (region_id.clone(), year), + Rc::new(ProcessInvestmentConstraint { + addition_limit: Some(limit), + }), + ); + } + + let process_rc = Rc::new(process); + + // Candidate with specified demand-limiting capacity + let asset = Asset::new_candidate( + process_rc.clone(), + region_id.clone(), + demand_limiting_capacity, + year, + ) + .unwrap(); + let asset_ref = AssetRef::from(asset); + let opt_assets = vec![asset_ref.clone()]; + + let limits = calculate_investment_limits_for_candidates( + &opt_assets, + agent_share, + year, + &milestone_years, + ); + let cap = limits[&asset_ref].total_capacity(); + assert_eq!(cap, expected_capacity); + } } From 6c4c6c5c3c1c146d2c7c3f1bef576ae4a7f56074 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 26 Jan 2026 11:23:16 +0000 Subject: [PATCH 07/22] Pre-compute investment limits, better error message --- src/simulation/investment.rs | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index f1e61691a..228f33df4 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -290,12 +290,21 @@ fn select_assets_for_single_market( region_id, year, ) - .collect(); + .collect::>(); + + // Calculate investment limits for candidate assets + let investment_limits = calculate_investment_limits_for_candidates( + &opt_assets, + commodity_portion, + year, + &model.parameters.milestone_years, + ); // Choose assets from among existing pool and candidates let best_assets = select_best_assets( model, opt_assets, + investment_limits, commodity, agent, region_id, @@ -675,7 +684,7 @@ fn warn_on_equal_appraisal_outputs( /// portion of the commodity demand). fn calculate_investment_limits_for_candidates( opt_assets: &[AssetRef], - agent_portion: Dimensionless, + commodity_portion: Dimensionless, year: u32, milestone_years: &[u32], ) -> HashMap { @@ -711,7 +720,7 @@ fn calculate_investment_limits_for_candidates( .get(&(asset.region_id().clone(), year)) .and_then(|c| { c.get_annual_addition_limit() - .map(|l| l * years_elapsed * agent_portion) + .map(|l| l * years_elapsed * commodity_portion) }) { let limit_capacity = AssetCapacity::from_capacity(limit, asset.unit_size()); @@ -728,6 +737,7 @@ fn calculate_investment_limits_for_candidates( fn select_best_assets( model: &Model, mut opt_assets: Vec, + investment_limits: HashMap, commodity: &Commodity, agent: &Agent, region_id: &RegionID, @@ -737,28 +747,22 @@ fn select_best_assets( writer: &mut DataWriter, ) -> Result> { let objective_type = &agent.objectives[&year]; + let mut remaining_candidate_capacity = investment_limits; // 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); - // Calculate investment limits for candidate assets - let agent_portion = agent.commodity_portions[&(commodity.id.clone(), year)]; - let mut remaining_candidate_capacity = calculate_investment_limits_for_candidates( - &opt_assets, - agent_portion, - year, - &model.parameters.milestone_years, - ); - // 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) { ensure!( !opt_assets.is_empty(), - "Failed to meet demand for commodity '{}' with provided assets", - &commodity.id + "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 ); // Since all assets with the same `group_id` are identical, we only need to appraise one From da9fafe81d4cd883555dc1632fe5219085950a17 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 26 Jan 2026 15:21:26 +0000 Subject: [PATCH 08/22] max_installable_capacity Asset function --- src/asset.rs | 67 ++++++++++++++++++++++++++++++++++++ src/simulation/investment.rs | 22 ++++-------- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index f7fe42873..71177f741 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -194,6 +194,22 @@ impl AssetCapacity { } } + /// Create an `AssetCapacity` from a total capacity and optional unit size + /// + /// If a unit size is provided, the capacity is represented as a discrete number of units, + /// calculated as the floor of (capacity / `unit_size`). If no unit size is provided, the + /// capacity is represented as continuous. + pub fn from_capacity_floor(capacity: Capacity, unit_size: Option) -> Self { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + match unit_size { + Some(size) => { + let num_units = (capacity / size).value().floor() as u32; + AssetCapacity::Discrete(num_units, size) + } + None => AssetCapacity::Continuous(capacity), + } + } + /// Returns the total capacity represented by this `AssetCapacity`. pub fn total_capacity(&self) -> Capacity { match self { @@ -1046,6 +1062,35 @@ impl Asset { let child_asset = AssetRef::from(Rc::new(child_asset)); std::iter::repeat_n(child_asset, n_units as usize).collect() } + + /// For uncommissioned 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, scaled by the number of years elapsed since the previous milestone year + /// and the portion of the commodity demand being considered. + /// + /// For divisible assets, the returned capacity will be rounded down to the nearest multiple of + /// the asset's unit size. + pub fn max_installable_capacity( + &self, + years_elapsed: Dimensionless, + commodity_portion: Dimensionless, + ) -> Option { + assert!( + !self.is_commissioned(), + "max_installable_capacity can only be called on uncommissioned assets" + ); + + self.process + .investment_constraints + .get(&(self.region_id.clone(), self.commission_year)) + .and_then(|c| { + c.get_annual_addition_limit() + .map(|l| l * years_elapsed * commodity_portion) + }) + .map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size())) + } } #[allow(clippy::missing_fields_in_debug)] @@ -2303,4 +2348,26 @@ mod tests { "Agent A0_GEX has asset with commission year 2060, not within process GASDRV commission years: 2020..=2050" ); } + + #[rstest] + fn max_installable_capacit(mut process: Process, region_id: RegionID) { + // Set an addition limit of 3 for (region, year 2015) + process.investment_constraints.insert( + (region_id.clone(), 2015), + Rc::new(crate::process::ProcessInvestmentConstraint { + addition_limit: Some(Capacity(3.0)), + }), + ); + + let process_rc = Rc::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(); + + // years_elapsed = 5, commodity_portion = 0.5 -> limit = 3 * 5 * 0.5 = 7.5 + let result = asset.max_installable_capacity(Dimensionless(5.0), Dimensionless(0.5)); + assert_eq!(result, Some(AssetCapacity::Continuous(Capacity(7.5)))); + } } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 228f33df4..cfc678675 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -680,8 +680,9 @@ fn warn_on_equal_appraisal_outputs( /// Calculate investment limits for an agent's candidate assets in a given year /// /// Investment limits are based on demand for the commodity (capacity cannot exceed that needed to -/// meet demand), and any addition limits specified by the process (scaled according to the agent's -/// portion of the commodity demand). +/// meet demand), and 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). fn calculate_investment_limits_for_candidates( opt_assets: &[AssetRef], commodity_portion: Dimensionless, @@ -708,22 +709,13 @@ fn calculate_investment_limits_for_candidates( // something is wrong assert_eq!(asset.commission_year(), year); - // Demand-limiting capacity (pre-calculated when creating candidate) + // Start off with the demand-limiting capacity (pre-calculated when creating candidate) let mut cap = asset.capacity(); - // Further capped by addition limits of the process, if specified - // These are scaled according to the agent's portion of the commodity demand and the - // number of years elapsed since the previous milestone year. - if let Some(limit) = asset - .process() - .investment_constraints - .get(&(asset.region_id().clone(), year)) - .and_then(|c| { - c.get_annual_addition_limit() - .map(|l| l * years_elapsed * commodity_portion) - }) + // Cap by the addition limits of the process, if specified + if let Some(limit_capacity) = + asset.max_installable_capacity(years_elapsed, commodity_portion) { - let limit_capacity = AssetCapacity::from_capacity(limit, asset.unit_size()); cap = cap.min(limit_capacity); } From 4fc39e653194a0a0ea2229d6de824eea0e1fa91f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 26 Jan 2026 16:46:26 +0000 Subject: [PATCH 09/22] Use u32 for number of years --- src/asset.rs | 6 +++--- src/simulation/investment.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 71177f741..cbd05f6d6 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1074,7 +1074,7 @@ impl Asset { /// the asset's unit size. pub fn max_installable_capacity( &self, - years_elapsed: Dimensionless, + years_elapsed: u32, commodity_portion: Dimensionless, ) -> Option { assert!( @@ -1087,7 +1087,7 @@ impl Asset { .get(&(self.region_id.clone(), self.commission_year)) .and_then(|c| { c.get_annual_addition_limit() - .map(|l| l * years_elapsed * commodity_portion) + .map(|l| l * Dimensionless(years_elapsed as f64) * commodity_portion) }) .map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size())) } @@ -2367,7 +2367,7 @@ mod tests { .unwrap(); // years_elapsed = 5, commodity_portion = 0.5 -> limit = 3 * 5 * 0.5 = 7.5 - let result = asset.max_installable_capacity(Dimensionless(5.0), Dimensionless(0.5)); + let result = asset.max_installable_capacity(5, Dimensionless(0.5)); assert_eq!(result, Some(AssetCapacity::Continuous(Capacity(7.5)))); } } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index cfc678675..cfbc02966 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -698,7 +698,7 @@ fn calculate_investment_limits_for_candidates( // Calculate number of years elapsed since previous milestone year let previous_milestone_year = milestone_years[pos - 1]; - let years_elapsed = Dimensionless((year - previous_milestone_year) as f64); + let years_elapsed = year - previous_milestone_year; // Calculate limits for each candidate asset opt_assets From 4afd749a3483bdd940e063d054821da8822eb5a0 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 26 Jan 2026 17:37:22 +0000 Subject: [PATCH 10/22] Pre-scale limits --- src/asset.rs | 16 ++--- src/input/process/investment_constraints.rs | 63 ++++++++++------ src/process.rs | 16 +++-- src/simulation/investment.rs | 80 +-------------------- 4 files changed, 57 insertions(+), 118 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index cbd05f6d6..deefaf3e5 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1067,14 +1067,12 @@ impl Asset { /// 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, scaled by the number of years elapsed since the previous milestone year - /// and the portion of the commodity demand being considered. + /// commission year, and the portion of the commodity demand being considered. /// /// For divisible assets, the returned capacity will be rounded down to the nearest multiple of /// the asset's unit size. pub fn max_installable_capacity( &self, - years_elapsed: u32, commodity_portion: Dimensionless, ) -> Option { assert!( @@ -1085,10 +1083,7 @@ impl Asset { self.process .investment_constraints .get(&(self.region_id.clone(), self.commission_year)) - .and_then(|c| { - c.get_annual_addition_limit() - .map(|l| l * Dimensionless(years_elapsed as f64) * commodity_portion) - }) + .and_then(|c| c.get_addition_limit().map(|l| l * commodity_portion)) .map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size())) } } @@ -2358,7 +2353,6 @@ mod tests { addition_limit: Some(Capacity(3.0)), }), ); - let process_rc = Rc::new(process); // Create a candidate asset with commission year 2015 @@ -2366,8 +2360,8 @@ mod tests { Asset::new_candidate(process_rc.clone(), region_id.clone(), Capacity(1.0), 2015) .unwrap(); - // years_elapsed = 5, commodity_portion = 0.5 -> limit = 3 * 5 * 0.5 = 7.5 - let result = asset.max_installable_capacity(5, Dimensionless(0.5)); - assert_eq!(result, Some(AssetCapacity::Continuous(Capacity(7.5)))); + // commodity_portion = 0.5 -> limit = 3 * 0.5 = 1.5 + let result = asset.max_installable_capacity(Dimensionless(0.5)); + assert_eq!(result, Some(AssetCapacity::Continuous(Capacity(1.5)))); } } diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index 7d461d7da..4ccaa77c7 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -5,7 +5,7 @@ use crate::process::{ ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap, }; use crate::region::parse_region_str; -use crate::units::Capacity; +use crate::units::{Capacity, Dimensionless}; use crate::year::parse_year_str; use anyhow::{Context, Result, ensure}; use itertools::iproduct; @@ -119,11 +119,27 @@ where })?; // Create constraints for each region and year combination - let constraint = Rc::new(ProcessInvestmentConstraint { - addition_limit: Some(record.addition_limit), - }); + // For a given milestone year, the addition limit should be multiplied + // by the number of years since the previous milestone year. Any + // addition limits specified for the first milestone year are ignored. let process_map = map.entry(process_id.clone()).or_default(); for (region, &year) in iproduct!(&record_regions, &constraint_years) { + // Calculate years since previous milestone year + // We can ignore constraints in the first milestone year as no investments are performed then + let idx = milestone_years.iter().position(|y| *y == year).unwrap(); + if idx == 0 { + continue; + } + let prev_year = milestone_years[idx - 1]; + let years_since_prev = year - prev_year; + + // Multiply the addition limit by the number of years since previous milestone. + let scaled_limit = record.addition_limit * Dimensionless(years_since_prev as f64); + + let constraint = Rc::new(ProcessInvestmentConstraint { + addition_limit: Some(scaled_limit), + }); + try_insert(process_map, &(region.clone(), year), constraint.clone())?; } } @@ -235,32 +251,32 @@ mod tests { let gbr_region: RegionID = "GBR".into(); let usa_region: RegionID = "USA".into(); - // Check GBR 2010 constraint - let gbr_2010 = process_constraints - .get(&(gbr_region.clone(), 2010)) - .expect("GBR 2010 constraint should exist"); - assert_eq!(gbr_2010.addition_limit, Some(Capacity(100.0))); + // GBR 2010 constraint is for the first milestone year and should be ignored + assert!( + !process_constraints.contains_key(&(gbr_region.clone(), 2010)), + "GBR 2010 constraint should not exist" + ); - // Check GBR 2015 constraint (from ALL regions) + // Check GBR 2015 constraint (from ALL regions), scaled by years since previous milestone (5 years) let gbr_2015 = process_constraints .get(&(gbr_region, 2015)) .expect("GBR 2015 constraint should exist"); - assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0))); + assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0 * 5.0))); - // Check USA 2015 constraint (from ALL regions) + // Check USA 2015 constraint (from ALL regions), scaled by 5 years let usa_2015 = process_constraints .get(&(usa_region.clone(), 2015)) .expect("USA 2015 constraint should exist"); - assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0))); + assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0 * 5.0))); - // Check USA 2020 constraint + // Check USA 2020 constraint, scaled by years since previous milestone (5 years) let usa_2020 = process_constraints .get(&(usa_region, 2020)) .expect("USA 2020 constraint should exist"); - assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0))); + assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0 * 5.0))); - // Verify total number of constraints (2 GBR + 2 USA = 4) - assert_eq!(process_constraints.len(), 4); + // Verify total number of constraints (GBR 2015, USA 2015, USA 2020 = 3) + assert_eq!(process_constraints.len(), 3); } #[rstest] @@ -293,21 +309,22 @@ mod tests { let gbr_region: RegionID = "GBR".into(); let usa_region: RegionID = "USA".into(); - // Verify constraint exists for all region-year combinations - for &year in &milestone_years { + // Verify constraint exists for all region-year combinations except the first milestone year + for &year in &milestone_years[1..] { let gbr_constraint = process_constraints .get(&(gbr_region.clone(), year)) .unwrap_or_else(|| panic!("GBR {year} constraint should exist")); - assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0))); + // scaled by years since previous milestone (5 years) + assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0 * 5.0))); let usa_constraint = process_constraints .get(&(usa_region.clone(), year)) .unwrap_or_else(|| panic!("USA {year} constraint should exist")); - assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0))); + assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0 * 5.0))); } - // Verify total number of constraints (2 regions × 3 years = 6) - assert_eq!(process_constraints.len(), 6); + // Verify total number of constraints (2 regions × 2 years = 4) + assert_eq!(process_constraints.len(), 4); } #[rstest] diff --git a/src/process.rs b/src/process.rs index 80602f48a..0ef6b6e3f 100644 --- a/src/process.rs +++ b/src/process.rs @@ -497,21 +497,23 @@ pub struct ProcessParameter { } /// A constraint imposed on investments in the process +/// +/// Constraints apply to a specific milestone year, and have been pre-scaled from annual limits +/// defined in the input data to account for the number of years since the previous milestone year. #[derive(PartialEq, Debug, Clone)] pub struct ProcessInvestmentConstraint { - /// Addition constraint: Yearly limit an agent can invest - /// in the process, shared according to the agent's - /// proportion of the processes primary commodity demand + /// Addition constraint: Limit an agent can invest in the process, shared according to the + /// agent's proportion of the processes primary commodity demand pub addition_limit: Option, } impl ProcessInvestmentConstraint { - /// Calculate the overall annual addition limit, if any + /// 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 + /// 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. - pub fn get_annual_addition_limit(&self) -> Option { + pub fn get_addition_limit(&self) -> Option { self.addition_limit } } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index cfbc02966..fb3fbcce8 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -293,12 +293,8 @@ fn select_assets_for_single_market( .collect::>(); // Calculate investment limits for candidate assets - let investment_limits = calculate_investment_limits_for_candidates( - &opt_assets, - commodity_portion, - year, - &model.parameters.milestone_years, - ); + let investment_limits = + calculate_investment_limits_for_candidates(&opt_assets, commodity_portion, year); // Choose assets from among existing pool and candidates let best_assets = select_best_assets( @@ -687,19 +683,7 @@ fn calculate_investment_limits_for_candidates( opt_assets: &[AssetRef], commodity_portion: Dimensionless, year: u32, - milestone_years: &[u32], ) -> HashMap { - // Ensure `year` is a milestone year and not the first milestone (we need the previous one) - let pos = milestone_years - .iter() - .position(|&y| y == year) - .expect("`year` must be a milestone year"); - assert!(pos > 0); - - // Calculate number of years elapsed since previous milestone year - let previous_milestone_year = milestone_years[pos - 1]; - let years_elapsed = year - previous_milestone_year; - // Calculate limits for each candidate asset opt_assets .iter() @@ -713,9 +697,7 @@ fn calculate_investment_limits_for_candidates( let mut cap = asset.capacity(); // Cap by the addition limits of the process, if specified - if let Some(limit_capacity) = - asset.max_installable_capacity(years_elapsed, commodity_portion) - { + if let Some(limit_capacity) = asset.max_installable_capacity(commodity_portion) { cap = cap.min(limit_capacity); } @@ -932,13 +914,11 @@ mod tests { use super::*; use crate::agent::AgentID; use crate::asset::Asset; - use crate::asset::AssetRef; use crate::commodity::Commodity; use crate::fixture::{ agent_id, asset, process, process_activity_limits_map, process_flows_map, region_id, svd_commodity, time_slice, time_slice_info, time_slice_info2, }; - use crate::process::ProcessInvestmentConstraint; use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow}; use crate::region::RegionID; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; @@ -1057,58 +1037,4 @@ mod tests { assert!(compare_asset_fallback(&asset3, &asset2).is_lt()); assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); } - - #[rstest] - #[case(Some(Capacity(3.0)), Capacity(10.0), Dimensionless(0.5), Capacity(7.5))] // Expected limit: min(10, 3 * 5 * 0.5) = 7.5 - #[case( - Some(Capacity(3.0)), - Capacity(10.0), - Dimensionless(1.0), - Capacity(10.0) - )] // Expected limit: min(10, 3 * 5 * 1.0) = 10 - #[case(None, Capacity(10.0), Dimensionless(0.5), Capacity(10.0))] // Expected limit: 10 (no addition limit) - fn calculate_investment_limits_for_candidates_parametrized( - mut process: Process, - region_id: RegionID, - #[case] process_addition_limit: Option, - #[case] demand_limiting_capacity: Capacity, - #[case] agent_share: Dimensionless, - #[case] expected_capacity: Capacity, - ) { - // Setup: year with previous milestone 5 years earlier - let year = 2020u32; - let milestone_years = vec![2015u32, 2020u32]; - - // Optionally add an annual addition limit for this process/region/year - if let Some(limit) = process_addition_limit { - process.investment_constraints.insert( - (region_id.clone(), year), - Rc::new(ProcessInvestmentConstraint { - addition_limit: Some(limit), - }), - ); - } - - let process_rc = Rc::new(process); - - // Candidate with specified demand-limiting capacity - let asset = Asset::new_candidate( - process_rc.clone(), - region_id.clone(), - demand_limiting_capacity, - year, - ) - .unwrap(); - let asset_ref = AssetRef::from(asset); - let opt_assets = vec![asset_ref.clone()]; - - let limits = calculate_investment_limits_for_candidates( - &opt_assets, - agent_share, - year, - &milestone_years, - ); - let cap = limits[&asset_ref].total_capacity(); - assert_eq!(cap, expected_capacity); - } } From 2a36db401bd5c4c08aa38ef235302ea1833d2eb7 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 27 Jan 2026 09:51:15 +0000 Subject: [PATCH 11/22] Simplify calculate_investment_limits_for_candidates --- src/simulation/investment.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index fb3fbcce8..7027e2489 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -294,7 +294,7 @@ fn select_assets_for_single_market( // Calculate investment limits for candidate assets let investment_limits = - calculate_investment_limits_for_candidates(&opt_assets, commodity_portion, year); + calculate_investment_limits_for_candidates(&opt_assets, commodity_portion); // Choose assets from among existing pool and candidates let best_assets = select_best_assets( @@ -682,17 +682,12 @@ fn warn_on_equal_appraisal_outputs( fn calculate_investment_limits_for_candidates( opt_assets: &[AssetRef], commodity_portion: Dimensionless, - year: u32, ) -> HashMap { // Calculate limits for each candidate asset opt_assets .iter() .filter(|asset| !asset.is_commissioned()) .map(|asset| { - // Sanity check: if the year does not match the asset's commission year, then - // something is wrong - assert_eq!(asset.commission_year(), year); - // Start off with the demand-limiting capacity (pre-calculated when creating candidate) let mut cap = asset.capacity(); From b2eb10d2e6d84221702a2cbb6fa9343e7f25565e Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 27 Jan 2026 10:45:40 +0000 Subject: [PATCH 12/22] Apply constraints in circularities model --- src/simulation/investment.rs | 17 +++++++++++++++++ src/simulation/optimisation.rs | 35 ++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 7027e2489..e5d9e9088 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -381,11 +381,28 @@ fn select_assets_for_cycle( .cloned() .collect(); + // Retrieve installable capacity limits for flexible capacity assets. + let key = (commodity_id.clone(), year); + let mut agent_share_cache = HashMap::new(); + let capacity_limits = flexible_capacity_assets + .iter() + .filter_map(|asset| { + let agent_id = asset.agent_id().unwrap().clone(); + let agent_share = *agent_share_cache + .entry(agent_id.clone()) + .or_insert_with(|| model.agents[&agent_id].commodity_portions[&key]); + asset + .max_installable_capacity(agent_share) + .map(|max_capacity| (asset.clone(), max_capacity)) + }) + .collect::>(); + // Run dispatch let solution = DispatchRun::new(model, &all_assets, year) .with_market_balance_subset(&markets_to_balance) .with_flexible_capacity_assets( &flexible_capacity_assets, + Some(&capacity_limits), // Gives newly selected cycle assets limited capacity wiggle-room; existing assets stay fixed. model.parameters.capacity_margin, ) diff --git a/src/simulation/optimisation.rs b/src/simulation/optimisation.rs index 0797e4161..7b082c873 100644 --- a/src/simulation/optimisation.rs +++ b/src/simulation/optimisation.rs @@ -17,6 +17,7 @@ use anyhow::{Result, bail, ensure}; use highs::{HighsModelStatus, HighsStatus, RowProblem as Problem, Sense}; use indexmap::{IndexMap, IndexSet}; use itertools::{chain, iproduct}; +use std::collections::HashMap; use std::error::Error; use std::fmt; use std::ops::Range; @@ -393,6 +394,7 @@ pub struct DispatchRun<'model, 'run> { model: &'model Model, existing_assets: &'run [AssetRef], flexible_capacity_assets: &'run [AssetRef], + capacity_limits: Option<&'run HashMap>, candidate_assets: &'run [AssetRef], markets_to_balance: &'run [(CommodityID, RegionID)], input_prices: Option<&'run CommodityPrices>, @@ -407,6 +409,7 @@ impl<'model, 'run> DispatchRun<'model, 'run> { model, existing_assets: assets, flexible_capacity_assets: &[], + capacity_limits: None, candidate_assets: &[], markets_to_balance: &[], input_prices: None, @@ -419,10 +422,12 @@ impl<'model, 'run> DispatchRun<'model, 'run> { pub fn with_flexible_capacity_assets( self, flexible_capacity_assets: &'run [AssetRef], + capacity_limits: Option<&'run HashMap>, capacity_margin: f64, ) -> Self { Self { flexible_capacity_assets, + capacity_limits, capacity_margin, ..self } @@ -583,6 +588,7 @@ impl<'model, 'run> DispatchRun<'model, 'run> { &mut problem, &mut variables.capacity_vars, self.flexible_capacity_assets, + self.capacity_limits, self.capacity_margin, ); } @@ -647,6 +653,7 @@ fn add_capacity_variables( problem: &mut Problem, variables: &mut CapacityVariableMap, assets: &[AssetRef], + capacity_limits: Option<&HashMap>, capacity_margin: f64, ) -> Range { // This line **must** come before we add more variables @@ -662,17 +669,41 @@ fn add_capacity_variables( let current_capacity = asset.capacity(); let coeff = calculate_capacity_coefficient(asset); + // Retrieve capacity limit if provided + let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset)); + + // Sanity check: make sure capacity_limit is compatible with current_capacity + if let Some(limit) = capacity_limit { + assert!( + matches!( + (current_capacity, limit), + (AssetCapacity::Continuous(_), AssetCapacity::Continuous(_)) + | (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _)) + ), + "Incompatible capacity types for asset capacity limit" + ); + } + + // Add a capacity variable for each asset + // Bounds are calculated based on current capacity with wiggle-room defined by + // `capacity_margin`, and limited by `capacity_limit` if provided. let var = match current_capacity { AssetCapacity::Continuous(cap) => { // Continuous capacity: capacity variable represents total capacity let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0); - let upper = (1.0 + capacity_margin) * cap.value(); + let mut upper = (1.0 + capacity_margin) * cap.value(); + if let Some(limit) = capacity_limit { + upper = upper.min(limit.total_capacity().value()); + } problem.add_column(coeff.value(), lower..=upper) } AssetCapacity::Discrete(units, unit_size) => { // Discrete capacity: capacity variable represents number of units let lower = ((1.0 - capacity_margin) * units as f64).max(0.0); - let upper = (1.0 + capacity_margin) * units as f64; + let mut upper = (1.0 + capacity_margin) * units as f64; + if let Some(limit) = capacity_limit { + upper = upper.min(limit.n_units().unwrap() as f64); + } problem.add_integer_column((coeff * unit_size).value(), lower..=upper) } }; From 8f05fb61a9f90609facbeb0d2c4a00c123fee0d0 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 27 Jan 2026 11:32:57 +0000 Subject: [PATCH 13/22] Copilot fixes --- src/asset.rs | 23 ++++++++++++++++++++- src/input/process/investment_constraints.rs | 4 +++- src/process.rs | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index deefaf3e5..11b963377 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1564,6 +1564,27 @@ mod tests { assert_eq!(got.total_capacity(), expected_total); } + #[rstest] + #[case::exact_multiple(Capacity(12.0), Some(Capacity(4.0)), Some(3), Capacity(12.0))] + #[case::rounded_down(Capacity(11.0), Some(Capacity(4.0)), Some(2), Capacity(8.0))] + #[case::unit_size_greater_than_capacity( + Capacity(3.0), + Some(Capacity(4.0)), + Some(0), + Capacity(0.0) + )] + #[case::continuous(Capacity(5.5), None, None, Capacity(5.5))] + fn from_capacity_floor( + #[case] capacity: Capacity, + #[case] unit_size: Option, + #[case] expected_n: Option, + #[case] expected_total: Capacity, + ) { + let got = AssetCapacity::from_capacity_floor(capacity, unit_size); + assert_eq!(got.n_units(), expected_n); + assert_eq!(got.total_capacity(), expected_total); + } + #[rstest] #[case::round_up(3u32, Capacity(4.0), Dimensionless(0.5), 2u32)] #[case::exact(3u32, Capacity(4.0), Dimensionless(0.33), 1u32)] @@ -2345,7 +2366,7 @@ mod tests { } #[rstest] - fn max_installable_capacit(mut process: Process, region_id: RegionID) { + 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), diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index 4ccaa77c7..ca5490b38 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -126,7 +126,9 @@ where for (region, &year) in iproduct!(&record_regions, &constraint_years) { // Calculate years since previous milestone year // We can ignore constraints in the first milestone year as no investments are performed then - let idx = milestone_years.iter().position(|y| *y == year).unwrap(); + let idx = milestone_years.iter().position(|y| *y == year).expect( + "Year should be in milestone_years since it was validated by parse_year_str", + ); if idx == 0 { continue; } diff --git a/src/process.rs b/src/process.rs index 0ef6b6e3f..6cea357f1 100644 --- a/src/process.rs +++ b/src/process.rs @@ -503,7 +503,7 @@ pub struct ProcessParameter { #[derive(PartialEq, Debug, Clone)] pub struct ProcessInvestmentConstraint { /// Addition constraint: Limit an agent can invest in the process, shared according to the - /// agent's proportion of the processes primary commodity demand + /// agent's proportion of the process's primary commodity demand pub addition_limit: Option, } From 51cf7b3aa1412ee628000f5fa2bbf21261c7edb3 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Tue, 10 Feb 2026 16:13:43 +0000 Subject: [PATCH 14/22] Remove unnecessary clone() --- 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 e5d9e9088..0f0ca04e5 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -387,10 +387,10 @@ fn select_assets_for_cycle( let capacity_limits = flexible_capacity_assets .iter() .filter_map(|asset| { - let agent_id = asset.agent_id().unwrap().clone(); + let agent_id = asset.agent_id().unwrap(); let agent_share = *agent_share_cache .entry(agent_id.clone()) - .or_insert_with(|| model.agents[&agent_id].commodity_portions[&key]); + .or_insert_with(|| model.agents[agent_id].commodity_portions[&key]); asset .max_installable_capacity(agent_share) .map(|max_capacity| (asset.clone(), max_capacity)) From 518ea917f117f0918f99291bb3d8665543a3ab56 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Tue, 10 Feb 2026 16:27:12 +0000 Subject: [PATCH 15/22] Tweak doc comment --- src/asset.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/asset.rs b/src/asset.rs index 11b963377..3bf1afb27 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1063,7 +1063,7 @@ impl Asset { std::iter::repeat_n(child_asset, n_units as usize).collect() } - /// For uncommissioned assets, get the maximum capacity permitted to be installed based on the + /// 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 From 3da198d6fccac3b713638708ce7d9b132abb69bf Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Wed, 11 Feb 2026 09:43:53 +0000 Subject: [PATCH 16/22] Add tests for `calculate_investment_limits_for_candidates` --- src/simulation/investment.rs | 272 ++++++++++++++++++++++++++++++++++- 1 file changed, 266 insertions(+), 6 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 0f0ca04e5..e7e29419e 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -928,15 +928,19 @@ mod tests { use crate::asset::Asset; use crate::commodity::Commodity; use crate::fixture::{ - agent_id, asset, process, process_activity_limits_map, process_flows_map, region_id, - svd_commodity, time_slice, time_slice_info, time_slice_info2, + agent_id, asset, process, process_activity_limits_map, process_flows_map, + process_investment_constraints, process_parameter_map, region_id, svd_commodity, + time_slice, time_slice_info, time_slice_info2, + }; + use crate::process::{ + ActivityLimits, FlowType, Process, ProcessActivityLimitsMap, ProcessFlow, ProcessFlowsMap, + ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessParameterMap, }; - use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow}; use crate::region::RegionID; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; use crate::units::Dimensionless; - use crate::units::{Capacity, Flow, FlowPerActivity, MoneyPerFlow}; - use indexmap::indexmap; + use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow}; + use indexmap::{IndexSet, indexmap}; use rstest::rstest; use std::rc::Rc; @@ -1047,6 +1051,262 @@ mod tests { assert!(compare_asset_fallback(&asset1, &asset3).is_lt()); assert!(compare_asset_fallback(&asset3, &asset1).is_gt()); assert!(compare_asset_fallback(&asset3, &asset2).is_lt()); - assert!(compare_asset_fallback(&asset2, &asset3).is_gt()); + assert!(compare_asset_fallback(&asset3, &asset2).is_gt()); + } + + #[rstest] + fn calculate_investment_limits_for_candidates_empty_list() { + // Test with empty list of assets + let opt_assets: Vec = vec![]; + let commodity_portion = Dimensionless(1.0); + + let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion); + + assert!(result.is_empty()); + } + + #[rstest] + fn calculate_investment_limits_for_candidates_commissioned_assets_filtered( + process: Process, + region_id: RegionID, + agent_id: AgentID, + ) { + // Create a mix of commissioned and candidate assets + let process_rc = Rc::new(process); + let capacity = Capacity(10.0); + + // Create commissioned asset - should be filtered out + let commissioned_asset = Asset::new_commissioned( + agent_id.clone(), + process_rc.clone(), + region_id.clone(), + capacity, + 2015, + ) + .unwrap(); + + // Create candidate asset - should be included + let candidate_asset = + Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2015).unwrap(); + + let candidate_asset_ref = AssetRef::from(candidate_asset); + let opt_assets = vec![ + AssetRef::from(commissioned_asset), + candidate_asset_ref.clone(), + ]; + let commodity_portion = Dimensionless(1.0); + + let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion); + + // Only the candidate asset should be in the result + assert_eq!(result.len(), 1); + assert!(result.contains_key(&candidate_asset_ref)); + } + + #[rstest] + fn calculate_investment_limits_for_candidates_no_investment_constraints( + process: Process, + region_id: RegionID, + ) { + // Create candidate asset without investment constraints + let process_rc = Rc::new(process); + let capacity = Capacity(15.0); + + let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap(); + + let opt_assets = vec![AssetRef::from(candidate_asset.clone())]; + let commodity_portion = Dimensionless(0.8); + + let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion); + + // Should return the asset's original capacity since no constraints apply + assert_eq!(result.len(), 1); + let asset_ref = AssetRef::from(candidate_asset); + assert_eq!(result[&asset_ref], AssetCapacity::Continuous(capacity)); + } + + #[rstest] + // Asset capacity higher than constraint -> limited by constraint + #[case(Capacity(15.0), Capacity(10.0))] + // Asset capacity lower than constraint -> limited by asset capacity + #[case(Capacity(5.0), Capacity(5.0))] + fn calculate_investment_limits_for_candidates_with_constraints( + region_id: RegionID, + process_activity_limits_map: ProcessActivityLimitsMap, + process_flows_map: ProcessFlowsMap, + process_parameter_map: ProcessParameterMap, + #[case] asset_capacity: Capacity, + #[case] expected_limit: Capacity, + ) { + let region_ids: IndexSet = [region_id.clone()].into(); + + // Add investment constraint with addition limit + let constraint = ProcessInvestmentConstraint { + addition_limit: Some(Capacity(10.0)), + }; + let mut constraints = ProcessInvestmentConstraintsMap::new(); + constraints.insert((region_id.clone(), 2015), Rc::new(constraint)); + + let process = Process { + id: "constrained_process".into(), + description: "Process with constraints".into(), + 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, + }; + + let process_rc = Rc::new(process); + + let candidate_asset = + Asset::new_candidate(process_rc, region_id, asset_capacity, 2015).unwrap(); + + let opt_assets = vec![AssetRef::from(candidate_asset.clone())]; + let commodity_portion = Dimensionless(1.0); + + let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion); + + // Should be limited by the minimum of asset capacity and constraint + assert_eq!(result.len(), 1); + let asset_ref = AssetRef::from(candidate_asset); + assert_eq!( + result[&asset_ref], + AssetCapacity::Continuous(expected_limit) + ); + } + + #[rstest] + fn calculate_investment_limits_for_candidates_multiple_assets( + region_id: RegionID, + process_activity_limits_map: ProcessActivityLimitsMap, + process_flows_map: ProcessFlowsMap, + process_parameter_map: ProcessParameterMap, + ) { + let region_ids: IndexSet = [region_id.clone()].into(); + + // Create first process with constraints + let constraint1 = ProcessInvestmentConstraint { + addition_limit: Some(Capacity(12.0)), + }; + let mut constraints1 = ProcessInvestmentConstraintsMap::new(); + constraints1.insert((region_id.clone(), 2015), Rc::new(constraint1)); + + let process1 = Process { + id: "process1".into(), + description: "First process".into(), + years: 2010..=2020, + activity_limits: process_activity_limits_map.clone(), + flows: process_flows_map.clone(), + parameters: process_parameter_map.clone(), + regions: region_ids.clone(), + primary_output: None, + capacity_to_activity: ActivityPerCapacity(1.0), + investment_constraints: constraints1, + unit_size: None, + }; + + // Create second process without constraints + let process2 = Process { + id: "process2".into(), + description: "Second process".into(), + 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: process_investment_constraints(), + unit_size: None, + }; + + let process1_rc = Rc::new(process1); + let process2_rc = Rc::new(process2); + + let candidate1 = + Asset::new_candidate(process1_rc, region_id.clone(), Capacity(20.0), 2015).unwrap(); + + let candidate2 = Asset::new_candidate(process2_rc, region_id, Capacity(8.0), 2015).unwrap(); + + let opt_assets = vec![ + AssetRef::from(candidate1.clone()), + AssetRef::from(candidate2.clone()), + ]; + let commodity_portion = Dimensionless(0.75); + + let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion); + + // Should have both assets in result + assert_eq!(result.len(), 2); + + // First asset should be limited by constraint: 12.0 * 0.75 = 9.0 + let asset1_ref = AssetRef::from(candidate1); + assert_eq!( + result[&asset1_ref], + AssetCapacity::Continuous(Capacity(9.0)) + ); + + // Second asset should use its original capacity (no constraints) + let asset2_ref = AssetRef::from(candidate2); + assert_eq!( + result[&asset2_ref], + AssetCapacity::Continuous(Capacity(8.0)) + ); + } + + #[rstest] + fn calculate_investment_limits_for_candidates_discrete_capacity( + region_id: RegionID, + process_activity_limits_map: crate::process::ProcessActivityLimitsMap, + process_flows_map: crate::process::ProcessFlowsMap, + process_parameter_map: crate::process::ProcessParameterMap, + ) { + let region_ids: IndexSet = [region_id.clone()].into(); + + // Add investment constraint + let constraint = ProcessInvestmentConstraint { + addition_limit: Some(Capacity(35.0)), // Enough for 3.5 units at 10.0 each + }; + let mut constraints = ProcessInvestmentConstraintsMap::new(); + constraints.insert((region_id.clone(), 2015), Rc::new(constraint)); + + let process = Process { + id: "discrete_process".into(), + description: "Process with discrete units".into(), + 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: Some(Capacity(10.0)), // Discrete units of 10.0 capacity each + }; + + let process_rc = Rc::new(process); + let capacity = Capacity(50.0); // 5 units at 10.0 each + + let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap(); + + let opt_assets = vec![AssetRef::from(candidate_asset.clone())]; + let commodity_portion = Dimensionless(1.0); + + let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion); + + // Should be limited by constraint and rounded down to whole units + // Constraint: 35.0, divided by unit size 10.0 = 3.5 -> floor to 3 units = 30.0 + assert_eq!(result.len(), 1); + let asset_ref = AssetRef::from(candidate_asset); + assert_eq!( + result[&asset_ref], + AssetCapacity::Discrete(3, Capacity(10.0)) + ); + assert_eq!(result[&asset_ref].total_capacity(), Capacity(30.0)); } } From 8694f0679a6ce2fc91509ee1ef268a0b572d3166 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Wed, 11 Feb 2026 09:55:05 +0000 Subject: [PATCH 17/22] AGENTS.md: Add instructions for tests --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 700357b46..082a66acb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,6 @@ For Rust code: - Prefer `use` imports to fully qualified paths - Prefer named format arguments (e.g. `"{x}"`) over positional formatting (e.g. `"{}", x`) +- Test function names should not be prefixed with `test_` +- Prefer using parameterised tests (using `rstest`) over separate ones where testing similar + functionality From e3d890b323218f35c4364016270091851a8915f6 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Wed, 11 Feb 2026 11:18:40 +0000 Subject: [PATCH 18/22] Asset::max_installable_capacity: Check `commodity_portion` is in range --- src/asset.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/asset.rs b/src/asset.rs index 3bf1afb27..ccfabfceb 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1079,6 +1079,10 @@ impl Asset { !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 From 52c77296002b491f600bf405856013bfd71fc081 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Wed, 11 Feb 2026 12:55:37 +0000 Subject: [PATCH 19/22] Remove now-stale note from schema --- schemas/input/process_investment_constraints.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/schemas/input/process_investment_constraints.yaml b/schemas/input/process_investment_constraints.yaml index cbc27c68e..d583f1e7e 100644 --- a/schemas/input/process_investment_constraints.yaml +++ b/schemas/input/process_investment_constraints.yaml @@ -2,9 +2,6 @@ $schema: https://specs.frictionlessdata.io/schemas/table-schema.json description: | Constraints on the amount agents can invest in processes. -notes: | - Not implemented yet! This file is reserved for future use. - fields: - name: process_id type: string From 4d1132c07d191dbc2ea9d310023adacc92c7c66e Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Wed, 11 Feb 2026 12:57:01 +0000 Subject: [PATCH 20/22] schemas/input/process_investment_constraints.yaml: Fixes and formatting --- schemas/input/process_investment_constraints.yaml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/schemas/input/process_investment_constraints.yaml b/schemas/input/process_investment_constraints.yaml index d583f1e7e..c65b9f8b2 100644 --- a/schemas/input/process_investment_constraints.yaml +++ b/schemas/input/process_investment_constraints.yaml @@ -12,15 +12,17 @@ fields: notes: | One or more region IDs, separated by semicolons or the string `all`. Must be regions in which the process operates. - - name: commission years + - name: commission_years type: string description: The milestone year(s) to which this entry applies - notes: One or more milestone years separated by semicolons, `all` to select all years or a year - range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + notes: | + One or more milestone years separated by semicolons, `all` to select all years or a year range + in the form 'start..end' to select all valid years within range, inclusive. Either 'start' 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum valid year, respectively. Must be within the process's year range. - - name: addition limit + - name: addition_limit type: number description: Yearly constraint on the amount agents can invest in the process - notes: The addition limit is allocated evenly between all agents using their proportion - of the processes primary commodity demand. + notes: | + The addition limit is allocated evenly between all agents using their proportion of the + process's primary commodity demand. From 765293208aece9f5f96aba088dc7dd3875a8920c Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Tue, 10 Feb 2026 16:30:16 +0000 Subject: [PATCH 21/22] Add `CapacityPerYear` unit type --- src/units.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/units.rs b/src/units.rs index 4a152c74b..437db54c7 100644 --- a/src/units.rs +++ b/src/units.rs @@ -255,6 +255,7 @@ unit_struct!(MoneyPerActivity); unit_struct!(ActivityPerCapacity); unit_struct!(FlowPerActivity); unit_struct!(FlowPerCapacity); +unit_struct!(CapacityPerYear); macro_rules! impl_div { ($Lhs:ident, $Rhs:ident, $Out:ident) => { @@ -309,3 +310,4 @@ impl_div!(MoneyPerYear, Capacity, MoneyPerCapacityPerYear); impl_div!(MoneyPerActivity, FlowPerActivity, MoneyPerFlow); impl_div!(MoneyPerCapacity, Year, MoneyPerCapacityPerYear); impl_div!(FlowPerCapacity, ActivityPerCapacity, FlowPerActivity); +impl_div!(Capacity, Year, CapacityPerYear); From aa045cd36b2779e89fe356baaf9be0f8dc410b59 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Tue, 10 Feb 2026 16:41:16 +0000 Subject: [PATCH 22/22] Use `CapacityPerYear` type to represent unscaled addition limits --- src/input/process/investment_constraints.rs | 33 +++++++++++---------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index ca5490b38..0854a3db1 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -5,7 +5,7 @@ use crate::process::{ ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap, }; use crate::region::parse_region_str; -use crate::units::{Capacity, Dimensionless}; +use crate::units::{CapacityPerYear, Year}; use crate::year::parse_year_str; use anyhow::{Context, Result, ensure}; use itertools::iproduct; @@ -22,7 +22,7 @@ struct ProcessInvestmentConstraintRaw { process_id: String, regions: String, commission_years: String, - addition_limit: Capacity, + addition_limit: CapacityPerYear, } impl ProcessInvestmentConstraintRaw { @@ -30,7 +30,7 @@ impl ProcessInvestmentConstraintRaw { fn validate(&self) -> Result<()> { // Validate that value is finite ensure!( - self.addition_limit.is_finite() && self.addition_limit >= Capacity(0.0), + self.addition_limit.is_finite() && self.addition_limit >= CapacityPerYear(0.0), "Invalid value for addition constraint: '{}'; must be non-negative and finite.", self.addition_limit ); @@ -136,7 +136,7 @@ where let years_since_prev = year - prev_year; // Multiply the addition limit by the number of years since previous milestone. - let scaled_limit = record.addition_limit * Dimensionless(years_since_prev as f64); + let scaled_limit = record.addition_limit * Year(years_since_prev as f64); let constraint = Rc::new(ProcessInvestmentConstraint { addition_limit: Some(scaled_limit), @@ -153,9 +153,10 @@ mod tests { use super::*; use crate::fixture::{assert_error, processes}; use crate::region::RegionID; + use crate::units::Capacity; use rstest::rstest; - fn validate_raw_constraint(addition_limit: Capacity) -> Result<()> { + fn validate_raw_constraint(addition_limit: CapacityPerYear) -> Result<()> { let constraint = ProcessInvestmentConstraintRaw { process_id: "test_process".into(), regions: "ALL".into(), @@ -174,7 +175,7 @@ mod tests { process_id: "process1".into(), regions: "GBR".into(), commission_years: "ALL".into(), // Should apply to milestone years [2012, 2016] - addition_limit: Capacity(100.0), + addition_limit: CapacityPerYear(100.0), }]; let result = read_process_investment_constraints_from_iter( @@ -220,19 +221,19 @@ mod tests { process_id: "process1".into(), regions: "GBR".into(), commission_years: "2010".into(), - addition_limit: Capacity(100.0), + addition_limit: CapacityPerYear(100.0), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "ALL".into(), commission_years: "2015".into(), - addition_limit: Capacity(200.0), + addition_limit: CapacityPerYear(200.0), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "USA".into(), commission_years: "2020".into(), - addition_limit: Capacity(50.0), + addition_limit: CapacityPerYear(50.0), }, ]; @@ -291,7 +292,7 @@ mod tests { process_id: "process1".into(), regions: "ALL".into(), commission_years: "ALL".into(), - addition_limit: Capacity(75.0), + addition_limit: CapacityPerYear(75.0), }]; // Read constraints into the map @@ -339,7 +340,7 @@ mod tests { process_id: "process1".into(), regions: "GBR".into(), commission_years: "2025".into(), // Outside milestone years (2010-2020) - addition_limit: Capacity(100.0), + addition_limit: CapacityPerYear(100.0), }]; // Should fail with milestone year validation error @@ -357,15 +358,15 @@ mod tests { #[test] fn validate_addition_with_finite_value() { // Valid: addition constraint with positive value - let valid = validate_raw_constraint(Capacity(10.0)); + let valid = validate_raw_constraint(CapacityPerYear(10.0)); valid.unwrap(); // Valid: addition constraint with zero value - let valid = validate_raw_constraint(Capacity(0.0)); + let valid = validate_raw_constraint(CapacityPerYear(0.0)); valid.unwrap(); // Not valid: addition constraint with negative value - let invalid = validate_raw_constraint(Capacity(-10.0)); + let invalid = validate_raw_constraint(CapacityPerYear(-10.0)); assert_error!( invalid, "Invalid value for addition constraint: '-10'; must be non-negative and finite." @@ -375,14 +376,14 @@ mod tests { #[test] fn validate_addition_rejects_infinite() { // Invalid: infinite value - let invalid = validate_raw_constraint(Capacity(f64::INFINITY)); + let invalid = validate_raw_constraint(CapacityPerYear(f64::INFINITY)); assert_error!( invalid, "Invalid value for addition constraint: 'inf'; must be non-negative and finite." ); // Invalid: NaN value - let invalid = validate_raw_constraint(Capacity(f64::NAN)); + let invalid = validate_raw_constraint(CapacityPerYear(f64::NAN)); assert_error!( invalid, "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."