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 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 diff --git a/schemas/input/process_investment_constraints.yaml b/schemas/input/process_investment_constraints.yaml index cbc27c68e..c65b9f8b2 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 @@ -15,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. diff --git a/src/asset.rs b/src/asset.rs index c9f08b358..0efd02488 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -207,6 +207,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 { @@ -1074,6 +1090,34 @@ impl Asset { AssetCapacity::Continuous(_) => None, } } + + /// For non-commissioned assets, get the maximum capacity permitted to be installed based on the + /// investment constraints for the asset's process. + /// + /// The limit is taken from the process's investment constraints for the asset's region and + /// commission year, and the portion of the commodity demand being considered. + /// + /// 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, + commodity_portion: Dimensionless, + ) -> Option { + assert!( + !self.is_commissioned(), + "max_installable_capacity can only be called on uncommissioned assets" + ); + assert!( + commodity_portion >= Dimensionless(0.0) && commodity_portion <= Dimensionless(1.0), + "commodity_portion must be between 0 and 1 inclusive" + ); + + self.process + .investment_constraints + .get(&(self.region_id.clone(), self.commission_year)) + .and_then(|c| c.get_addition_limit().map(|l| l * commodity_portion)) + .map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size())) + } } #[allow(clippy::missing_fields_in_debug)] @@ -1548,6 +1592,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)] @@ -2384,4 +2449,25 @@ mod tests { "Agent A0_GEX has asset with commission year 2060, not within process GASDRV commission years: 2020..=2050" ); } + + #[rstest] + fn max_installable_capacity(mut process: Process, region_id: RegionID) { + // Set an addition limit of 3 for (region, year 2015) + process.investment_constraints.insert( + (region_id.clone(), 2015), + 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(); + + // 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 aaacf07bf..0854a3db1 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::{CapacityPerYear, Year}; 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: CapacityPerYear, } 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 >= CapacityPerYear(0.0), "Invalid value for addition constraint: '{}'; must be non-negative and finite.", self.addition_limit ); @@ -118,11 +119,29 @@ 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).expect( + "Year should be in milestone_years since it was validated by parse_year_str", + ); + 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 * Year(years_since_prev as f64); + + let constraint = Rc::new(ProcessInvestmentConstraint { + addition_limit: Some(scaled_limit), + }); + try_insert(process_map, &(region.clone(), year), constraint.clone())?; } } @@ -134,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: f64) -> Result<()> { + fn validate_raw_constraint(addition_limit: CapacityPerYear) -> Result<()> { let constraint = ProcessInvestmentConstraintRaw { process_id: "test_process".into(), regions: "ALL".into(), @@ -155,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: 100.0, + addition_limit: CapacityPerYear(100.0), }]; let result = read_process_investment_constraints_from_iter( @@ -201,19 +221,19 @@ mod tests { process_id: "process1".into(), regions: "GBR".into(), commission_years: "2010".into(), - addition_limit: 100.0, + addition_limit: CapacityPerYear(100.0), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "ALL".into(), commission_years: "2015".into(), - addition_limit: 200.0, + addition_limit: CapacityPerYear(200.0), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "USA".into(), commission_years: "2020".into(), - addition_limit: 50.0, + addition_limit: CapacityPerYear(50.0), }, ]; @@ -234,32 +254,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(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(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(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(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] @@ -272,7 +292,7 @@ mod tests { process_id: "process1".into(), regions: "ALL".into(), commission_years: "ALL".into(), - addition_limit: 75.0, + addition_limit: CapacityPerYear(75.0), }]; // Read constraints into the map @@ -292,21 +312,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(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(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] @@ -319,7 +340,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: CapacityPerYear(100.0), }]; // Should fail with milestone year validation error @@ -337,15 +358,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(CapacityPerYear(10.0)); valid.unwrap(); // Valid: addition constraint with zero value - let valid = validate_raw_constraint(0.0); + let valid = validate_raw_constraint(CapacityPerYear(0.0)); valid.unwrap(); // Not valid: addition constraint with negative value - let invalid = validate_raw_constraint(-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." @@ -355,14 +376,14 @@ mod tests { #[test] fn validate_addition_rejects_infinite() { // Invalid: infinite value - let invalid = validate_raw_constraint(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(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." diff --git a/src/process.rs b/src/process.rs index f5bf6fcfc..0de0bd4ec 100644 --- a/src/process.rs +++ b/src/process.rs @@ -497,12 +497,25 @@ 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 - pub addition_limit: Option, + /// 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, +} + +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. + 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 62de10e15..a1d6e6ecc 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -291,12 +291,17 @@ 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); // Choose assets from among existing pool and candidates let best_assets = select_best_assets( model, opt_assets, + investment_limits, commodity, agent, region_id, @@ -377,11 +382,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(); + 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, ) @@ -669,11 +691,40 @@ 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 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, +) -> HashMap { + // Calculate limits for each candidate asset + opt_assets + .iter() + .filter(|asset| !asset.is_commissioned()) + .map(|asset| { + // Start off with the demand-limiting capacity (pre-calculated when creating candidate) + let mut cap = asset.capacity(); + + // Cap by the addition limits of the process, if specified + if let Some(limit_capacity) = asset.max_installable_capacity(commodity_portion) { + 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( model: &Model, mut opt_assets: Vec, + investment_limits: HashMap, commodity: &Commodity, agent: &Agent, region_id: &RegionID, @@ -683,25 +734,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); - let mut remaining_candidate_capacity = HashMap::from_iter( - opt_assets - .iter() - .filter(|asset| !asset.is_commissioned()) - .map(|asset| (asset.clone(), asset.capacity())), - ); - + // 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 @@ -711,13 +759,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 @@ -775,7 +824,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, @@ -854,13 +903,19 @@ 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, + 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::{Capacity, Flow, FlowPerActivity, MoneyPerFlow}; - use indexmap::indexmap; + use crate::units::Dimensionless; + use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow}; + use indexmap::{IndexSet, indexmap}; use rstest::rstest; use std::rc::Rc; @@ -945,4 +1000,260 @@ mod tests { // Maximum = 20.0 assert_eq!(result, Capacity(20.0)); } + + #[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)); + } } 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) } }; 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);