From 334b8d215db959fc1204b5f74684741277c00038 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Mon, 13 Jul 2026 17:11:11 +0100 Subject: [PATCH 1/6] Add field to `ProcessInvestmentConstraintRaw` for total capacity constraint --- src/input/process/investment_constraints.rs | 86 ++++++++++++++++++--- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index 4dec59c9f..a135bc8b5 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -7,10 +7,10 @@ use crate::process::{ ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap, }; use crate::region::parse_region_str; -use crate::units::{CapacityPerYear, Year}; +use crate::units::{Capacity, CapacityPerYear, Year}; use anyhow::{Context, Result, ensure}; use itertools::iproduct; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::path::Path; use std::rc::Rc; @@ -24,20 +24,48 @@ struct ProcessInvestmentConstraintRaw { regions: String, commission_years: String, addition_limit: CapacityPerYear, + #[serde( + default, + deserialize_with = "ProcessInvestmentConstraintRaw::parse_empty_limit" + )] + total_capacity_limit: Option, } impl ProcessInvestmentConstraintRaw { /// Validate the constraint record for logical consistency and required fields fn validate(&self) -> Result<()> { - // Validate that value is finite + // Validate that addition_limit is finite ensure!( 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 ); + // Validate total_capacity_limit: must be in range [0, inf) + if let Some(limit) = self.total_capacity_limit { + ensure!( + limit.is_finite() && limit >= Capacity(0.0), + "Invalid value for total capacity constraint: '{limit}'; must be non-negative and finite.", + ); + } + Ok(()) } + + // Custom deserializer for if a limit is empty (implies no limit) + fn parse_empty_limit<'de, D>(d: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let limit = Option::::deserialize(d)?; + match limit { + None => Ok(None), + Some(s) => s + .parse::() + .map(Some) + .map_err(serde::de::Error::custom), + } + } } /// Read the process investment constraints CSV file. @@ -155,12 +183,16 @@ mod tests { use crate::units::Capacity; use rstest::rstest; - fn validate_raw_constraint(addition_limit: CapacityPerYear) -> Result<()> { + fn validate_raw_constraint( + addition_limit: CapacityPerYear, + total_capacity_limit: Option, + ) -> Result<()> { let constraint = ProcessInvestmentConstraintRaw { process_id: "test_process".into(), regions: "ALL".into(), commission_years: "2030".into(), addition_limit, + total_capacity_limit, }; constraint.validate() } @@ -175,6 +207,7 @@ mod tests { regions: "GBR".into(), commission_years: "ALL".into(), // Should apply to milestone years [2012, 2016] addition_limit: CapacityPerYear(100.0), + total_capacity_limit: Some(Capacity(100.0)), }]; let result = read_process_investment_constraints_from_iter( @@ -221,18 +254,21 @@ mod tests { regions: "GBR".into(), commission_years: "2010".into(), addition_limit: CapacityPerYear(100.0), + total_capacity_limit: Some(Capacity(100.0)), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "ALL".into(), commission_years: "2015".into(), addition_limit: CapacityPerYear(200.0), + total_capacity_limit: Some(Capacity(100.0)), }, ProcessInvestmentConstraintRaw { process_id: "process1".into(), regions: "USA".into(), commission_years: "2020".into(), addition_limit: CapacityPerYear(50.0), + total_capacity_limit: Some(Capacity(100.0)), }, ]; @@ -292,6 +328,7 @@ mod tests { regions: "ALL".into(), commission_years: "ALL".into(), addition_limit: CapacityPerYear(75.0), + total_capacity_limit: Some(Capacity(100.0)), }]; // Read constraints into the map @@ -340,6 +377,7 @@ mod tests { regions: "GBR".into(), commission_years: "2025".into(), // Outside milestone years (2010-2020) addition_limit: CapacityPerYear(100.0), + total_capacity_limit: Some(Capacity(100.0)), }]; // Should fail with milestone year validation error @@ -357,15 +395,15 @@ mod tests { #[test] fn validate_addition_with_finite_value() { // Valid: addition constraint with positive value - let valid = validate_raw_constraint(CapacityPerYear(10.0)); + let valid = validate_raw_constraint(CapacityPerYear(10.0), None); valid.unwrap(); // Valid: addition constraint with zero value - let valid = validate_raw_constraint(CapacityPerYear(0.0)); + let valid = validate_raw_constraint(CapacityPerYear(0.0), None); valid.unwrap(); // Not valid: addition constraint with negative value - let invalid = validate_raw_constraint(CapacityPerYear(-10.0)); + let invalid = validate_raw_constraint(CapacityPerYear(-10.0), None); assert_error!( invalid, "Invalid value for addition constraint: '-10'; must be non-negative and finite." @@ -375,17 +413,47 @@ mod tests { #[test] fn validate_addition_rejects_infinite() { // Invalid: infinite value - let invalid = validate_raw_constraint(CapacityPerYear(f64::INFINITY)); + let invalid = validate_raw_constraint(CapacityPerYear(f64::INFINITY), None); assert_error!( invalid, "Invalid value for addition constraint: 'inf'; must be non-negative and finite." ); // Invalid: NaN value - let invalid = validate_raw_constraint(CapacityPerYear(f64::NAN)); + let invalid = validate_raw_constraint(CapacityPerYear(f64::NAN), None); assert_error!( invalid, "Invalid value for addition constraint: 'NaN'; must be non-negative and finite." ); } + + #[test] + fn validate_total_capacity_limit() { + // Valid: total_capacity_limit with values in range [0, inf) + let valid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(0.0))); + valid.unwrap(); + let valid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(123.45e6))); + valid.unwrap(); + + // Valid: total_capacity_limit as None + let valid = validate_raw_constraint(CapacityPerYear(10.0), None); + valid.unwrap(); + + // Not valid: total_capacity_limit with values outside range [0, inf) + let invalid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(-7.89))); + assert_error!( + invalid, + "Invalid value for total capacity constraint: '-7.89'; must be non-negative and finite." + ); + let invalid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(f64::INFINITY))); + assert_error!( + invalid, + "Invalid value for total capacity constraint: 'inf'; must be non-negative and finite." + ); + let invalid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(f64::NAN))); + assert_error!( + invalid, + "Invalid value for total capacity constraint: 'NaN'; must be non-negative and finite." + ); + } } From a2399604ee3fe0767aaf412306cf1079b63c756b Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Tue, 14 Jul 2026 12:58:39 +0100 Subject: [PATCH 2/6] Add total capacity limits for muse1_default example --- .../muse1_default/process_investment_constraints.csv | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/muse1_default/process_investment_constraints.csv b/examples/muse1_default/process_investment_constraints.csv index c8f9aeca3..e31e912b6 100644 --- a/examples/muse1_default/process_investment_constraints.csv +++ b/examples/muse1_default/process_investment_constraints.csv @@ -1,6 +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 +process_id,regions,commission_years,addition_limit,total_capacity_limit +gassupply1,R1,all,10,100 +gasCCGT,R1,all,10,100 +windturbine,R1,all,10,100 +gasboiler,R1,all,10,100 +heatpump,R1,all,10,100 From 0eebbb233da5fc8bb766ae44cc3785d820841598 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Tue, 14 Jul 2026 14:45:18 +0100 Subject: [PATCH 3/6] Use total capacity limit and current capacity in addition limit calculation --- src/asset.rs | 8 ++++++-- src/input/process/investment_constraints.rs | 1 + src/process.rs | 6 +++++- src/simulation/market.rs | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index f591a7968..161a10a96 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -934,7 +934,10 @@ impl Asset { self.process .investment_constraints .get(&(self.region_id.clone(), self.commission_year)) - .and_then(|c| c.get_addition_limit().map(|l| l * commodity_portion)) + .and_then(|c| { + c.get_addition_limit(self.capacity()) + .map(|l| l * commodity_portion) + }) .map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size())) } } @@ -1590,11 +1593,12 @@ mod tests { #[rstest] fn max_installable_capacity(mut process: Process, region_id: RegionID) { - // Set an addition limit of 3 for (region, year 2015) + // Set an addition limit of 3 and total capacity limit of 100 for (region, year 2015) process.investment_constraints.insert( (region_id.clone(), 2015), Rc::new(crate::process::ProcessInvestmentConstraint { addition_limit: Some(Capacity(3.0)), + total_capacity_limit: Some(Capacity(100.0)), }), ); let process_rc = Rc::new(process); diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index a135bc8b5..24e307afc 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -167,6 +167,7 @@ where let constraint = Rc::new(ProcessInvestmentConstraint { addition_limit: Some(scaled_limit), + total_capacity_limit: record.total_capacity_limit, }); try_insert(process_map, &(region.clone(), year), constraint.clone())?; diff --git a/src/process.rs b/src/process.rs index 9495e50ff..f73bafe95 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,5 +1,6 @@ //! Processes are used for converting between different commodities. The data structures in this //! module are used to represent these conversions along with the associated costs. +use crate::asset::AssetCapacity; use crate::commodity::{Commodity, CommodityID}; use crate::id::define_id_type; use crate::region::RegionID; @@ -505,6 +506,8 @@ pub struct ProcessInvestmentConstraint { /// Addition constraint: Limit an agent can invest in the process, shared according to the /// agent's proportion of the process's primary commodity demand pub addition_limit: Option, + /// Total capacity constraint: Limit the total capacity an agent can invest + pub total_capacity_limit: Option, } impl ProcessInvestmentConstraint { @@ -513,7 +516,8 @@ impl ProcessInvestmentConstraint { /// 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 { + #[allow(unused_variables)] + pub fn get_addition_limit(&self, current_capacity: AssetCapacity) -> Option { self.addition_limit } } diff --git a/src/simulation/market.rs b/src/simulation/market.rs index 806a273f5..7f1e6e6cc 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -530,6 +530,7 @@ mod tests { (region_id.clone(), 2015), Rc::new(ProcessInvestmentConstraint { addition_limit: Some(Capacity(10.0)), + total_capacity_limit: Some(Capacity(100.0)), }), ); From b2e0ab274c77dd57176ad3dc93f6b115cb8533c1 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 15 Jul 2026 10:54:00 +0100 Subject: [PATCH 4/6] Remove superfluous custom deserialiser --- src/input/process/investment_constraints.rs | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index 24e307afc..20d4e1ff6 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -10,7 +10,7 @@ use crate::region::parse_region_str; use crate::units::{Capacity, CapacityPerYear, Year}; use anyhow::{Context, Result, ensure}; use itertools::iproduct; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use std::collections::HashMap; use std::path::Path; use std::rc::Rc; @@ -24,10 +24,6 @@ struct ProcessInvestmentConstraintRaw { regions: String, commission_years: String, addition_limit: CapacityPerYear, - #[serde( - default, - deserialize_with = "ProcessInvestmentConstraintRaw::parse_empty_limit" - )] total_capacity_limit: Option, } @@ -51,21 +47,6 @@ impl ProcessInvestmentConstraintRaw { Ok(()) } - - // Custom deserializer for if a limit is empty (implies no limit) - fn parse_empty_limit<'de, D>(d: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let limit = Option::::deserialize(d)?; - match limit { - None => Ok(None), - Some(s) => s - .parse::() - .map(Some) - .map_err(serde::de::Error::custom), - } - } } /// Read the process investment constraints CSV file. From 43a875332f8992e0cbd9e5b230d3009e4bf3af35 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 15 Jul 2026 12:43:34 +0100 Subject: [PATCH 5/6] Parameterise capacity constraint validation tests --- src/input/process/investment_constraints.rs | 118 ++++++++++---------- 1 file changed, 61 insertions(+), 57 deletions(-) diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index 20d4e1ff6..a19c73e7e 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -374,68 +374,72 @@ mod tests { ); } - #[test] - fn validate_addition_with_finite_value() { - // Valid: addition constraint with positive value - let valid = validate_raw_constraint(CapacityPerYear(10.0), None); - valid.unwrap(); - - // Valid: addition constraint with zero value - let valid = validate_raw_constraint(CapacityPerYear(0.0), None); + #[rstest] + #[case(CapacityPerYear(10.0), None)] + #[case(CapacityPerYear(0.0), None)] + #[case(CapacityPerYear(10.0), Some(Capacity(100.0)))] + #[case(CapacityPerYear(10.0), Some(Capacity(0.0)))] + fn validate_constraints_valid( + #[case] addition_limit: CapacityPerYear, + #[case] total_capacity_limit: Option, + ) { + // Valid: capacity constraints with values >= 0, and total_capacity_limit as None + let valid = validate_raw_constraint(addition_limit, total_capacity_limit); valid.unwrap(); - - // Not valid: addition constraint with negative value - let invalid = validate_raw_constraint(CapacityPerYear(-10.0), None); - assert_error!( - invalid, - "Invalid value for addition constraint: '-10'; must be non-negative and finite." - ); } - #[test] - fn validate_addition_rejects_infinite() { - // Invalid: infinite value - let invalid = validate_raw_constraint(CapacityPerYear(f64::INFINITY), None); - assert_error!( - invalid, - "Invalid value for addition constraint: 'inf'; must be non-negative and finite." - ); - - // Invalid: NaN value - let invalid = validate_raw_constraint(CapacityPerYear(f64::NAN), None); - assert_error!( - invalid, - "Invalid value for addition constraint: 'NaN'; must be non-negative and finite." - ); + #[rstest] + #[case(CapacityPerYear(-10.0), None, "Invalid value for addition constraint: '-10'; must be non-negative and finite.")] + #[case(CapacityPerYear(10.0), Some(Capacity(-100.0)), "Invalid value for total capacity constraint: '-100'; must be non-negative and finite.")] + fn validate_constraints_rejects_negative( + #[case] addition_limit: CapacityPerYear, + #[case] total_capacity_limit: Option, + #[case] error_msg: &str, + ) { + // Not valid: capacity constraints with negative value + let invalid = validate_raw_constraint(addition_limit, total_capacity_limit); + assert_error!(invalid, error_msg); } - #[test] - fn validate_total_capacity_limit() { - // Valid: total_capacity_limit with values in range [0, inf) - let valid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(0.0))); - valid.unwrap(); - let valid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(123.45e6))); - valid.unwrap(); - - // Valid: total_capacity_limit as None - let valid = validate_raw_constraint(CapacityPerYear(10.0), None); - valid.unwrap(); + #[rstest] + #[case( + CapacityPerYear(f64::INFINITY), + None, + "Invalid value for addition constraint: 'inf'; must be non-negative and finite." + )] + #[case( + CapacityPerYear(10.0), + Some(Capacity(f64::INFINITY)), + "Invalid value for total capacity constraint: 'inf'; must be non-negative and finite." + )] + fn validate_constraints_rejects_infinite( + #[case] addition_limit: CapacityPerYear, + #[case] total_capacity_limit: Option, + #[case] error_msg: &str, + ) { + // Not valid: capacity constraints with infinite value + let invalid = validate_raw_constraint(addition_limit, total_capacity_limit); + assert_error!(invalid, error_msg); + } - // Not valid: total_capacity_limit with values outside range [0, inf) - let invalid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(-7.89))); - assert_error!( - invalid, - "Invalid value for total capacity constraint: '-7.89'; must be non-negative and finite." - ); - let invalid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(f64::INFINITY))); - assert_error!( - invalid, - "Invalid value for total capacity constraint: 'inf'; must be non-negative and finite." - ); - let invalid = validate_raw_constraint(CapacityPerYear(10.0), Some(Capacity(f64::NAN))); - assert_error!( - invalid, - "Invalid value for total capacity constraint: 'NaN'; must be non-negative and finite." - ); + #[rstest] + #[case( + CapacityPerYear(f64::NAN), + None, + "Invalid value for addition constraint: 'NaN'; must be non-negative and finite." + )] + #[case( + CapacityPerYear(10.0), + Some(Capacity(f64::NAN)), + "Invalid value for total capacity constraint: 'NaN'; must be non-negative and finite." + )] + fn validate_constraints_rejects_nan( + #[case] addition_limit: CapacityPerYear, + #[case] total_capacity_limit: Option, + #[case] error_msg: &str, + ) { + // Not valid: capacity constraints with NaN value + let invalid = validate_raw_constraint(addition_limit, total_capacity_limit); + assert_error!(invalid, error_msg); } } From f5775776c88039eedbaaeacd76697ed328cd2150 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 15 Jul 2026 12:47:23 +0100 Subject: [PATCH 6/6] Revert "Use total capacity limit and current capacity in addition limit calculation" This reverts commit 0eebbb233da5fc8bb766ae44cc3785d820841598. Defer to future, when all capacity constraints are implemented --- src/asset.rs | 8 ++------ src/input/process/investment_constraints.rs | 1 - src/process.rs | 6 +----- src/simulation/market.rs | 1 - 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 161a10a96..f591a7968 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -934,10 +934,7 @@ impl Asset { self.process .investment_constraints .get(&(self.region_id.clone(), self.commission_year)) - .and_then(|c| { - c.get_addition_limit(self.capacity()) - .map(|l| l * commodity_portion) - }) + .and_then(|c| c.get_addition_limit().map(|l| l * commodity_portion)) .map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size())) } } @@ -1593,12 +1590,11 @@ mod tests { #[rstest] fn max_installable_capacity(mut process: Process, region_id: RegionID) { - // Set an addition limit of 3 and total capacity limit of 100 for (region, year 2015) + // 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)), - total_capacity_limit: Some(Capacity(100.0)), }), ); let process_rc = Rc::new(process); diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index a19c73e7e..d9275f655 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -148,7 +148,6 @@ where let constraint = Rc::new(ProcessInvestmentConstraint { addition_limit: Some(scaled_limit), - total_capacity_limit: record.total_capacity_limit, }); try_insert(process_map, &(region.clone(), year), constraint.clone())?; diff --git a/src/process.rs b/src/process.rs index f73bafe95..9495e50ff 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,6 +1,5 @@ //! Processes are used for converting between different commodities. The data structures in this //! module are used to represent these conversions along with the associated costs. -use crate::asset::AssetCapacity; use crate::commodity::{Commodity, CommodityID}; use crate::id::define_id_type; use crate::region::RegionID; @@ -506,8 +505,6 @@ pub struct ProcessInvestmentConstraint { /// Addition constraint: Limit an agent can invest in the process, shared according to the /// agent's proportion of the process's primary commodity demand pub addition_limit: Option, - /// Total capacity constraint: Limit the total capacity an agent can invest - pub total_capacity_limit: Option, } impl ProcessInvestmentConstraint { @@ -516,8 +513,7 @@ impl ProcessInvestmentConstraint { /// 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. - #[allow(unused_variables)] - pub fn get_addition_limit(&self, current_capacity: AssetCapacity) -> Option { + pub fn get_addition_limit(&self) -> Option { self.addition_limit } } diff --git a/src/simulation/market.rs b/src/simulation/market.rs index 7f1e6e6cc..806a273f5 100644 --- a/src/simulation/market.rs +++ b/src/simulation/market.rs @@ -530,7 +530,6 @@ mod tests { (region_id.clone(), 2015), Rc::new(ProcessInvestmentConstraint { addition_limit: Some(Capacity(10.0)), - total_capacity_limit: Some(Capacity(100.0)), }), );