diff --git a/examples/muse1_default/process_investment_constraints.csv b/examples/muse1_default/process_investment_constraints.csv new file mode 100644 index 00000000..c8f9aeca --- /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 00000000..09b9404e --- /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 diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index aaacf07b..7d461d7d 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 f12986e1..1a22663f 100644 --- a/src/process.rs +++ b/src/process.rs @@ -502,7 +502,18 @@ 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 + } } #[cfg(test)] diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index d7a92792..9f81e3c8 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -668,6 +668,63 @@ fn warn_on_equal_appraisal_outputs( } } +/// 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 calculate_investment_limits_for_candidates( + opt_assets: &[AssetRef], + agent_portion: Dimensionless, + 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 + 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(); + + // 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_addition_limit() + .map(|l| l * years_elapsed * 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 +744,16 @@ 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 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) { @@ -710,13 +770,14 @@ 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 + 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 @@ -788,7 +849,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,