From edbe5ffa0b1eae1f72e429fe283721d6a4bb285f Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Tue, 14 Jul 2026 16:38:59 +0100 Subject: [PATCH 1/4] Add field to `ProcessInvestmentConstraintRaw` for capacity growth constraint --- src/input/process/investment_constraints.rs | 68 +++++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index d9275f655..95c085392 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -7,7 +7,7 @@ use crate::process::{ ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap, }; use crate::region::parse_region_str; -use crate::units::{Capacity, CapacityPerYear, Year}; +use crate::units::{Capacity, CapacityPerYear, Dimensionless, Year}; use anyhow::{Context, Result, ensure}; use itertools::iproduct; use serde::Deserialize; @@ -24,6 +24,7 @@ struct ProcessInvestmentConstraintRaw { regions: String, commission_years: String, addition_limit: CapacityPerYear, + capacity_growth_limit: Option, total_capacity_limit: Option, } @@ -37,6 +38,14 @@ impl ProcessInvestmentConstraintRaw { self.addition_limit ); + // Validate capacity_growth_limit: must be in range [0, inf) + if let Some(limit) = self.capacity_growth_limit { + ensure!( + limit.is_finite() && limit >= Dimensionless(0.0), + "Invalid value for capacity growth constraint: '{limit}'; must be non-negative and finite.", + ); + } + // Validate total_capacity_limit: must be in range [0, inf) if let Some(limit) = self.total_capacity_limit { ensure!( @@ -166,6 +175,7 @@ mod tests { fn validate_raw_constraint( addition_limit: CapacityPerYear, + capacity_growth_limit: Option, total_capacity_limit: Option, ) -> Result<()> { let constraint = ProcessInvestmentConstraintRaw { @@ -173,6 +183,7 @@ mod tests { regions: "ALL".into(), commission_years: "2030".into(), addition_limit, + capacity_growth_limit, total_capacity_limit, }; constraint.validate() @@ -188,6 +199,7 @@ mod tests { regions: "GBR".into(), commission_years: "ALL".into(), // Should apply to milestone years [2012, 2016] addition_limit: CapacityPerYear(100.0), + capacity_growth_limit: Some(Dimensionless(0.5)), total_capacity_limit: Some(Capacity(100.0)), }]; @@ -235,6 +247,7 @@ mod tests { regions: "GBR".into(), commission_years: "2010".into(), addition_limit: CapacityPerYear(100.0), + capacity_growth_limit: Some(Dimensionless(0.5)), total_capacity_limit: Some(Capacity(100.0)), }, ProcessInvestmentConstraintRaw { @@ -242,6 +255,7 @@ mod tests { regions: "ALL".into(), commission_years: "2015".into(), addition_limit: CapacityPerYear(200.0), + capacity_growth_limit: Some(Dimensionless(0.5)), total_capacity_limit: Some(Capacity(100.0)), }, ProcessInvestmentConstraintRaw { @@ -249,6 +263,7 @@ mod tests { regions: "USA".into(), commission_years: "2020".into(), addition_limit: CapacityPerYear(50.0), + capacity_growth_limit: Some(Dimensionless(0.5)), total_capacity_limit: Some(Capacity(100.0)), }, ]; @@ -309,6 +324,7 @@ mod tests { regions: "ALL".into(), commission_years: "ALL".into(), addition_limit: CapacityPerYear(75.0), + capacity_growth_limit: Some(Dimensionless(0.5)), total_capacity_limit: Some(Capacity(100.0)), }]; @@ -358,6 +374,7 @@ mod tests { regions: "GBR".into(), commission_years: "2025".into(), // Outside milestone years (2010-2020) addition_limit: CapacityPerYear(100.0), + capacity_growth_limit: Some(Dimensionless(0.5)), total_capacity_limit: Some(Capacity(100.0)), }]; @@ -374,29 +391,36 @@ mod tests { } #[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)))] + #[case(CapacityPerYear(10.0), None, None)] + #[case(CapacityPerYear(0.0), None, None)] + #[case(CapacityPerYear(10.0), Some(Dimensionless(0.5)), None)] + #[case(CapacityPerYear(10.0), Some(Dimensionless(0.0)), None)] + #[case(CapacityPerYear(10.0), None, Some(Capacity(100.0)))] + #[case(CapacityPerYear(10.0), None, Some(Capacity(0.0)))] fn validate_constraints_valid( #[case] addition_limit: CapacityPerYear, + #[case] capacity_growth_limit: Option, #[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: capacity constraints with values >= 0, and capacity_growth_limit and total_capacity_limit as None + let valid = + validate_raw_constraint(addition_limit, capacity_growth_limit, total_capacity_limit); valid.unwrap(); } #[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.")] + #[case(CapacityPerYear(-10.0), None, None, "Invalid value for addition constraint: '-10'; must be non-negative and finite.")] + #[case(CapacityPerYear(10.0), Some(Dimensionless(-0.5)), None, "Invalid value for capacity growth constraint: '-0.5'; must be non-negative and finite.")] + #[case(CapacityPerYear(10.0), None, 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] capacity_growth_limit: Option, #[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); + let invalid = + validate_raw_constraint(addition_limit, capacity_growth_limit, total_capacity_limit); assert_error!(invalid, error_msg); } @@ -404,20 +428,30 @@ mod tests { #[case( CapacityPerYear(f64::INFINITY), None, + None, "Invalid value for addition constraint: 'inf'; must be non-negative and finite." )] #[case( CapacityPerYear(10.0), + Some(Dimensionless(f64::INFINITY)), + None, + "Invalid value for capacity growth constraint: 'inf'; must be non-negative and finite." + )] + #[case( + CapacityPerYear(10.0), + None, 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] capacity_growth_limit: Option, #[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); + let invalid = + validate_raw_constraint(addition_limit, capacity_growth_limit, total_capacity_limit); assert_error!(invalid, error_msg); } @@ -425,20 +459,30 @@ mod tests { #[case( CapacityPerYear(f64::NAN), None, + None, "Invalid value for addition constraint: 'NaN'; must be non-negative and finite." )] #[case( CapacityPerYear(10.0), + Some(Dimensionless(f64::NAN)), + None, + "Invalid value for capacity growth constraint: 'NaN'; must be non-negative and finite." + )] + #[case( + CapacityPerYear(10.0), + None, 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] capacity_growth_limit: Option, #[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); + let invalid = + validate_raw_constraint(addition_limit, capacity_growth_limit, total_capacity_limit); assert_error!(invalid, error_msg); } } From e8df356a8309d9900a225744cf7602d6ee1de831 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 15 Jul 2026 16:26:49 +0100 Subject: [PATCH 2/4] Add capacity growth 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 e31e912b6..b6fe7e85c 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,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 +process_id,regions,commission_years,addition_limit,capacity_growth_limit,total_capacity_limit +gassupply1,R1,all,10,0.5,100 +gasCCGT,R1,all,10,0.5,100 +windturbine,R1,all,10,0.5,100 +gasboiler,R1,all,10,0.5,100 +heatpump,R1,all,10,0.5,100 From 4c34dad5c59dfd34896be57580babddf682e7d27 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 15 Jul 2026 16:27:10 +0100 Subject: [PATCH 3/4] Log a warning if capacity growth limit fractions above 1 provided --- Cargo.lock | 20 +++++++++++++++++ Cargo.toml | 1 + src/input/process/investment_constraints.rs | 24 ++++++++++++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 82ba6cf7e..3b158d002 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,6 +877,19 @@ name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "value-bag", +] + +[[package]] +name = "logtest" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e43a8657c1d64516dcc9db8ca03826a4aceaf89d5ce1b37b59f6ff0e43026" +dependencies = [ + "lazy_static", + "log", +] [[package]] name = "map-macro" @@ -930,6 +943,7 @@ dependencies = [ "indexmap", "itertools 0.15.0", "log", + "logtest", "map-macro", "ordered-float", "petgraph", @@ -1607,6 +1621,12 @@ dependencies = [ "getrandom 0.4.2", ] +[[package]] +name = "value-bag" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index ec6c75eea..d844d5ef2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ documented = "0.9.2" dirs = "6.0.0" edit = "0.1.5" erased-serde = "0.4.10" +logtest = "2.0.0" [dev-dependencies] assert_cmd = "2.2.2" diff --git a/src/input/process/investment_constraints.rs b/src/input/process/investment_constraints.rs index 95c085392..06d95608a 100644 --- a/src/input/process/investment_constraints.rs +++ b/src/input/process/investment_constraints.rs @@ -10,6 +10,7 @@ use crate::region::parse_region_str; use crate::units::{Capacity, CapacityPerYear, Dimensionless, Year}; use anyhow::{Context, Result, ensure}; use itertools::iproduct; +use log::warn; use serde::Deserialize; use std::collections::HashMap; use std::path::Path; @@ -38,12 +39,21 @@ impl ProcessInvestmentConstraintRaw { self.addition_limit ); - // Validate capacity_growth_limit: must be in range [0, inf) + // Validate capacity_growth_limit if let Some(limit) = self.capacity_growth_limit { + // Must be in range [0, inf) ensure!( limit.is_finite() && limit >= Dimensionless(0.0), "Invalid value for capacity growth constraint: '{limit}'; must be non-negative and finite.", ); + + // Warn user they may have specified limit as percentage, rather than fraction + if limit > Dimensionless(1.0) { + warn!( + "Interpreting capacity growth constraint '{limit}' as {}%", + limit * Dimensionless(100.0) + ); + } } // Validate total_capacity_limit: must be in range [0, inf) @@ -171,6 +181,7 @@ mod tests { use crate::fixture::{assert_error, processes}; use crate::region::RegionID; use crate::units::Capacity; + use logtest::Logger; use rstest::rstest; fn validate_raw_constraint( @@ -485,4 +496,15 @@ mod tests { validate_raw_constraint(addition_limit, capacity_growth_limit, total_capacity_limit); assert_error!(invalid, error_msg); } + + #[test] + fn validate_capacity_growth_limit_warning() { + // Check warning raised if value above 1 provided + let mut logger = Logger::start(); + let _ = validate_raw_constraint(CapacityPerYear(10.0), Some(Dimensionless(5.0)), None); + assert_eq!( + logger.pop().unwrap().args(), + "Interpreting capacity growth constraint '5' as 500%" + ); + } } From ef971c4af225e185ec2b7339ad0f7bd456bd2c86 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Thu, 16 Jul 2026 16:56:22 +0100 Subject: [PATCH 4/4] Describe `capacity_growth_limit` in the input file schema --- schemas/input/process_investment_constraints.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/schemas/input/process_investment_constraints.yaml b/schemas/input/process_investment_constraints.yaml index 20985f8cf..7ce046280 100644 --- a/schemas/input/process_investment_constraints.yaml +++ b/schemas/input/process_investment_constraints.yaml @@ -26,6 +26,12 @@ fields: notes: | The addition limit is allocated evenly between all agents using their proportion of the process's primary commodity demand. + - name: capacity_growth_limit + type: number + description: | + The constraint on fractional growth per year, based on the available stock in a year, per + region and technology. E.g 0.1 -> max 10% growth allowed. + notes: This is currently unused. - name: total_capacity_limit type: number description: The hard upper limit on the amount agents can invest in the process