From cd4d887c3b9cfbac31c26e15b4e44694d50b574f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 12:25:15 +0100 Subject: [PATCH 01/10] First working version with new approach --- examples/simple/processes.csv | 14 +- schemas/input/model.yaml | 7 - src/input/process.rs | 23 +- src/model/parameters.rs | 12 +- src/output.rs | 2 +- src/process.rs | 14 +- src/simulation/investment.rs | 203 ++++-------------- src/simulation/investment/appraisal.rs | 31 +-- .../investment/appraisal/constraints.rs | 5 +- .../investment/appraisal/optimisation.rs | 6 +- 10 files changed, 83 insertions(+), 234 deletions(-) diff --git a/examples/simple/processes.csv b/examples/simple/processes.csv index 59ea10bf6..767399fc7 100644 --- a/examples/simple/processes.csv +++ b/examples/simple/processes.csv @@ -1,7 +1,7 @@ -id,description,regions,primary_output,start_year,end_year,capacity_to_activity,unit_size -GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0, -GASPRC,Gas processing,all,GASNAT,2020,2040,1.0, -WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54, -GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54, -RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0, -RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0, +id,description,regions,primary_output,start_year,end_year,capacity_to_activity,capacity_granularity,is_divisible +GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false +GASPRC,Gas processing,all,GASNAT,2020,2040,1.0,100,false +WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54,0.1,false +GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54,0.1,false +RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,100,false +RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0,100,false diff --git a/schemas/input/model.yaml b/schemas/input/model.yaml index 1380ed978..7758e7c4d 100644 --- a/schemas/input/model.yaml +++ b/schemas/input/model.yaml @@ -15,13 +15,6 @@ properties: default: 0.0001 notes: | The default value should work. Do not change this value unless you know what you're doing! - capacity_limit_factor: - type: number - description: A factor which constrains the maximum capacity given to candidate assets - default: 0.1 - notes: | - This is the proportion of the maximum required capacity across time slices (for a given - asset/commodity etc. combination). value_of_lost_load: type: number description: The cost applied to unmet demand diff --git a/src/input/process.rs b/src/input/process.rs index 2f47168d4..3cc269f77 100644 --- a/src/input/process.rs +++ b/src/input/process.rs @@ -37,7 +37,8 @@ struct ProcessRaw { start_year: Option, end_year: Option, capacity_to_activity: Option, - unit_size: Option, + capacity_granularity: Capacity, + is_divisible: bool, } define_id_getter! {ProcessRaw, ProcessID} @@ -146,14 +147,12 @@ where .capacity_to_activity .unwrap_or(ActivityPerCapacity(1.0)); - // Validate unit_size - if process_raw.unit_size.is_some() { - ensure!( - process_raw.unit_size > Some(Capacity(0.0)), - "Error in process {}: unit_size must be > 0 or None", - process_raw.id - ); - } + // Validate capacity_granularity + ensure!( + process_raw.capacity_granularity > Capacity(0.0), + "Error in process {}: capacity_granularity must be > 0", + process_raw.id + ); // Validate capacity_to_activity ensure!( @@ -173,7 +172,11 @@ where primary_output, capacity_to_activity, investment_constraints: ProcessInvestmentConstraintsMap::new(), - unit_size: process_raw.unit_size, + unit_size: process_raw + .is_divisible + .then(|| process_raw.capacity_granularity), + capacity_granularity: process_raw.capacity_granularity, + is_divisible: process_raw.is_divisible, }; ensure!( diff --git a/src/model/parameters.rs b/src/model/parameters.rs index bd0dd3c9b..373e1f158 100644 --- a/src/model/parameters.rs +++ b/src/model/parameters.rs @@ -4,9 +4,7 @@ //! `model.toml` configuration used by the model. Validation functions ensure sensible numeric //! ranges and invariants for runtime use. use crate::asset::check_capacity_valid_for_asset; -use crate::input::{ - deserialise_proportion_nonzero, input_err_msg, is_sorted_and_unique, read_toml, -}; +use crate::input::{input_err_msg, is_sorted_and_unique, read_toml}; use crate::units::{Capacity, Dimensionless, Flow, MoneyPerFlow}; use anyhow::{Context, Result, ensure}; use itertools::Itertools; @@ -77,11 +75,6 @@ pub struct ModelParameters { /// /// Don't change unless you know what you're doing. pub candidate_asset_capacity: Capacity, - /// Affects the maximum capacity that can be given to a newly created asset. - /// - /// It is the proportion of maximum capacity that could be required across time slices. - #[serde(deserialize_with = "deserialise_proportion_nonzero")] - pub capacity_limit_factor: Dimensionless, /// The cost applied to unmet demand. /// /// Currently this only applies to the LCOX appraisal. @@ -118,7 +111,6 @@ impl Default for ModelParameters { // Default values for optional parameters allow_dangerous_options: false, candidate_asset_capacity: Capacity(1e-4), - capacity_limit_factor: Dimensionless(0.1), value_of_lost_load: MoneyPerFlow(1e9), max_ironing_out_iterations: 1, price_tolerance: Dimensionless(1e-6), @@ -328,8 +320,6 @@ impl ModelParameters { // milestone_years check_milestone_years(&self.milestone_years)?; - // capacity_limit_factor already validated with deserialise_proportion_nonzero - // candidate_asset_capacity check_capacity_valid_for_asset(self.candidate_asset_capacity) .context("Invalid value for candidate_asset_capacity")?; diff --git a/src/output.rs b/src/output.rs index 794559b40..30e420356 100644 --- a/src/output.rs +++ b/src/output.rs @@ -486,7 +486,7 @@ impl DebugDataWriter { asset_id: result.asset.id(), process_id: result.asset.process_id().clone(), region_id: result.asset.region_id().clone(), - capacity: result.capacity.total_capacity(), + capacity: result.asset.capacity().total_capacity(), metric: result.metric.as_ref().map(|m| m.value()), }; self.appraisal_results_writer.serialize(row)?; diff --git a/src/process.rs b/src/process.rs index fa83424bf..647ff6108 100644 --- a/src/process.rs +++ b/src/process.rs @@ -63,11 +63,17 @@ pub struct Process { pub capacity_to_activity: ActivityPerCapacity, /// Investment constraints for this process pub investment_constraints: ProcessInvestmentConstraintsMap, - /// Capacity of the units in which an asset for this process will be divided into when commissioned, if any. + /// The amount of capacity appraised and commissioned at a time. + pub capacity_granularity: Capacity, + /// Whether or not assets are divisible. /// - /// By default, an asset will not be divided when commissioned (`unit_size` will be None), but - /// if this is set, then it will be divided in as many assets as needed to commission the total - /// capacity, each having a `unit_size` capacity or a fraction of it. + /// This controls whether this process is commissioned as a set of individual units of size + /// `capacity_granularity` (true) or as a single indivisible unit (false). + pub is_divisible: bool, + /// Capacity of the units making up divisible assets, if applicable. + /// + /// For divisible processes, this is set to `capacity_granularity`. For indivisible processes, + /// this is set to None. pub unit_size: Option, } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 248d7ba27..298f7ec07 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -7,15 +7,14 @@ use crate::model::Model; use crate::output::DataWriter; use crate::region::RegionID; use crate::simulation::prices::Prices; -use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel}; -use crate::units::{ActivityPerCapacity, Capacity, Dimensionless, Flow, FlowPerCapacity}; +use crate::time_slice::{TimeSliceID, TimeSliceInfo}; +use crate::units::{Capacity, Dimensionless, Flow}; use anyhow::{Context, Result, bail, ensure}; use indexmap::IndexMap; use itertools::{Itertools, chain}; use log::debug; use std::collections::{HashMap, HashSet}; use std::fmt::Display; -use strum::IntoEnumIterator; pub mod appraisal; use appraisal::coefficients::calculate_coefficients_for_assets; @@ -284,16 +283,8 @@ fn select_assets_for_single_market( ); // Existing and candidate assets from which to choose - let opt_assets = get_asset_options( - &model.time_slice_info, - existing_assets, - &demand_portion_for_market, - agent, - commodity, - region_id, - year, - ) - .collect::>(); + let opt_assets = get_asset_options(existing_assets, agent, commodity, region_id, year) + .collect::>(); // Calculate investment limits for candidate assets let investment_limits = @@ -571,94 +562,9 @@ where }) } -/// Returns the minimum installed capacity required for `asset` to satisfy the demand that it can -/// potentially serve, accounting for its activity constraints. -/// -/// The returned value is the maximum capacity requirement implied by any time-slice selection, -/// since constraints at coarser aggregation levels (e.g. seasonal or annual limits) can require -/// more capacity than constraints at the finest time-slice level. -/// -/// Demand is evaluated using the commodity's balance level. Demand within a balance bucket is -/// treated as fungible: if the asset is capable of operating in any constituent time slice of a -/// bucket, then all demand in that bucket is considered serviceable by the asset. -/// -/// Selections whose maximum supply is zero are ignored. Such selections would otherwise imply an -/// infinite capacity requirement and therefore provide no useful lower bound. -fn get_demand_limiting_capacity( - time_slice_info: &TimeSliceInfo, - asset: &Asset, - commodity: &Commodity, - demand: &DemandMap, -) -> Capacity { - let coeff = asset.get_flow(&commodity.id).unwrap().coeff; - let mut capacity = Capacity(0.0); - let mut demand_cache: HashMap<_, Flow> = HashMap::new(); - - // Calculate demand-limiting capacity at each timeslice level and take the max. - for level in TimeSliceLevel::iter() { - for selection in time_slice_info.iter_selections_at_level(level) { - // Maximum supply within this selection according to the asset's activity limits. - let max_supply_for_selection = *asset - .get_activity_per_capacity_limits_for_selection(&selection) - .end() - * coeff; - - // Selections with zero supply would imply infinite demand-limiting capacity, - // so they do not contribute to the maximum. - if max_supply_for_selection == FlowPerCapacity(0.0) { - continue; - } - - // Serviceable demand within this selection. - // - // Demand is effectively grouped into balance buckets at the commodity's - // balance level. A balance bucket contributes if: - // 1. The bucket is contained within this selection, and - // 2. The asset can operate in at least one constituent timeslice - // within that bucket. - // - // Demand within a balance bucket is fungible, so if the asset can serve - // any timeslice in the bucket, all demand in that bucket is considered - // serviceable. - let demand_selection_level = level.max(commodity.time_slice_level); - let demand_selection = selection - .containing_selection_at_level(demand_selection_level) - .unwrap(); - let serviceable_demand_for_selection = *demand_cache - .entry(demand_selection.clone()) - .or_insert_with(|| { - demand_selection - .iter_at_level(time_slice_info, commodity.time_slice_level) - .unwrap() - .filter(|(bucket, _)| { - bucket.iter(time_slice_info).any(|(ts, _)| { - *asset.get_activity_per_capacity_limits(ts).end() - > ActivityPerCapacity(0.0) - }) - }) - .map(|(bucket, _)| { - bucket - .iter(time_slice_info) - .map(|(ts, _)| demand[ts]) - .sum::() - }) - .sum() - }); - - // Calculate demand-limiting capacity for this selection and take the - // maximum across all selections. - capacity = capacity.max(serviceable_demand_for_selection / max_supply_for_selection); - } - } - - capacity -} - /// Get options from existing and potential assets for the given parameters fn get_asset_options<'a>( - time_slice_info: &'a TimeSliceInfo, all_existing_assets: &'a [AssetRef], - demand: &'a DemandMap, agent: &'a Agent, commodity: &'a Commodity, region_id: &'a RegionID, @@ -673,16 +579,13 @@ fn get_asset_options<'a>( .cloned(); // Get candidates assets which produce the commodity of interest - let candidate_assets = - get_candidate_assets(time_slice_info, demand, agent, region_id, commodity, year); + let candidate_assets = get_candidate_assets(agent, region_id, commodity, year); chain(existing_assets, candidate_assets) } /// Get candidate assets which produce a particular commodity for a given agent fn get_candidate_assets<'a>( - time_slice_info: &'a TimeSliceInfo, - demand: &'a DemandMap, agent: &'a Agent, region_id: &'a RegionID, commodity: &'a Commodity, @@ -691,15 +594,13 @@ fn get_candidate_assets<'a>( agent .iter_search_space(region_id, &commodity.id, year) .map(move |process| { - let mut asset = - Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year) - .unwrap(); - - // Set capacity based on demand - // This will serve as the upper limit when appraising the asset - let capacity = get_demand_limiting_capacity(time_slice_info, &asset, commodity, demand); - let asset_capacity = AssetCapacity::from_capacity(capacity, asset.unit_size()); - asset.set_capacity(asset_capacity); + let asset = Asset::new_candidate( + process.clone(), + region_id.clone(), + process.capacity_granularity, + year, + ) + .unwrap(); asset.into() }) @@ -744,28 +645,20 @@ fn log_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). +/// Investment limits are based on any annual addition limits specified by the process (scaled +/// according to the agent's portion of the commodity demand and the number of years elapsed since +/// the previous milestone year). 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) + .filter_map(|asset| { + asset + .max_installable_capacity(commodity_portion) + .map(|limit_capacity| (asset.clone(), limit_capacity)) }) .collect() } @@ -820,28 +713,9 @@ fn select_best_assets( continue; } - // For candidates, determine the maximum capacity that can be invested in this round. - // This is whichever is the smallest of the tranche size (based on demand limiting - // capacity before investment), the remaining available capacity for the candidate and - // the demand limiting capacity recalculated based on demand unserved by the other - // selected assets. - let max_capacity = (!asset.is_commissioned()).then(|| { - let tranche_capacity = asset - .capacity() - .apply_limit_factor(model.parameters.capacity_limit_factor); - let dlc = AssetCapacity::from_capacity( - get_demand_limiting_capacity(&model.time_slice_info, asset, commodity, &demand), - asset.unit_size(), - ); - let remaining_capacity = remaining_candidate_capacity[asset]; - - tranche_capacity.min(dlc).min(remaining_capacity) - }); - let output = appraise_investment( model, asset, - max_capacity, commodity, objective_type, &coefficients[asset], @@ -894,13 +768,12 @@ fn select_best_assets( "Selected {} asset '{}' (capacity: {})", &best_output.asset.state(), &best_output.asset.process_id(), - best_output.capacity.total_capacity() + best_output.asset.capacity().total_capacity() ); // Update the assets and remaining candidate capacity update_assets( best_output.asset, - best_output.capacity, &mut opt_assets, &mut remaining_candidate_capacity, &mut best_assets, @@ -930,8 +803,7 @@ fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool /// Update capacity of chosen asset, if needed, and update both asset options and chosen assets fn update_assets( - mut best_asset: AssetRef, - capacity: AssetCapacity, + best_asset: AssetRef, opt_assets: &mut Vec, remaining_candidate_capacity: &mut HashMap, best_assets: &mut Vec, @@ -943,27 +815,30 @@ fn update_assets( best_assets.push(best_asset); } AssetState::Candidate => { - // Remove this capacity from the available remaining capacity for this asset - let remaining_capacity = remaining_candidate_capacity.get_mut(&best_asset).unwrap(); - *remaining_capacity = *remaining_capacity - capacity; - - // If there's no capacity remaining, remove the asset from the options - if remaining_capacity.total_capacity() <= Capacity(0.0) { - let old_idx = opt_assets - .iter() - .position(|asset| *asset == best_asset) - .unwrap(); - opt_assets.swap_remove(old_idx); - remaining_candidate_capacity.remove(&best_asset); + // Track remaining capacity for assets that have limits + if let Some(remaining_capacity) = remaining_candidate_capacity.get_mut(&best_asset) { + *remaining_capacity = *remaining_capacity - best_asset.capacity(); + + // If there's no capacity remaining, remove the asset from the options + if remaining_capacity.total_capacity() <= Capacity(0.0) { + let old_idx = opt_assets + .iter() + .position(|asset| *asset == best_asset) + .unwrap(); + + opt_assets.swap_remove(old_idx); + remaining_candidate_capacity.remove(&best_asset); + } } if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset) { // If the asset is already in the list of best assets, add the additional required capacity - existing_asset.make_mut().increase_capacity(capacity); + existing_asset + .make_mut() + .increase_capacity(best_asset.capacity()); } else { - // Otherwise, update the capacity of the chosen asset and add it to the list of best assets - best_asset.make_mut().set_capacity(capacity); + // Otherwise add it to the list of best assets best_assets.push(best_asset); } } diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index 30f8cd760..db2252c10 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -1,12 +1,12 @@ //! Calculation for investment tools such as Levelised Cost of X (LCOX) and Net Present Value (NPV). use super::DemandMap; use crate::agent::ObjectiveType; -use crate::asset::{Asset, AssetCapacity, AssetRef}; +use crate::asset::{Asset, AssetRef}; use crate::commodity::Commodity; use crate::finance::{ProfitabilityIndex, lcox, profitability_index}; use crate::model::Model; use crate::time_slice::TimeSliceID; -use crate::units::{Activity, Capacity, Money, MoneyPerActivity, MoneyPerCapacity}; +use crate::units::{Activity, Money, MoneyPerActivity, MoneyPerCapacity}; use anyhow::Result; use costs::annual_fixed_cost; use erased_serde::Serialize as ErasedSerialize; @@ -53,8 +53,6 @@ where pub struct AppraisalOutput { /// The asset being appraised pub asset: AssetRef, - /// The hypothetical capacity to install - pub capacity: AssetCapacity, /// Time slice level activity of the asset pub activity: IndexMap, /// The hypothetical unmet demand following investment in this asset @@ -69,14 +67,12 @@ impl AppraisalOutput { /// Create a new `AppraisalOutput` fn new( asset: AssetRef, - capacity: AssetCapacity, results: ResultsMap, metric: Option, coefficients: Rc, ) -> Self { Self { asset, - capacity, activity: results.activity, unmet_demand: results.unmet_demand, metric: metric.map(|m| Box::new(m) as Box), @@ -106,10 +102,9 @@ impl AppraisalOutput { /// Whether this [`AppraisalOutput`] is a valid output. /// - /// Specifically, it checks whether the metric is a valid value (not `None`) and that the - /// calculated capacity is greater than zero. + /// Specifically, it checks whether the metric is a valid value (not `None`). pub fn is_valid(&self) -> bool { - self.metric.is_some() && self.capacity.total_capacity() > Capacity(0.0) + self.metric.is_some() } } @@ -253,16 +248,14 @@ impl MetricTrait for NPVMetric {} fn calculate_lcox( model: &Model, asset: &AssetRef, - max_capacity: AssetCapacity, commodity: &Commodity, coefficients: &Rc, demand: &DemandMap, ) -> Result { - let results = - perform_optimisation(model, asset, max_capacity, commodity, coefficients, demand)?; + let results = perform_optimisation(model, asset, commodity, coefficients, demand)?; let cost_index = lcox( - max_capacity.total_capacity(), + asset.capacity().total_capacity(), annual_fixed_cost(asset), &results.activity, &coefficients.market_costs, @@ -270,7 +263,6 @@ fn calculate_lcox( Ok(AppraisalOutput::new( asset.clone(), - max_capacity, results, cost_index.map(LCOXMetric::new), coefficients.clone(), @@ -285,13 +277,11 @@ fn calculate_lcox( fn calculate_npv( model: &Model, asset: &AssetRef, - max_capacity: AssetCapacity, commodity: &Commodity, coefficients: &Rc, demand: &DemandMap, ) -> Result { - let results = - perform_optimisation(model, asset, max_capacity, commodity, coefficients, demand)?; + let results = perform_optimisation(model, asset, commodity, coefficients, demand)?; let annual_fixed_cost = annual_fixed_cost(asset); assert!( @@ -300,7 +290,7 @@ fn calculate_npv( ); let profitability_index = profitability_index( - max_capacity.total_capacity(), + asset.capacity().total_capacity(), annual_fixed_cost, &results.activity, &coefficients.market_costs, @@ -308,7 +298,6 @@ fn calculate_npv( Ok(AppraisalOutput::new( asset.clone(), - max_capacity, results, Some(NPVMetric::new(profitability_index)), coefficients.clone(), @@ -324,18 +313,16 @@ fn calculate_npv( pub fn appraise_investment( model: &Model, asset: &AssetRef, - max_capacity: Option, commodity: &Commodity, objective_type: &ObjectiveType, coefficients: &Rc, demand: &DemandMap, ) -> Result { - let max_capacity = max_capacity.unwrap_or(asset.capacity()); let appraisal_method = match objective_type { ObjectiveType::LevelisedCostOfX => calculate_lcox, ObjectiveType::NetPresentValue => calculate_npv, }; - appraisal_method(model, asset, max_capacity, commodity, coefficients, demand) + appraisal_method(model, asset, commodity, coefficients, demand) } /// Compare assets as a fallback if metrics are equal. diff --git a/src/simulation/investment/appraisal/constraints.rs b/src/simulation/investment/appraisal/constraints.rs index 01270ace0..89aaca886 100644 --- a/src/simulation/investment/appraisal/constraints.rs +++ b/src/simulation/investment/appraisal/constraints.rs @@ -1,7 +1,7 @@ //! Constraints for the optimisation problem. use super::DemandMap; use super::optimisation::Variable; -use crate::asset::{AssetCapacity, AssetRef}; +use crate::asset::AssetRef; use crate::commodity::Commodity; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; use crate::units::Flow; @@ -21,11 +21,10 @@ use indexmap::IndexMap; pub fn add_activity_constraints( problem: &mut Problem, asset: &AssetRef, - max_capacity: AssetCapacity, activity_vars: &IndexMap, time_slice_info: &TimeSliceInfo, ) { - let capacity = max_capacity.total_capacity(); + let capacity = asset.capacity().total_capacity(); for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() { let limits = (capacity * *limits.start()).value()..=(capacity * *limits.end()).value(); diff --git a/src/simulation/investment/appraisal/optimisation.rs b/src/simulation/investment/appraisal/optimisation.rs index 87b94afd3..cecdf4d3a 100644 --- a/src/simulation/investment/appraisal/optimisation.rs +++ b/src/simulation/investment/appraisal/optimisation.rs @@ -2,7 +2,6 @@ use super::DemandMap; use super::ObjectiveCoefficients; use super::constraints::{add_activity_constraints, add_demand_constraints}; -use crate::asset::AssetCapacity; use crate::asset::AssetRef; use crate::commodity::Commodity; use crate::model::Model; @@ -50,13 +49,12 @@ fn add_activity_vars( fn add_constraints( problem: &mut Problem, asset: &AssetRef, - max_capacity: AssetCapacity, commodity: &Commodity, activity_vars: &IndexMap, demand: &DemandMap, time_slice_info: &TimeSliceInfo, ) { - add_activity_constraints(problem, asset, max_capacity, activity_vars, time_slice_info); + add_activity_constraints(problem, asset, activity_vars, time_slice_info); add_demand_constraints( problem, asset, @@ -111,7 +109,6 @@ fn compute_unmet_demand( pub fn perform_optimisation( model: &Model, asset: &AssetRef, - max_capacity: AssetCapacity, commodity: &Commodity, coefficients: &ObjectiveCoefficients, demand: &DemandMap, @@ -124,7 +121,6 @@ pub fn perform_optimisation( add_constraints( &mut problem, asset, - max_capacity, commodity, &activity_vars, demand, From ab60a80099d50f3926da4e816fe51e6f7448bea3 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 14:27:28 +0100 Subject: [PATCH 02/10] unit_size is now a method --- src/asset.rs | 8 ++++---- src/input/asset.rs | 2 +- src/input/process.rs | 3 --- src/process.rs | 13 ++++++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 860e47779..6681c50d0 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -136,7 +136,7 @@ impl Asset { capacity: Capacity, commission_year: u32, ) -> Result { - let unit_size = process.unit_size; + let unit_size = process.unit_size(); Self::new_with_state( AssetState::Candidate, process, @@ -190,7 +190,7 @@ impl Asset { capacity: Capacity, commission_year: u32, ) -> Result { - let unit_size = process.unit_size; + let unit_size = process.unit_size(); Self::new_with_state( AssetState::Ready { agent_id, @@ -216,7 +216,7 @@ impl Asset { capacity: Capacity, commission_year: u32, ) -> Result { - let unit_size = process.unit_size; + let unit_size = process.unit_size(); Self::new_with_state( AssetState::Commissioned { id: AssetID(0), @@ -988,7 +988,7 @@ impl UserAsset { max_decommission_year: Option, ) -> Result { check_capacity_valid_for_asset(capacity)?; - let unit_size = process.unit_size; + let unit_size = process.unit_size(); let asset = Asset::new_with_state( AssetState::Ready { agent_id, diff --git a/src/input/asset.rs b/src/input/asset.rs index b43906a10..f02324ee5 100644 --- a/src/input/asset.rs +++ b/src/input/asset.rs @@ -106,7 +106,7 @@ where // Check that capacity is approximately a multiple of the process unit size // If not, raise a warning - if let Some(unit_size) = process.unit_size { + if let Some(unit_size) = process.unit_size() { let ratio = (asset.capacity / unit_size).value(); if !approx_eq!(f64, ratio, ratio.ceil()) { let n_units = ratio.ceil(); diff --git a/src/input/process.rs b/src/input/process.rs index 3cc269f77..1703be1b9 100644 --- a/src/input/process.rs +++ b/src/input/process.rs @@ -172,9 +172,6 @@ where primary_output, capacity_to_activity, investment_constraints: ProcessInvestmentConstraintsMap::new(), - unit_size: process_raw - .is_divisible - .then(|| process_raw.capacity_granularity), capacity_granularity: process_raw.capacity_granularity, is_divisible: process_raw.is_divisible, }; diff --git a/src/process.rs b/src/process.rs index 647ff6108..f27f13c01 100644 --- a/src/process.rs +++ b/src/process.rs @@ -70,11 +70,6 @@ pub struct Process { /// This controls whether this process is commissioned as a set of individual units of size /// `capacity_granularity` (true) or as a single indivisible unit (false). pub is_divisible: bool, - /// Capacity of the units making up divisible assets, if applicable. - /// - /// For divisible processes, this is set to `capacity_granularity`. For indivisible processes, - /// this is set to None. - pub unit_size: Option, } impl Process { @@ -82,6 +77,14 @@ impl Process { pub fn active_for_year(&self, year: u32) -> bool { self.years.contains(&year) } + + /// Capacity of the units making up divisible assets, if applicable. + /// + /// For divisible processes, this will be `capacity_granularity`. For indivisible processes, + /// this will be None. + pub fn unit_size(&self) -> Option { + self.is_divisible.then(|| self.capacity_granularity) + } } /// Defines the activity limits for a process in a given region and year From b1fccf0cd13ec4b9d57ba24e5229212288196ceb Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 14:50:06 +0100 Subject: [PATCH 03/10] Fix most of the tests --- src/asset.rs | 8 +- src/asset/pool.rs | 8 +- src/fixture.rs | 9 +- src/process.rs | 2 +- src/simulation/investment.rs | 433 ++----------------------- src/simulation/investment/appraisal.rs | 33 +- src/simulation/prices.rs | 3 +- 7 files changed, 44 insertions(+), 452 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 6681c50d0..614f2678a 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1404,7 +1404,8 @@ mod tests { #[case] unit_size: Capacity, #[case] n_expected_children: usize, ) { - process.unit_size = Some(unit_size); + process.capacity_granularity = unit_size; + process.is_divisible = true; let asset = AssetRef::from( Asset::new_ready( "agent1".into(), @@ -1442,10 +1443,7 @@ mod tests { #[rstest] fn into_for_each_child_nondivisible(asset: Asset) { - assert!( - asset.process.unit_size.is_none(), - "Asset should be non-divisible" - ); + assert!(!asset.process.is_divisible, "Asset should be non-divisible"); let asset = AssetRef::from(asset); let mut count = 0; diff --git a/src/asset/pool.rs b/src/asset/pool.rs index 7d5e7d654..7a8439c38 100644 --- a/src/asset/pool.rs +++ b/src/asset/pool.rs @@ -265,7 +265,7 @@ mod tests { #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] fn expected_children_for_divisible(asset: &Asset) -> usize { - (asset.total_capacity() / asset.process.unit_size.expect("Asset is not divisible")) + (asset.total_capacity() / asset.process.unit_size().expect("Asset is not divisible")) .value() .ceil() as usize } @@ -442,7 +442,8 @@ mod tests { let original_count = asset_pool.assets.len(); // Create new non-commissioned assets - process.unit_size = Some(Capacity(4.0)); + process.capacity_granularity = Capacity(4.0); + process.is_divisible = true; let process_rc = Rc::new(process); let new_assets: Vec = vec![ Asset::new_ready( @@ -474,7 +475,8 @@ mod tests { let existing_assets = asset_pool.take(); // Add one ready divisible asset so extend() commissions multiple new children - process.unit_size = Some(Capacity(4.0)); + process.capacity_granularity = Capacity(4.0); + process.is_divisible = true; let process_rc = Rc::new(process); let ready_divisible: AssetRef = Asset::new_ready( "agent_selected".into(), diff --git a/src/fixture.rs b/src/fixture.rs index e212a0c12..0e0aba1a9 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -4,7 +4,7 @@ use crate::agent::{ Agent, AgentCommodityPortionsMap, AgentID, AgentMap, AgentObjectiveMap, AgentSearchSpaceMap, DecisionRule, }; -use crate::asset::{Asset, AssetCapacity, AssetPool, AssetRef}; +use crate::asset::{Asset, AssetPool, AssetRef}; use crate::commodity::{ Commodity, CommodityID, CommodityLevyMap, CommodityType, DemandMap, PricingStrategy, }; @@ -212,7 +212,8 @@ pub fn asset(process: Process) -> Asset { #[fixture] pub fn asset_divisible(mut process: Process) -> Asset { - process.unit_size = Some(Capacity(4.0)); + process.capacity_granularity = Capacity(4.0); + process.is_divisible = true; Asset::new_ready( "agent1".into(), Rc::new(process), @@ -321,7 +322,8 @@ pub fn process( primary_output: None, capacity_to_activity: ActivityPerCapacity(1.0), investment_constraints: process_investment_constraints, - unit_size: None, + capacity_granularity: Capacity(1.0), + is_divisible: false, } } @@ -406,7 +408,6 @@ pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutpu let unmet_demand = indexmap! { time_slice.clone() => Flow(5.0) }; AppraisalOutput { asset: AssetRef::from(asset), - capacity: AssetCapacity::Continuous(Capacity(42.0)), coefficients: Rc::new(ObjectiveCoefficients { activity_coefficients, market_costs, diff --git a/src/process.rs b/src/process.rs index f27f13c01..9551f6de1 100644 --- a/src/process.rs +++ b/src/process.rs @@ -83,7 +83,7 @@ impl Process { /// For divisible processes, this will be `capacity_granularity`. For indivisible processes, /// this will be None. pub fn unit_size(&self) -> Option { - self.is_divisible.then(|| self.capacity_granularity) + self.is_divisible.then_some(self.capacity_granularity) } } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 298f7ec07..ca8587aab 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -288,7 +288,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); + collect_investment_limits_for_candidates(&opt_assets, commodity_portion); // Choose assets from among existing pool and candidates let best_assets = select_best_assets( @@ -643,12 +643,12 @@ fn log_on_equal_appraisal_outputs( } } -/// Calculate investment limits for an agent's candidate assets in a given year +/// Collect investment limits for an agent's candidate assets in a given year /// -/// Investment limits are based on any annual addition limits specified by the process (scaled +/// Investment limits are based on any annual addition limits specified by the process, scaled /// according to the agent's portion of the commodity demand and the number of years elapsed since -/// the previous milestone year). -fn calculate_investment_limits_for_candidates( +/// the previous milestone year. +fn collect_investment_limits_for_candidates( opt_assets: &[AssetRef], commodity_portion: Dimensionless, ) -> HashMap { @@ -849,421 +849,42 @@ fn update_assets( #[cfg(test)] mod tests { use super::*; - use crate::commodity::Commodity; - use crate::fixture::{ - 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::region::RegionID; - use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection}; use crate::units::Dimensionless; - use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow}; - use indexmap::{IndexSet, indexmap}; - use rstest::rstest; - use std::rc::Rc; + use rstest::{fixture, rstest}; #[rstest] - fn get_demand_limiting_capacity_works( - time_slice: TimeSliceID, - time_slice_info: TimeSliceInfo, - svd_commodity: Commodity, - mut process: Process, - ) { - // Add flows for the process using the existing commodity fixture - let commodity_rc = Rc::new(svd_commodity); - let process_flow = ProcessFlow { - commodity: Rc::clone(&commodity_rc), - coeff: FlowPerActivity(2.0), // 2 units of flow per unit of activity - kind: FlowType::Fixed, - cost: MoneyPerFlow(0.0), - }; - let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() }; - let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows)); - process.flows = process_flows_map; - - // Create asset with the configured process - let asset = asset(process); - - // Create demand map - demand of 10.0 for our time slice - let demand = indexmap! { time_slice.clone() => Flow(10.0)}; - - // Call the function - let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand); - - // Expected calculation: - // max_flow_per_cap = activity_per_capacity_limit (1.0) * coeff (2.0) = 2.0 - // required_capacity = demand (10.0) / max_flow_per_cap (2.0) = 5.0 - assert_eq!(result, Capacity(5.0)); - } - - #[rstest] - fn get_demand_limiting_capacity_multiple_time_slices( - time_slice_info2: TimeSliceInfo, - svd_commodity: Commodity, - mut process: Process, - ) { - let (time_slice1, time_slice2) = - time_slice_info2.time_slices.keys().collect_tuple().unwrap(); - - // Add flows for the process using the existing commodity fixture - let commodity_rc = Rc::new(svd_commodity); - let process_flow = ProcessFlow { - commodity: Rc::clone(&commodity_rc), - coeff: FlowPerActivity(1.0), // 1 unit of flow per unit of activity - kind: FlowType::Fixed, - cost: MoneyPerFlow(0.0), - }; - let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() }; - let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows)); - process.flows = process_flows_map; - - // Add activity limits for the process - let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info2); - limits.add_time_slice_limit(time_slice1.clone(), Dimensionless(0.0)..=Dimensionless(0.2)); - limits.add_time_slice_limit(time_slice2.clone(), Dimensionless(0.0)..=Dimensionless(0.0)); - let limits_map = process_activity_limits_map(process.regions.clone(), limits); - process.activity_limits = limits_map; - - // Create asset with the configured process - let asset = asset(process); - - // Create demand map with different demands for each time slice - let demand = indexmap! { - time_slice1.clone() => Flow(4.0), // Requires capacity of 4.0/0.2 = 20.0 - time_slice2.clone() => Flow(3.0), // Would require infinite capacity, but should be skipped - }; - - // Call the function - let result = - get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand); - - // Expected: maximum of the capacity requirements across time slices (excluding zero limit) - // Time slice 1: demand (4.0) / (activity_limit (0.2) * coeff (1.0)) = 20.0 - // Time slice 2: skipped due to zero activity limit - // Maximum = 20.0 - assert_eq!(result, Capacity(20.0)); - } - - #[rstest] - fn get_demand_limiting_capacity_uses_coarser_limits( - time_slice_info2: TimeSliceInfo, - svd_commodity: Commodity, - mut process: Process, - ) { - let (time_slice1, time_slice2) = - time_slice_info2.time_slices.keys().collect_tuple().unwrap(); - - // Configure a 1:1 activity-to-flow relationship. - let commodity_rc = Rc::new(svd_commodity); - let process_flow = ProcessFlow { - commodity: Rc::clone(&commodity_rc), - coeff: FlowPerActivity(1.0), - kind: FlowType::Fixed, - cost: MoneyPerFlow(0.0), - }; - - let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() }; - process.flows = process_flows_map(process.regions.clone(), Rc::new(process_flows)); - - // Fine-grained limits imply a capacity requirement of 5: - // TS1: 5 / 1 = 5 - // TS2: 5 / 1 = 5 - // - // The annual limit implies: - // (5 + 5) / 0.5 = 20 - // - // The function should return the larger value. - let limits = HashMap::from([ - ( - TimeSliceSelection::Single(time_slice1.clone()), - Dimensionless(0.0)..=Dimensionless(1.0), - ), - ( - TimeSliceSelection::Single(time_slice2.clone()), - Dimensionless(0.0)..=Dimensionless(1.0), - ), - ( - TimeSliceSelection::Annual, - Dimensionless(0.0)..=Dimensionless(0.5), - ), - ]); - - process.activity_limits = process_activity_limits_map( - process.regions.clone(), - ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap(), - ); - - let asset = asset(process); - - let demand = indexmap! { - time_slice1.clone() => Flow(5.0), - time_slice2.clone() => Flow(5.0), - }; - - let result = - get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand); - - 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); - + fn collect_investment_limits_for_candidates_empty_list() { + let result = collect_investment_limits_for_candidates(&[], Dimensionless(1.0)); 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)); + #[fixture] + fn commissioned_asset() -> AssetRef { + todo!("Commissioned asset"); } - #[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) - ); + #[fixture] + fn non_commissioned_asset_without_limit() -> AssetRef { + todo!("Non-commissioned asset with max_installable_capacity() -> None"); } - #[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)) - ); + #[fixture] + fn non_commissioned_asset_with_limit() -> AssetRef { + todo!("Non-commissioned asset with max_installable_capacity() -> Some(...)"); } #[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, + #[case::commissioned(commissioned_asset(), false)] + #[case::no_limit(non_commissioned_asset_without_limit(), false)] + #[case::with_limit(non_commissioned_asset_with_limit(), true)] + fn collect_investment_limits_for_candidates_filters_assets_correctly( + #[case] asset: AssetRef, + #[case] expected_in_result: bool, ) { - 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)) + let result = collect_investment_limits_for_candidates( + std::slice::from_ref(&asset), + Dimensionless(1.0), ); - assert_eq!(result[&asset_ref].total_capacity(), Capacity(30.0)); + assert_eq!(result.contains_key(&asset), expected_in_result); } } diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index db2252c10..e24fbe3e9 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -387,7 +387,7 @@ mod tests { use crate::fixture::{agent_id, asset, process, region_id}; use crate::process::Process; use crate::region::RegionID; - use crate::units::{Money, MoneyPerActivity}; + use crate::units::{Capacity, Money, MoneyPerActivity}; use float_cmp::assert_approx_eq; use rstest::rstest; use std::rc::Rc; @@ -581,7 +581,6 @@ mod tests { .zip(metrics) .map(|(asset, metric)| AppraisalOutput { asset: AssetRef::from(asset), - capacity: AssetCapacity::Continuous(Capacity(10.0)), coefficients: objective_coeffs(), activity: IndexMap::new(), unmet_demand: IndexMap::new(), @@ -896,41 +895,11 @@ mod tests { ); } - /// Test that appraisal outputs with zero capacity are filtered out during sorting. - #[rstest] - fn appraisal_sort_filters_zero_capacity_outputs(asset: Asset) { - let metric = LCOXMetric::new(MoneyPerActivity(1.0)); - let metrics = [ - Box::new(metric.clone()), - Box::new(metric.clone()), - Box::new(metric), - ]; - - // Create outputs with zero capacity - let mut outputs: Vec = metrics - .into_iter() - .map(|metric| AppraisalOutput { - asset: AssetRef::from(asset.clone()), - capacity: AssetCapacity::Continuous(Capacity(0.0)), - coefficients: objective_coeffs(), - activity: IndexMap::new(), - unmet_demand: IndexMap::new(), - metric: Some(metric), - }) - .collect(); - - sort_and_filter_appraisal_outputs(&mut outputs); - - // All zero capacity outputs should be filtered out - assert_eq!(outputs.len(), 0); - } - /// Test that appraisal outputs with an invalid metric are filtered out #[rstest] fn appraisal_sort_filters_invalid_metric(asset: Asset) { let output = AppraisalOutput { asset: AssetRef::from(asset), - capacity: AssetCapacity::Continuous(Capacity(1.0)), // non-zero capacity coefficients: objective_coeffs(), activity: IndexMap::new(), unmet_demand: IndexMap::new(), diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index 750fc30fb..7bcf1687f 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -1337,7 +1337,8 @@ mod tests { primary_output: None, capacity_to_activity: ActivityPerCapacity(1.0), investment_constraints: HashMap::new(), - unit_size: None, + capacity_granularity: Capacity(1.0), + is_divisible: false, } } From f42b7079e6c0265b45536d3a0267c425a427d467 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 14:53:43 +0100 Subject: [PATCH 04/10] Fix more tests --- src/asset.rs | 8 ++++---- src/input/process/flow.rs | 4 ++-- src/output.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 614f2678a..c9cda6519 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1540,8 +1540,8 @@ mod tests { #[test] fn commission_year_before_time_horizon() { let processes_patch = FilePatch::new("processes.csv") - .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,") - .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,"); + .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false") + .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,100,false"); // Check we can run model with asset commissioned before time horizon (simple starts in // 2020) @@ -1565,8 +1565,8 @@ mod tests { #[test] fn commission_year_after_time_horizon() { let processes_patch = FilePatch::new("processes.csv") - .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,") - .with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0,"); + .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false") + .with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0,100,false"); // Check we can run model with asset commissioned after time horizon (simple ends in 2040) let patches = vec![ diff --git a/src/input/process/flow.rs b/src/input/process/flow.rs index 66c96a494..716c653d9 100644 --- a/src/input/process/flow.rs +++ b/src/input/process/flow.rs @@ -796,8 +796,8 @@ mod tests { // non-milestone years. let patches = vec![ FilePatch::new("processes.csv") - .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,") - .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,"), + .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false") + .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,100,false"), FilePatch::new("process_flows.csv") .with_deletion("GASPRC,GASPRD,all,all,-1.05,fixed,") .with_addition("GASPRC,GASPRD,all,2020;2030;2040,-1.05,fixed,"), diff --git a/src/output.rs b/src/output.rs index 30e420356..9479e942e 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1183,7 +1183,7 @@ mod tests { asset_id: None, process_id: asset.process_id().clone(), region_id: asset.region_id().clone(), - capacity: Capacity(42.0), + capacity: Capacity(2.0), metric: Some(4.14), }; let records: Vec = From 30f3d63c297205693fb39a84628f949fb8a25014 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 15:13:50 +0100 Subject: [PATCH 05/10] Remove is_valid --- src/simulation/investment/appraisal.rs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index e24fbe3e9..86e658558 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -89,23 +89,16 @@ impl AppraisalOutput { /// possible, which is why we use a more approximate comparison. pub fn compare_metric(&self, other: &Self) -> Ordering { assert!( - self.is_valid() && other.is_valid(), + self.metric.is_some() && other.metric.is_some(), "Cannot compare non-valid outputs" ); - // We've already checked the metrics aren't `None` in `is_valid` + // We've already checked the metrics aren't `None` self.metric .as_ref() .unwrap() .compare(other.metric.as_ref().unwrap().as_ref()) } - - /// Whether this [`AppraisalOutput`] is a valid output. - /// - /// Specifically, it checks whether the metric is a valid value (not `None`). - pub fn is_valid(&self) -> bool { - self.metric.is_some() - } } /// Supertrait for appraisal metrics that can be serialised and compared. @@ -342,17 +335,15 @@ fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { /// and newer assets are preferred over older ones. The function does not guarantee that all ties /// will be resolved. /// -/// Before sorting, outputs are filtered using [`AppraisalOutput::is_valid`], which excludes entries -/// with invalid metrics (e.g. `None`) as well as zero capacity. This avoids meaningless or `NaN` -/// appraisal metrics that could cause the program to panic, so the length of the returned vector -/// may be less than the input. +/// Before sorting, outputs are filtered to exclude entries with invalid metrics (i.e. `None`), so +/// the length of the returned vector may be less than the input. /// /// # Returns /// /// Returns the number of non-feasible assets which were removed. pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec) -> usize { let old_len = outputs.len(); - outputs.retain(AppraisalOutput::is_valid); + outputs.retain(|output| output.metric.is_some()); let num_nonfeasible = old_len - outputs.len(); outputs.sort_by(|output1, output2| match output1.compare_metric(output2) { From 1254e2832ce64acbc0f59cc0de0ce3de80b20216 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 15:42:51 +0100 Subject: [PATCH 06/10] Update tests for collect_investment_limits_for_candidates --- src/simulation/investment.rs | 93 ++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index ca8587aab..a46c6f7d7 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -849,8 +849,21 @@ fn update_assets( #[cfg(test)] mod tests { use super::*; + use crate::fixture::{ + asset, process, process_activity_limits_map, process_flows_map, process_parameter_map, + region_id, + }; + use crate::process::{ + Process, ProcessActivityLimitsMap, ProcessFlowsMap, ProcessInvestmentConstraint, + ProcessInvestmentConstraintsMap, ProcessParameterMap, + }; + use crate::region::RegionID; use crate::units::Dimensionless; + use crate::units::{ActivityPerCapacity, Capacity}; + use indexmap::IndexSet; use rstest::{fixture, rstest}; + use std::rc::Rc; + use std::slice::from_ref; #[rstest] fn collect_investment_limits_for_candidates_empty_list() { @@ -859,32 +872,82 @@ mod tests { } #[fixture] - fn commissioned_asset() -> AssetRef { - todo!("Commissioned asset"); + fn commissioned_asset(asset: Asset) -> AssetRef { + asset.into() } #[fixture] - fn non_commissioned_asset_without_limit() -> AssetRef { - todo!("Non-commissioned asset with max_installable_capacity() -> None"); + fn uncommissioned_asset_without_limit(process: Process, region_id: RegionID) -> AssetRef { + Asset::new_candidate(Rc::new(process), region_id, Capacity(10.0), 2015) + .unwrap() + .into() } #[fixture] - fn non_commissioned_asset_with_limit() -> AssetRef { - todo!("Non-commissioned asset with max_installable_capacity() -> Some(...)"); + fn uncommissioned_asset_with_limit( + region_id: RegionID, + process_activity_limits_map: ProcessActivityLimitsMap, + process_flows_map: ProcessFlowsMap, + process_parameter_map: ProcessParameterMap, + ) -> AssetRef { + let region_ids: IndexSet = [region_id.clone()].into(); + + let mut constraints = ProcessInvestmentConstraintsMap::new(); + + constraints.insert( + (region_id.clone(), 2015), + Rc::new(ProcessInvestmentConstraint { + addition_limit: Some(Capacity(10.0)), + }), + ); + + let process = Process { + id: "constrained_process".into(), + description: String::new(), + years: 2010..=2020, + activity_limits: process_activity_limits_map, + flows: process_flows_map, + parameters: process_parameter_map, + regions: region_ids, + primary_output: None, + capacity_to_activity: ActivityPerCapacity(1.0), + investment_constraints: constraints, + capacity_granularity: Capacity(1.0), + is_divisible: false, + }; + + Asset::new_candidate(Rc::new(process), region_id, Capacity(15.0), 2015) + .unwrap() + .into() } #[rstest] - #[case::commissioned(commissioned_asset(), false)] - #[case::no_limit(non_commissioned_asset_without_limit(), false)] - #[case::with_limit(non_commissioned_asset_with_limit(), true)] - fn collect_investment_limits_for_candidates_filters_assets_correctly( - #[case] asset: AssetRef, - #[case] expected_in_result: bool, - ) { + fn commissioned_assets_are_excluded(commissioned_asset: AssetRef) { + let result = collect_investment_limits_for_candidates( + from_ref(&commissioned_asset), + Dimensionless(1.0), + ); + + assert!(!result.contains_key(&commissioned_asset)); + } + + #[rstest] + fn candidate_assets_without_limits_are_excluded(uncommissioned_asset_without_limit: AssetRef) { let result = collect_investment_limits_for_candidates( - std::slice::from_ref(&asset), + from_ref(&uncommissioned_asset_without_limit), Dimensionless(1.0), ); - assert_eq!(result.contains_key(&asset), expected_in_result); + + assert!(!result.contains_key(&uncommissioned_asset_without_limit)); + } + + #[rstest] + fn candidate_assets_with_limits_are_included(uncommissioned_asset_with_limit: AssetRef) { + let result = collect_investment_limits_for_candidates( + from_ref(&uncommissioned_asset_with_limit), + Dimensionless(1.0), + ); + + assert!(result.contains_key(&uncommissioned_asset_with_limit)); } } From a6775675826d9728f54a94327ed2640326af8e1d Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 16:09:05 +0100 Subject: [PATCH 07/10] Default capacity_granularity and is_divisible --- examples/simple/processes.csv | 14 +++++++------- schemas/input/processes.yaml | 2 +- src/example/patches.rs | 11 +++++++++-- src/input/process.rs | 30 +++++++++++++++++++----------- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/examples/simple/processes.csv b/examples/simple/processes.csv index 767399fc7..63f48a973 100644 --- a/examples/simple/processes.csv +++ b/examples/simple/processes.csv @@ -1,7 +1,7 @@ -id,description,regions,primary_output,start_year,end_year,capacity_to_activity,capacity_granularity,is_divisible -GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false -GASPRC,Gas processing,all,GASNAT,2020,2040,1.0,100,false -WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54,0.1,false -GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54,0.1,false -RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,100,false -RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0,100,false +id,description,regions,primary_output,start_year,end_year,capacity_to_activity +GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0 +GASPRC,Gas processing,all,GASNAT,2020,2040,1.0 +WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54 +GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54 +RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0 +RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0 diff --git a/schemas/input/processes.yaml b/schemas/input/processes.yaml index f12d1ddac..dfa1ff2dc 100644 --- a/schemas/input/processes.yaml +++ b/schemas/input/processes.yaml @@ -31,7 +31,7 @@ fields: description: Factor relating capacity units (e.g. GW) to activity units (e.g. PJ). It is the maximum activity per year for one unit of capacity. - notes: Must be >=0. Optional (defaults to 1.0). + notes: Must be >0. Optional (defaults to 1.0). - name: unit_size type: number description: diff --git a/src/example/patches.rs b/src/example/patches.rs index 712cda45b..0bb5e30e6 100644 --- a/src/example/patches.rs +++ b/src/example/patches.rs @@ -56,8 +56,15 @@ fn get_all_patches() -> PatchMap { "simple", vec![ FilePatch::new("processes.csv") - .with_deletion("RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,") - .with_addition("RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,1000"), + .with_replacement(&[ + "id,description,regions,primary_output,start_year,end_year,capacity_to_activity,is_divisible", + "GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,", + "GASPRC,Gas processing,all,GASNAT,2020,2040,1.0,", + "WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54,", + "GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54,", + "RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,true", + "RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0,", + ]), ], None, ), diff --git a/src/input/process.rs b/src/input/process.rs index 1703be1b9..693c2e240 100644 --- a/src/input/process.rs +++ b/src/input/process.rs @@ -8,7 +8,7 @@ use crate::process::{ }; use crate::region::{RegionID, parse_region_str}; use crate::time_slice::TimeSliceInfo; -use crate::units::{ActivityPerCapacity, Capacity}; +use crate::units::{Activity, ActivityPerCapacity, Capacity}; use anyhow::{Context, Ok, Result, ensure}; use indexmap::IndexSet; use log::warn; @@ -37,8 +37,8 @@ struct ProcessRaw { start_year: Option, end_year: Option, capacity_to_activity: Option, - capacity_granularity: Capacity, - is_divisible: bool, + capacity_granularity: Option, + is_divisible: Option, } define_id_getter! {ProcessRaw, ProcessID} @@ -147,17 +147,25 @@ where .capacity_to_activity .unwrap_or(ActivityPerCapacity(1.0)); - // Validate capacity_granularity + // Validate capacity_to_activity ensure!( - process_raw.capacity_granularity > Capacity(0.0), - "Error in process {}: capacity_granularity must be > 0", + capacity_to_activity > ActivityPerCapacity(0.0), + "Error in process {}: capacity_to_activity must be > 0", process_raw.id ); - // Validate capacity_to_activity + // is_divisible defaults to false if not specified + let is_divisible = process_raw.is_divisible.unwrap_or(false); + + // Capacity granularity defaults to 10 / capacity_to_activity if not specified + let capacity_granularity = process_raw + .capacity_granularity + .unwrap_or_else(|| Activity(10.0) / capacity_to_activity); + + // Validate capacity_granularity ensure!( - capacity_to_activity >= ActivityPerCapacity(0.0), - "Error in process {}: capacity_to_activity must be >= 0", + capacity_granularity > Capacity(0.0), + "Error in process {}: capacity_granularity must be > 0", process_raw.id ); @@ -172,8 +180,8 @@ where primary_output, capacity_to_activity, investment_constraints: ProcessInvestmentConstraintsMap::new(), - capacity_granularity: process_raw.capacity_granularity, - is_divisible: process_raw.is_divisible, + capacity_granularity, + is_divisible, }; ensure!( From 6a6a9cb442cd5fa9651fd22037c01a64e31a75c6 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 16:15:39 +0100 Subject: [PATCH 08/10] Documentation --- schemas/input/processes.yaml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/schemas/input/processes.yaml b/schemas/input/processes.yaml index dfa1ff2dc..d3f06cbc3 100644 --- a/schemas/input/processes.yaml +++ b/schemas/input/processes.yaml @@ -32,17 +32,18 @@ fields: Factor relating capacity units (e.g. GW) to activity units (e.g. PJ). It is the maximum activity per year for one unit of capacity. notes: Must be >0. Optional (defaults to 1.0). - - name: unit_size + - name: capacity_granularity type: number description: - Capacity of the units in which an asset for this process will be divided into when - commissioned, if any. - notes: - If present, must be >0. Optional (defaults to None). Assets with a defined unit size are - divided into n = ceil(C / U) equal units, where C is overall capacity and U is unit_size - (i.e. rounding up the number of units, which may result in a total capacity greater than C, if - C is not an exact multiple of U). - - It should be noted that making this number too small with respect to the typical size of an - asset might create hundreds or thousands of children assets, with a very negative effect on - the performance. Users are advised to use this feature with care. + The minimum capacity increment that can be invested in for this process. Reducing this value + allows for more precise capacities, but can slow down the simulation. + notes: Must be >0. Optional (defaults to 10 / capacity_to_activity). + - name: is_divisible + type: boolean + description: + Whether the process is commissioned as a set of individual units of size + `capacity_granularity` (true) or as a single indivisible unit (false). If true, assets are + divided into n = ceil(C / G) equal units, where C is overall capacity and G is + `capacity_granularity` (i.e. rounding up the number of units, which may result in a total + capacity greater than C, if C is not an exact multiple of G). + notes: Optional (defaults to false). From 23d307fbd03d235c7202404ba062a0f0588d436f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 16:22:16 +0100 Subject: [PATCH 09/10] Fix patches, improve docs --- examples/simple/processes.csv | 14 +++++++------- schemas/input/processes.yaml | 3 ++- src/example/patches.rs | 11 ++--------- src/input/process/flow.rs | 4 ++-- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/examples/simple/processes.csv b/examples/simple/processes.csv index 63f48a973..887eb951b 100644 --- a/examples/simple/processes.csv +++ b/examples/simple/processes.csv @@ -1,7 +1,7 @@ -id,description,regions,primary_output,start_year,end_year,capacity_to_activity -GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0 -GASPRC,Gas processing,all,GASNAT,2020,2040,1.0 -WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54 -GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54 -RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0 -RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0 +id,description,regions,primary_output,start_year,end_year,capacity_to_activity,is_divisible +GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,false +GASPRC,Gas processing,all,GASNAT,2020,2040,1.0,false +WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54,false +GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54,false +RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,false +RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0,false diff --git a/schemas/input/processes.yaml b/schemas/input/processes.yaml index d3f06cbc3..8ef0ce06f 100644 --- a/schemas/input/processes.yaml +++ b/schemas/input/processes.yaml @@ -42,7 +42,8 @@ fields: type: boolean description: Whether the process is commissioned as a set of individual units of size - `capacity_granularity` (true) or as a single indivisible unit (false). If true, assets are + `capacity_granularity` (true), which can be decommissioned individually, or as a single + indivisible unit (false) which must be decommissioned in one go. If true, assets are divided into n = ceil(C / G) equal units, where C is overall capacity and G is `capacity_granularity` (i.e. rounding up the number of units, which may result in a total capacity greater than C, if C is not an exact multiple of G). diff --git a/src/example/patches.rs b/src/example/patches.rs index 0bb5e30e6..5f0db797b 100644 --- a/src/example/patches.rs +++ b/src/example/patches.rs @@ -56,15 +56,8 @@ fn get_all_patches() -> PatchMap { "simple", vec![ FilePatch::new("processes.csv") - .with_replacement(&[ - "id,description,regions,primary_output,start_year,end_year,capacity_to_activity,is_divisible", - "GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,", - "GASPRC,Gas processing,all,GASNAT,2020,2040,1.0,", - "WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54,", - "GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54,", - "RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,true", - "RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0,", - ]), + .with_deletion("RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,false") + .with_addition("RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,true"), ], None, ), diff --git a/src/input/process/flow.rs b/src/input/process/flow.rs index 716c653d9..66c96a494 100644 --- a/src/input/process/flow.rs +++ b/src/input/process/flow.rs @@ -796,8 +796,8 @@ mod tests { // non-milestone years. let patches = vec![ FilePatch::new("processes.csv") - .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false") - .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,100,false"), + .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,") + .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,"), FilePatch::new("process_flows.csv") .with_deletion("GASPRC,GASPRD,all,all,-1.05,fixed,") .with_addition("GASPRC,GASPRD,all,2020;2030;2040,-1.05,fixed,"), From c7fb67c92c5d8bab34d9b8d6ea0fe21b2b1744f2 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 18 Jun 2026 16:31:04 +0100 Subject: [PATCH 10/10] Fix patch tests --- src/asset.rs | 8 ++++---- src/input/process/flow.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index c9cda6519..915aaab88 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1540,8 +1540,8 @@ mod tests { #[test] fn commission_year_before_time_horizon() { let processes_patch = FilePatch::new("processes.csv") - .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false") - .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,100,false"); + .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,false") + .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,false"); // Check we can run model with asset commissioned before time horizon (simple starts in // 2020) @@ -1565,8 +1565,8 @@ mod tests { #[test] fn commission_year_after_time_horizon() { let processes_patch = FilePatch::new("processes.csv") - .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,100,false") - .with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0,100,false"); + .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,false") + .with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0,false"); // Check we can run model with asset commissioned after time horizon (simple ends in 2040) let patches = vec![ diff --git a/src/input/process/flow.rs b/src/input/process/flow.rs index 66c96a494..d9bbe091d 100644 --- a/src/input/process/flow.rs +++ b/src/input/process/flow.rs @@ -796,8 +796,8 @@ mod tests { // non-milestone years. let patches = vec![ FilePatch::new("processes.csv") - .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,") - .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,"), + .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,false") + .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,false"), FilePatch::new("process_flows.csv") .with_deletion("GASPRC,GASPRD,all,all,-1.05,fixed,") .with_addition("GASPRC,GASPRD,all,2020;2030;2040,-1.05,fixed,"),