From 17a888eb1088632ff36e0d08b6cfb6a04e21ae47 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Thu, 13 Aug 2026 16:08:13 +0100 Subject: [PATCH 01/14] Add commodity constraint file reading and validation --- examples/simple/commodity_constraints.csv | 4 + src/commodity.rs | 16 ++- src/input/commodity/constraints.rs | 138 ++++++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 examples/simple/commodity_constraints.csv create mode 100644 src/input/commodity/constraints.rs diff --git a/examples/simple/commodity_constraints.csv b/examples/simple/commodity_constraints.csv new file mode 100644 index 000000000..dcee2f3d2 --- /dev/null +++ b/examples/simple/commodity_constraints.csv @@ -0,0 +1,4 @@ +commodity_id,region_id,balance_type,years,time_slice,limits +GASPRD,GBR,prod,2020,winter.day,12.34..56.78 +ELCTRI,GBR,cons,2020,summer,3.14.. +CO2EMT,GBR,prod,2030,annual,..1.618034 diff --git a/src/commodity.rs b/src/commodity.rs index a7fa92edf..0597a1c79 100644 --- a/src/commodity.rs +++ b/src/commodity.rs @@ -2,10 +2,12 @@ use crate::id::{define_id_getter, define_id_type}; use crate::region::RegionID; use crate::time_slice::{TimeSliceID, TimeSliceLevel, TimeSliceSelection}; -use crate::units::{Flow, MoneyPerFlow}; +use crate::units::{Flow, Money, MoneyPerFlow}; use indexmap::IndexMap; use serde::Deserialize; use std::collections::HashMap; +use std::ops::RangeInclusive; +use std::rc::Rc; use std::sync::Arc; define_id_type! {CommodityID, "commodity ID"} @@ -16,6 +18,9 @@ pub type CommodityMap = IndexMap>; /// A map of [`MoneyPerFlow`]s, keyed by region ID, year and time slice ID for a specific levy pub type CommodityLevyMap = HashMap<(RegionID, u32, TimeSliceID), MoneyPerFlow>; +/// A map of [`CommodityConstraint`]s, keyed by region ID and year +pub type CommodityConstraintsMap = HashMap<(RegionID, u32), Rc>; + /// A map of demand values, keyed by region ID, year and time slice selection pub type DemandMap = HashMap<(RegionID, u32, TimeSliceSelection), Flow>; @@ -115,6 +120,15 @@ pub enum PricingStrategy { Unpriced, } +/// A constraint imposed on commodity values +#[derive(PartialEq, Debug, Clone)] +pub struct CommodityConstraint { + /// The range of values the commodity is constrained to lie between + pub limits: RangeInclusive, +} + +impl CommodityConstraint {} + #[cfg(test)] mod tests { use super::*; diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs new file mode 100644 index 000000000..b61afcfc9 --- /dev/null +++ b/src/input/commodity/constraints.rs @@ -0,0 +1,138 @@ +//! Code for reading commodity constraints from a CSV file. +use super::super::{input_err_msg, read_csv_optional}; +use crate::commodity::{BalanceType, CommodityConstraint, CommodityConstraintsMap, CommodityID}; +use crate::id::IDCollection; +use crate::input::{parse_range, parse_year_str, try_insert}; +use crate::region::{RegionID, parse_region_str}; +use crate::time_slice::TimeSliceInfo; +use crate::units::Money; +use anyhow::{Context, Result, ensure}; +use indexmap::IndexSet; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +const COMMODITY_CONSTRAINTS_FILE_NAME: &str = "commodity_constraints.csv"; + +/// Constraints for each commodity +#[derive(PartialEq, Debug, Deserialize)] +struct CommodityConstraintRaw { + /// Unique identifier for the commodity + commodity_id: String, + /// Region id + region_id: String, + /// Type of balance + balance_type: BalanceType, + /// The year(s) to which the constraint applies + years: String, + /// The time slice to which the constraint applies + time_slice: String, + /// Limits on the value of the commodity + limits: String, +} + +impl CommodityConstraintRaw { + fn validate(&self) -> Result<()> { + // Only permit single regions for initial implementation + ensure!( + self.region_id != "all" && !self.region_id.contains(";"), + "Only single regions are permitted" + ); + + // Net production already constrained by commodity balance constraints + ensure!( + self.balance_type != BalanceType::Net, + "Balance type cannot be 'net' for commodity constraints" + ); + + Ok(()) + } +} + +/// Read the commodity constraints CSV file. +/// +/// # Arguments +/// +/// * `model_dir` - Folder containing model configuration files +/// * `commodity_ids` - All possible commodity IDs +/// * `region_ids` - All possible region IDs +/// * `time_slice_info` - Information about time slices +/// * `milestone_years` - All milestone years +/// +/// # Returns +/// +/// A `HashMap` mapping commodity IDs to their +/// commodity-constraints maps, or an error. +pub fn read_commodity_constraints( + model_dir: &Path, + commodity_ids: &IndexSet, + region_ids: &IndexSet, + time_slice_info: &TimeSliceInfo, + milestone_years: &[u32], +) -> Result> { + let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME); + let commodity_constraints_csv = read_csv_optional(&file_path)?; + read_commodity_constraints_from_iter( + commodity_constraints_csv, + commodity_ids, + region_ids, + time_slice_info, + milestone_years, + ) + .with_context(|| input_err_msg(&file_path)) +} + +/// Process raw commodity-constraint records into a constraints map. +/// +/// # Arguments +/// +/// * `iter` - Iterator over `CommodityConstraintRaw` records +/// * `commodity_ids` - All possible commodity IDs +/// * `region_ids` - All possible region IDs +/// * `time_slice_info` - Information about time slices +/// * `milestone_years` - All milestone years +/// +/// # Returns +/// +/// A `HashMap` mapping commodity IDs to their +/// commodity-constraints maps, or an error. +fn read_commodity_constraints_from_iter( + iter: I, + commodity_ids: &IndexSet, + region_ids: &IndexSet, + time_slice_info: &TimeSliceInfo, + milestone_years: &[u32], +) -> Result> +where + I: Iterator, +{ + let mut map: HashMap = HashMap::new(); + + for record in iter { + record.validate()?; + + // Extract fields from record + let commodity_id = commodity_ids.get_id(&record.commodity_id)?; + // Validation ensures single region_id, so take that at index 0 + let region_id = parse_region_str(&record.region_id, region_ids)?[0].clone(); + let years = parse_year_str(&record.years, milestone_years)?; + let ts_selection = time_slice_info.get_selection(&record.time_slice)?; + let limits = parse_range(&record.limits, Money(0.0)..=Money(f64::INFINITY)) + .with_context(|| format!("Could not parse constraint range: {}", record.limits))?; + + // For each record, store that constraint per year + let commodity_map = map.entry(commodity_id.clone()).or_default(); + for year in &years { + let constraint = Rc::new(CommodityConstraint { + limits: limits.clone(), + }); + try_insert( + commodity_map, + &(region_id.clone(), *year), + constraint.clone(), + )?; + } + } + Ok(map) +} From 89fa150cdf3d1911ab267d8e0b8a01e465cb1ba9 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Thu, 13 Aug 2026 16:17:02 +0100 Subject: [PATCH 02/14] Add schema for commodity constraints input file --- schemas/input/commodity_constraints.yaml | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 schemas/input/commodity_constraints.yaml diff --git a/schemas/input/commodity_constraints.yaml b/schemas/input/commodity_constraints.yaml new file mode 100644 index 000000000..4f613e114 --- /dev/null +++ b/schemas/input/commodity_constraints.yaml @@ -0,0 +1,29 @@ +$schema: https://specs.frictionlessdata.io/schemas/table-schema.json +description: Specifies constraints on commodities. + +fields: + - name: commodity_id + type: string + description: The commodity to which this constraint applies + - name: region_id + type: string + description: The region in which this constraint applies + - name: balance_type + type: string + description: The type of balance to which this is applied + notes: + The "net" option is not valid here, as net production is already constrained by + the commodity balance constraints. + - name: years + type: string + description: The year(s) to which this entry applies + - name: time_slice + type: string + description: The time slice(s) to which this entry applies + - name: limits + type: string + description: Lower and upper limits on the value of the commodity + notes: + A string in the format `min..max`, where `min` and `max` are decimal numbers + (e.g. "0.12..78.9"). Either `min` or `max` can be omitted (e.g "0.12.." or + "..78.9"), which will set the corresponding limit to 0 or infinite, respectively. From efbc8e45e2ac093010188a66a7b4ea21d11bf781 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Fri, 14 Aug 2026 15:34:51 +0100 Subject: [PATCH 03/14] Corrections to commodity constraint handling - Cleaner parsing of region_id - Change units of limits from money to flow - Store time slice selection and balance type in `CommodityConstraint` struct - Store vectors of `CommodityConstraint`s in `CommodityConstraintMap` --- src/commodity.rs | 12 +++++++---- src/input/commodity/constraints.rs | 32 ++++++++++++------------------ 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/commodity.rs b/src/commodity.rs index 0597a1c79..129350bea 100644 --- a/src/commodity.rs +++ b/src/commodity.rs @@ -2,7 +2,7 @@ use crate::id::{define_id_getter, define_id_type}; use crate::region::RegionID; use crate::time_slice::{TimeSliceID, TimeSliceLevel, TimeSliceSelection}; -use crate::units::{Flow, Money, MoneyPerFlow}; +use crate::units::{Flow, MoneyPerFlow}; use indexmap::IndexMap; use serde::Deserialize; use std::collections::HashMap; @@ -18,8 +18,8 @@ pub type CommodityMap = IndexMap>; /// A map of [`MoneyPerFlow`]s, keyed by region ID, year and time slice ID for a specific levy pub type CommodityLevyMap = HashMap<(RegionID, u32, TimeSliceID), MoneyPerFlow>; -/// A map of [`CommodityConstraint`]s, keyed by region ID and year -pub type CommodityConstraintsMap = HashMap<(RegionID, u32), Rc>; +/// A map of vectors of [`CommodityConstraint`]s, keyed by region ID and year +pub type CommodityConstraintsMap = HashMap<(RegionID, u32), Vec>; /// A map of demand values, keyed by region ID, year and time slice selection pub type DemandMap = HashMap<(RegionID, u32, TimeSliceSelection), Flow>; @@ -123,8 +123,12 @@ pub enum PricingStrategy { /// A constraint imposed on commodity values #[derive(PartialEq, Debug, Clone)] pub struct CommodityConstraint { + /// The balance type for the commodity constraint + pub balance_type: BalanceType, + /// The time slice selection for the commodity constraint + pub ts_selection: TimeSliceSelection, /// The range of values the commodity is constrained to lie between - pub limits: RangeInclusive, + pub limits: RangeInclusive, } impl CommodityConstraint {} diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs index b61afcfc9..d30c7447a 100644 --- a/src/input/commodity/constraints.rs +++ b/src/input/commodity/constraints.rs @@ -2,10 +2,10 @@ use super::super::{input_err_msg, read_csv_optional}; use crate::commodity::{BalanceType, CommodityConstraint, CommodityConstraintsMap, CommodityID}; use crate::id::IDCollection; -use crate::input::{parse_range, parse_year_str, try_insert}; -use crate::region::{RegionID, parse_region_str}; +use crate::input::{parse_range, parse_year_str}; +use crate::region::RegionID; use crate::time_slice::TimeSliceInfo; -use crate::units::Money; +use crate::units::Flow; use anyhow::{Context, Result, ensure}; use indexmap::IndexSet; use serde::Deserialize; @@ -34,12 +34,6 @@ struct CommodityConstraintRaw { impl CommodityConstraintRaw { fn validate(&self) -> Result<()> { - // Only permit single regions for initial implementation - ensure!( - self.region_id != "all" && !self.region_id.contains(";"), - "Only single regions are permitted" - ); - // Net production already constrained by commodity balance constraints ensure!( self.balance_type != BalanceType::Net, @@ -114,24 +108,24 @@ where // Extract fields from record let commodity_id = commodity_ids.get_id(&record.commodity_id)?; - // Validation ensures single region_id, so take that at index 0 - let region_id = parse_region_str(&record.region_id, region_ids)?[0].clone(); + let region_id = region_ids.get_id(&record.region_id)?; let years = parse_year_str(&record.years, milestone_years)?; let ts_selection = time_slice_info.get_selection(&record.time_slice)?; - let limits = parse_range(&record.limits, Money(0.0)..=Money(f64::INFINITY)) + let limits = parse_range(&record.limits, Flow(0.0)..=Flow(f64::INFINITY)) .with_context(|| format!("Could not parse constraint range: {}", record.limits))?; // For each record, store that constraint per year let commodity_map = map.entry(commodity_id.clone()).or_default(); for year in &years { - let constraint = Rc::new(CommodityConstraint { + let constraint = CommodityConstraint { + balance_type: record.balance_type.clone(), + ts_selection: ts_selection.clone(), limits: limits.clone(), - }); - try_insert( - commodity_map, - &(region_id.clone(), *year), - constraint.clone(), - )?; + }; + commodity_map + .entry((region_id.clone(), *year)) + .and_modify(|constraints| constraints.push(constraint.clone())) + .or_insert(vec![constraint]); } } Ok(map) From 328243d451ba29e7915edce032fbb933f8af98fb Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Tue, 25 Aug 2026 17:21:19 +0100 Subject: [PATCH 04/14] Add tests for creating commodity constraints --- src/commodity.rs | 1 - src/input/commodity.rs | 11 ++ src/input/commodity/constraints.rs | 172 ++++++++++++++++++++++++++++- 3 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/commodity.rs b/src/commodity.rs index 129350bea..43a2521ec 100644 --- a/src/commodity.rs +++ b/src/commodity.rs @@ -7,7 +7,6 @@ use indexmap::IndexMap; use serde::Deserialize; use std::collections::HashMap; use std::ops::RangeInclusive; -use std::rc::Rc; use std::sync::Arc; define_id_type! {CommodityID, "commodity ID"} diff --git a/src/input/commodity.rs b/src/input/commodity.rs index b9209aab1..839a9d061 100644 --- a/src/input/commodity.rs +++ b/src/input/commodity.rs @@ -16,6 +16,8 @@ use std::path::Path; mod levy; use levy::read_commodity_levies; +mod constraints; +use constraints::read_commodity_constraints; mod demand; use demand::read_demand; mod demand_slicing; @@ -55,6 +57,15 @@ pub fn read_commodities( let commodities = read_commodities_file(model_dir)?; let commodity_ids = commodities.keys().cloned().collect(); + // Read constraints table + let _commodity_constraints = read_commodity_constraints( + model_dir, + &commodity_ids, + region_ids, + time_slice_info, + milestone_years, + ); + // Read costs table let mut costs = read_commodity_levies( model_dir, diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs index d30c7447a..7542a2cd0 100644 --- a/src/input/commodity/constraints.rs +++ b/src/input/commodity/constraints.rs @@ -11,7 +11,6 @@ use indexmap::IndexSet; use serde::Deserialize; use std::collections::HashMap; use std::path::Path; -use std::rc::Rc; const COMMODITY_CONSTRAINTS_FILE_NAME: &str = "commodity_constraints.csv"; @@ -130,3 +129,174 @@ where } Ok(map) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixture::assert_error; + use crate::time_slice::{TimeSliceID, TimeSliceSelection}; + use crate::units::Year; + use float_cmp::assert_approx_eq; + use std::fs; + use tempfile::tempdir; + + fn validate_raw_constraint(region_id: &str, balance_type: BalanceType) -> Result<()> { + let constraint = CommodityConstraintRaw { + commodity_id: "test_commodity".into(), + region_id: region_id.to_string(), + balance_type, + years: "2020".into(), + time_slice: "annual".into(), + limits: "1.2..2.3".into(), + }; + constraint.validate() + } + + #[test] + fn validate_constraints_valid() { + let valid = validate_raw_constraint("test_region", BalanceType::Production); + valid.unwrap(); + } + + #[test] + fn validate_constraints_invalid() { + // Invalid balance_type specified + let invalid = validate_raw_constraint("test_region", BalanceType::Net); + assert_error!( + invalid, + "Balance type cannot be 'net' for commodity constraints" + ); + } + + #[test] + fn read_commodity_constraints_success() -> Result<()> { + // Create a model dir and write invalid CSV content to force + // read_commodity_constraints_from_iter failure + let dir = tempdir()?; + let model_dir = dir.path(); + + // Create simple commodity constraints csv content + let csv = "commodity_id,region_id,balance_type,years,time_slice,limits +ELCTRI,GBR,cons,2030,summer,12.34..56.78 +CO2EMT,GBR,cons,2030,winter,..9.99 +CO2EMT,GBR,prod,2030,summer,9.99.. +"; + let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME); + fs::write(&file_path, csv)?; + + // Create basic model inputs + let mut commodity_ids: IndexSet = IndexSet::new(); + commodity_ids.insert(CommodityID::from("ELCTRI")); + commodity_ids.insert(CommodityID::from("CO2EMT")); + + let mut region_ids: IndexSet = IndexSet::new(); + region_ids.insert(RegionID::from("GBR")); + + let time_slice1 = TimeSliceID { + season: "summer".into(), + time_of_day: "all-day".into(), + }; + let time_slice2 = TimeSliceID { + season: "summer".into(), + time_of_day: "all-day".into(), + }; + let time_slice_info = TimeSliceInfo { + seasons: [("summer".into(), Year(1.0)), ("winter".into(), Year(1.0))].into(), + times_of_day: ["day".into(), "night".into()].into(), + time_slices: [ + (time_slice1.clone(), Year(1.0)), + (time_slice2.clone(), Year(1.0)), + ] + .into(), + }; + + let milestone_years = vec![2030]; + + // Create the constraints map + let constraints_map = read_commodity_constraints( + model_dir, + &commodity_ids, + ®ion_ids, + &time_slice_info, + &milestone_years, + )?; + + // Check the constraints map contains the expected constraint, keyed by the expected + // commodity id + assert!(constraints_map.contains_key(&CommodityID::from("ELCTRI"))); + assert!(constraints_map.contains_key(&CommodityID::from("CO2EMT"))); + + // ELCTRI constraint + let elctri_constraint = &constraints_map[&CommodityID::from("ELCTRI")]; + let elctri_gbr_2030 = elctri_constraint + .get(&(RegionID::from("GBR"), 2030)) + .unwrap(); + assert_eq!(elctri_gbr_2030[0].balance_type, BalanceType::Consumption); + assert_eq!( + elctri_gbr_2030[0].ts_selection, + TimeSliceSelection::Season("summer".into()), + ); + assert_approx_eq!(f64, elctri_gbr_2030[0].limits.start().value(), 12.34); + assert_approx_eq!(f64, elctri_gbr_2030[0].limits.end().value(), 56.78); + + // CO2EMT constraints + let co2emt_constraint = &constraints_map[&CommodityID::from("CO2EMT")]; + let co2emt_gbr_2030 = co2emt_constraint + .get(&(RegionID::from("GBR"), 2030)) + .unwrap(); + assert_eq!(co2emt_gbr_2030[0].balance_type, BalanceType::Consumption); + assert_eq!( + co2emt_gbr_2030[0].ts_selection, + TimeSliceSelection::Season("winter".into()), + ); + assert_approx_eq!(f64, co2emt_gbr_2030[0].limits.start().value(), 0.0); + assert_approx_eq!(f64, co2emt_gbr_2030[0].limits.end().value(), 9.99); + + assert_eq!(co2emt_gbr_2030[1].balance_type, BalanceType::Production); + assert_eq!( + co2emt_gbr_2030[1].ts_selection, + TimeSliceSelection::Season("summer".into()), + ); + assert_approx_eq!(f64, co2emt_gbr_2030[1].limits.start().value(), 9.99); + assert_approx_eq!(f64, co2emt_gbr_2030[1].limits.end().value(), f64::INFINITY); + + Ok(()) + } + + #[test] + fn read_commodity_constraints_fails_with_invalid_csv() -> Result<()> { + // Create a model dir and write invalid CSV content to force + // read_commodity_constraints_from_iter failure + let dir = tempdir()?; + let model_dir = dir.path(); + + // Create invalid commodity constraints csv content + let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME); + fs::write(&file_path, "invalid,commodity,constraints\nbad,row\n")?; + + // Create empty model inputs + let commodity_ids: IndexSet = IndexSet::new(); + let region_ids: IndexSet = IndexSet::new(); + let time_slice_info = TimeSliceInfo::default(); + let milestone_years: Vec = vec![2020, 2030]; + + // Try to create the constraints map + let result = read_commodity_constraints( + model_dir, + &commodity_ids, + ®ion_ids, + &time_slice_info, + &milestone_years, + ); + + // Check failure and file path present in error message + assert!(result.is_err()); + let err_text = format!("{:#}", result.unwrap_err()); + assert!( + err_text.contains(COMMODITY_CONSTRAINTS_FILE_NAME), + "error message should include file name context, got: {err_text}" + ); + + Ok(()) + } +} From 03f77ef31cac88f05012e37e10d92b5af543e5ff Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 08:49:09 +0100 Subject: [PATCH 05/14] Prevent SVD commodities from having commodity constraints --- src/input/commodity.rs | 2 +- src/input/commodity/constraints.rs | 57 +++++++++++++++++++----------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/input/commodity.rs b/src/input/commodity.rs index 839a9d061..211fdd4e0 100644 --- a/src/input/commodity.rs +++ b/src/input/commodity.rs @@ -60,7 +60,7 @@ pub fn read_commodities( // Read constraints table let _commodity_constraints = read_commodity_constraints( model_dir, - &commodity_ids, + &commodities, region_ids, time_slice_info, milestone_years, diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs index 7542a2cd0..6ef1af39e 100644 --- a/src/input/commodity/constraints.rs +++ b/src/input/commodity/constraints.rs @@ -1,13 +1,16 @@ //! Code for reading commodity constraints from a CSV file. use super::super::{input_err_msg, read_csv_optional}; -use crate::commodity::{BalanceType, CommodityConstraint, CommodityConstraintsMap, CommodityID}; +use crate::commodity::{ + BalanceType, Commodity, CommodityConstraint, CommodityConstraintsMap, CommodityID, + CommodityType, +}; use crate::id::IDCollection; use crate::input::{parse_range, parse_year_str}; use crate::region::RegionID; use crate::time_slice::TimeSliceInfo; use crate::units::Flow; use anyhow::{Context, Result, ensure}; -use indexmap::IndexSet; +use indexmap::{IndexMap, IndexSet}; use serde::Deserialize; use std::collections::HashMap; use std::path::Path; @@ -48,7 +51,7 @@ impl CommodityConstraintRaw { /// # Arguments /// /// * `model_dir` - Folder containing model configuration files -/// * `commodity_ids` - All possible commodity IDs +/// * `commodities` - The commodities in the model /// * `region_ids` - All possible region IDs /// * `time_slice_info` - Information about time slices /// * `milestone_years` - All milestone years @@ -59,7 +62,7 @@ impl CommodityConstraintRaw { /// commodity-constraints maps, or an error. pub fn read_commodity_constraints( model_dir: &Path, - commodity_ids: &IndexSet, + commodities: &IndexMap, region_ids: &IndexSet, time_slice_info: &TimeSliceInfo, milestone_years: &[u32], @@ -68,7 +71,7 @@ pub fn read_commodity_constraints( let commodity_constraints_csv = read_csv_optional(&file_path)?; read_commodity_constraints_from_iter( commodity_constraints_csv, - commodity_ids, + commodities, region_ids, time_slice_info, milestone_years, @@ -81,7 +84,7 @@ pub fn read_commodity_constraints( /// # Arguments /// /// * `iter` - Iterator over `CommodityConstraintRaw` records -/// * `commodity_ids` - All possible commodity IDs +/// * `commodities` - The commodoties in the model /// * `region_ids` - All possible region IDs /// * `time_slice_info` - Information about time slices /// * `milestone_years` - All milestone years @@ -92,7 +95,7 @@ pub fn read_commodity_constraints( /// commodity-constraints maps, or an error. fn read_commodity_constraints_from_iter( iter: I, - commodity_ids: &IndexSet, + commodities: &IndexMap, region_ids: &IndexSet, time_slice_info: &TimeSliceInfo, milestone_years: &[u32], @@ -106,7 +109,14 @@ where record.validate()?; // Extract fields from record - let commodity_id = commodity_ids.get_id(&record.commodity_id)?; + let commodity = commodities + .get(&CommodityID::new(&record.commodity_id)) + .unwrap(); + ensure!( + commodity.kind != CommodityType::ServiceDemand, + "SVD commodities are not permitted to have commodity constraints" + ); + let commodity_id = &commodity.id; let region_id = region_ids.get_id(&record.region_id)?; let years = parse_year_str(&record.years, milestone_years)?; let ts_selection = time_slice_info.get_selection(&record.time_slice)?; @@ -134,6 +144,7 @@ where mod tests { use super::*; use crate::fixture::assert_error; + use crate::input::commodity::{COMMODITY_FILE_NAME, read_commodities_file}; use crate::time_slice::{TimeSliceID, TimeSliceSelection}; use crate::units::Year; use float_cmp::assert_approx_eq; @@ -170,24 +181,30 @@ mod tests { #[test] fn read_commodity_constraints_success() -> Result<()> { - // Create a model dir and write invalid CSV content to force - // read_commodity_constraints_from_iter failure + // Create a model dir and write simple CSV files let dir = tempdir()?; let model_dir = dir.path(); - // Create simple commodity constraints csv content - let csv = "commodity_id,region_id,balance_type,years,time_slice,limits + // Create simple commodity constraints csv file + let constraints_csv = "commodity_id,region_id,balance_type,years,time_slice,limits ELCTRI,GBR,cons,2030,summer,12.34..56.78 CO2EMT,GBR,cons,2030,winter,..9.99 CO2EMT,GBR,prod,2030,summer,9.99.. "; - let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME); - fs::write(&file_path, csv)?; + fs::write( + model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME), + constraints_csv, + )?; + + // Create simple commodities csv to simplify creating `Commodity`s + let commodities_csv = "id,description,type,time_slice_level,units +ELCTRI,Electricity,sed,daynight,PJ +CO2EMT,CO2 emitted,oth,annual,ktCO2 +"; + fs::write(model_dir.join(COMMODITY_FILE_NAME), commodities_csv)?; // Create basic model inputs - let mut commodity_ids: IndexSet = IndexSet::new(); - commodity_ids.insert(CommodityID::from("ELCTRI")); - commodity_ids.insert(CommodityID::from("CO2EMT")); + let commodities = read_commodities_file(model_dir).unwrap(); let mut region_ids: IndexSet = IndexSet::new(); region_ids.insert(RegionID::from("GBR")); @@ -215,7 +232,7 @@ CO2EMT,GBR,prod,2030,summer,9.99.. // Create the constraints map let constraints_map = read_commodity_constraints( model_dir, - &commodity_ids, + &commodities, ®ion_ids, &time_slice_info, &milestone_years, @@ -275,7 +292,7 @@ CO2EMT,GBR,prod,2030,summer,9.99.. fs::write(&file_path, "invalid,commodity,constraints\nbad,row\n")?; // Create empty model inputs - let commodity_ids: IndexSet = IndexSet::new(); + let commodities: IndexMap = IndexMap::new(); let region_ids: IndexSet = IndexSet::new(); let time_slice_info = TimeSliceInfo::default(); let milestone_years: Vec = vec![2020, 2030]; @@ -283,7 +300,7 @@ CO2EMT,GBR,prod,2030,summer,9.99.. // Try to create the constraints map let result = read_commodity_constraints( model_dir, - &commodity_ids, + &commodities, ®ion_ids, &time_slice_info, &milestone_years, From 24ff422df960f759c2c82ffed6a4494cd22f60b4 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 12:34:01 +0100 Subject: [PATCH 06/14] Remove examples/simple/commodity_constraints.csv --- examples/simple/commodity_constraints.csv | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 examples/simple/commodity_constraints.csv diff --git a/examples/simple/commodity_constraints.csv b/examples/simple/commodity_constraints.csv deleted file mode 100644 index dcee2f3d2..000000000 --- a/examples/simple/commodity_constraints.csv +++ /dev/null @@ -1,4 +0,0 @@ -commodity_id,region_id,balance_type,years,time_slice,limits -GASPRD,GBR,prod,2020,winter.day,12.34..56.78 -ELCTRI,GBR,cons,2020,summer,3.14.. -CO2EMT,GBR,prod,2030,annual,..1.618034 From 4c3f350ea925240ea9d73d8851715f27e5a70d11 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 12:36:17 +0100 Subject: [PATCH 07/14] State valid balance type options --- schemas/input/commodity_constraints.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/input/commodity_constraints.yaml b/schemas/input/commodity_constraints.yaml index 4f613e114..95101d769 100644 --- a/schemas/input/commodity_constraints.yaml +++ b/schemas/input/commodity_constraints.yaml @@ -12,8 +12,8 @@ fields: type: string description: The type of balance to which this is applied notes: - The "net" option is not valid here, as net production is already constrained by - the commodity balance constraints. + Valid options are "cons" and "prod"; the "net" option is not valid here, as net + production is already constrained by the commodity balance constraints. - name: years type: string description: The year(s) to which this entry applies From 310d9ad438f9e2464c6fea9f47ee2f8b552bd59d Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 12:38:09 +0100 Subject: [PATCH 08/14] Add notes for time_slice field --- schemas/input/commodity_constraints.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schemas/input/commodity_constraints.yaml b/schemas/input/commodity_constraints.yaml index 95101d769..6667b9b0c 100644 --- a/schemas/input/commodity_constraints.yaml +++ b/schemas/input/commodity_constraints.yaml @@ -20,6 +20,9 @@ fields: - name: time_slice type: string description: The time slice(s) to which this entry applies + notes: | + Can be a single time slice (e.g. `winter.day`), a whole season (e.g. `winter`) or `annual`, + representing the whole year - name: limits type: string description: Lower and upper limits on the value of the commodity From 56a8eaac8fc57f5ab73063acfcbf0fc4c0020401 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 12:39:45 +0100 Subject: [PATCH 09/14] Improve top-level schema description --- schemas/input/commodity_constraints.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schemas/input/commodity_constraints.yaml b/schemas/input/commodity_constraints.yaml index 6667b9b0c..6a65446b1 100644 --- a/schemas/input/commodity_constraints.yaml +++ b/schemas/input/commodity_constraints.yaml @@ -1,5 +1,7 @@ $schema: https://specs.frictionlessdata.io/schemas/table-schema.json -description: Specifies constraints on commodities. +description: | + Specifies the limits on the total amount of consumption/production of a given + commodity in a given region, year and time slice(s). fields: - name: commodity_id From 1b1897f3757f83c0c2bfdc7fcbc3b3507529e087 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 12:59:19 +0100 Subject: [PATCH 10/14] Improve commodity and commodity ID extraction --- src/input/commodity/constraints.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs index 6ef1af39e..133c64865 100644 --- a/src/input/commodity/constraints.rs +++ b/src/input/commodity/constraints.rs @@ -4,7 +4,7 @@ use crate::commodity::{ BalanceType, Commodity, CommodityConstraint, CommodityConstraintsMap, CommodityID, CommodityType, }; -use crate::id::IDCollection; +use crate::id::{GetIDValue, IDCollection}; use crate::input::{parse_range, parse_year_str}; use crate::region::RegionID; use crate::time_slice::TimeSliceInfo; @@ -109,14 +109,11 @@ where record.validate()?; // Extract fields from record - let commodity = commodities - .get(&CommodityID::new(&record.commodity_id)) - .unwrap(); + let (commodity_id, commodity) = commodities.get_id_value(&record.commodity_id)?; ensure!( commodity.kind != CommodityType::ServiceDemand, "SVD commodities are not permitted to have commodity constraints" ); - let commodity_id = &commodity.id; let region_id = region_ids.get_id(&record.region_id)?; let years = parse_year_str(&record.years, milestone_years)?; let ts_selection = time_slice_info.get_selection(&record.time_slice)?; From b31ff9c77971b4837bce97d3171599ac156c6427 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 13:06:47 +0100 Subject: [PATCH 11/14] Tidy up csv creation strings with macro --- src/input/commodity/constraints.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs index 133c64865..2677ed56c 100644 --- a/src/input/commodity/constraints.rs +++ b/src/input/commodity/constraints.rs @@ -182,22 +182,24 @@ mod tests { let dir = tempdir()?; let model_dir = dir.path(); - // Create simple commodity constraints csv file - let constraints_csv = "commodity_id,region_id,balance_type,years,time_slice,limits -ELCTRI,GBR,cons,2030,summer,12.34..56.78 -CO2EMT,GBR,cons,2030,winter,..9.99 -CO2EMT,GBR,prod,2030,summer,9.99.. -"; + // Create simple commodity constraints CSV file + let constraints_csv = concat!( + "commodity_id,region_id,balance_type,years,time_slice,limits\n", + "ELCTRI,GBR,cons,2030,summer,12.34..56.78\n", + "CO2EMT,GBR,cons,2030,winter,..9.99\n", + "CO2EMT,GBR,prod,2030,summer,9.99..\n", + ); fs::write( model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME), constraints_csv, )?; - // Create simple commodities csv to simplify creating `Commodity`s - let commodities_csv = "id,description,type,time_slice_level,units -ELCTRI,Electricity,sed,daynight,PJ -CO2EMT,CO2 emitted,oth,annual,ktCO2 -"; + // Create simple commodities CSV to simplify creating `Commodity`s + let commodities_csv = concat!( + "id,description,type,time_slice_level,units\n", + "ELCTRI,Electricity,sed,daynight,PJ\n", + "CO2EMT,CO2 emitted,oth,annual,ktCO2\n", + ); fs::write(model_dir.join(COMMODITY_FILE_NAME), commodities_csv)?; // Create basic model inputs @@ -284,7 +286,7 @@ CO2EMT,CO2 emitted,oth,annual,ktCO2 let dir = tempdir()?; let model_dir = dir.path(); - // Create invalid commodity constraints csv content + // Create invalid commodity constraints CSV content let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME); fs::write(&file_path, "invalid,commodity,constraints\nbad,row\n")?; From 14066fe6872a80d2812f3c14dfe9819a2280ddce Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 13:14:12 +0100 Subject: [PATCH 12/14] Remove unused region_id argument in validation tests --- src/input/commodity/constraints.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs index 2677ed56c..f6c78e5ac 100644 --- a/src/input/commodity/constraints.rs +++ b/src/input/commodity/constraints.rs @@ -148,10 +148,10 @@ mod tests { use std::fs; use tempfile::tempdir; - fn validate_raw_constraint(region_id: &str, balance_type: BalanceType) -> Result<()> { + fn validate_raw_constraint(balance_type: BalanceType) -> Result<()> { let constraint = CommodityConstraintRaw { commodity_id: "test_commodity".into(), - region_id: region_id.to_string(), + region_id: "test_region".into(), balance_type, years: "2020".into(), time_slice: "annual".into(), @@ -162,14 +162,14 @@ mod tests { #[test] fn validate_constraints_valid() { - let valid = validate_raw_constraint("test_region", BalanceType::Production); + let valid = validate_raw_constraint(BalanceType::Production); valid.unwrap(); } #[test] fn validate_constraints_invalid() { // Invalid balance_type specified - let invalid = validate_raw_constraint("test_region", BalanceType::Net); + let invalid = validate_raw_constraint(BalanceType::Net); assert_error!( invalid, "Balance type cannot be 'net' for commodity constraints" From 632d9efa4557df5a9b2b08541a5c2b01f385a201 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 13:48:25 +0100 Subject: [PATCH 13/14] Fix duplicate time slice --- src/input/commodity/constraints.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/commodity/constraints.rs b/src/input/commodity/constraints.rs index f6c78e5ac..2e9fa772b 100644 --- a/src/input/commodity/constraints.rs +++ b/src/input/commodity/constraints.rs @@ -213,7 +213,7 @@ mod tests { time_of_day: "all-day".into(), }; let time_slice2 = TimeSliceID { - season: "summer".into(), + season: "winter".into(), time_of_day: "all-day".into(), }; let time_slice_info = TimeSliceInfo { From 9ec63c4455803324072b98f28881d205c5576718 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Wed, 26 Aug 2026 14:37:41 +0100 Subject: [PATCH 14/14] Construct s with constraints --- src/commodity.rs | 5 +++++ src/fixture.rs | 6 +++++- src/input/commodity.rs | 13 +++++++++---- src/input/process/flow.rs | 7 +++++-- src/process.rs | 13 ++++++++++++- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/commodity.rs b/src/commodity.rs index 43a2521ec..585cf5fdc 100644 --- a/src/commodity.rs +++ b/src/commodity.rs @@ -57,6 +57,11 @@ pub struct Commodity { /// `time_slice_level` field. E.g. if the `time_slice_level` is seasonal, then there will be /// keys representing each season (and not e.g. individual time slices). pub demand: DemandMap, + /// Constraints for this commodity for different combinations of region, year and time slice. + /// + /// May be empty if there are no constraints for this commodity, otherwise there must be entries + /// for every combination of parameters. + pub constraints: CommodityConstraintsMap, /// Units for this commodity represented as a string e.g Petajoules, Tonnes /// This is only used for validation purposes. pub units: String, diff --git a/src/fixture.rs b/src/fixture.rs index 1d215148a..420983e37 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -6,7 +6,8 @@ use crate::agent::{ }; use crate::asset::{Asset, AssetCapacity, AssetPool, AssetRef}; use crate::commodity::{ - Commodity, CommodityID, CommodityLevyMap, CommodityType, DemandMap, PricingStrategy, + Commodity, CommodityConstraintsMap, CommodityID, CommodityLevyMap, CommodityType, DemandMap, + PricingStrategy, }; use crate::patch::{FilePatch, ModelPatch}; use crate::process::{ @@ -157,6 +158,7 @@ pub fn svd_commodity() -> Commodity { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), } } @@ -172,6 +174,7 @@ pub fn sed_commodity() -> Commodity { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), } } @@ -187,6 +190,7 @@ pub fn other_commodity() -> Commodity { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), } } diff --git a/src/input/commodity.rs b/src/input/commodity.rs index 211fdd4e0..e3d503fc9 100644 --- a/src/input/commodity.rs +++ b/src/input/commodity.rs @@ -2,8 +2,8 @@ use super::{input_err_msg, read_csv}; use crate::ISSUES_URL; use crate::commodity::{ - BalanceType, Commodity, CommodityID, CommodityLevyMap, CommodityMap, CommodityType, DemandMap, - PricingStrategy, + BalanceType, Commodity, CommodityConstraintsMap, CommodityID, CommodityLevyMap, CommodityMap, + CommodityType, DemandMap, PricingStrategy, }; use crate::model::{ALLOW_DANGEROUS_OPTION_NAME, dangerous_model_options_enabled}; use crate::region::RegionID; @@ -58,13 +58,13 @@ pub fn read_commodities( let commodity_ids = commodities.keys().cloned().collect(); // Read constraints table - let _commodity_constraints = read_commodity_constraints( + let mut commodity_constraints = read_commodity_constraints( model_dir, &commodities, region_ids, time_slice_info, milestone_years, - ); + )?; // Read costs table let mut costs = read_commodity_levies( @@ -99,6 +99,9 @@ pub fn read_commodities( if let Some(demand) = demand.remove(&id) { commodity.demand = demand; } + if let Some(commodity_constraints) = commodity_constraints.remove(&id) { + commodity.constraints = commodity_constraints; + } (id, commodity.into()) }) @@ -131,6 +134,7 @@ where levies_prod: CommodityLevyMap::default(), levies_cons: CommodityLevyMap::default(), demand: DemandMap::default(), + constraints: CommodityConstraintsMap::default(), units: commodity_raw.units, }; @@ -217,6 +221,7 @@ mod tests { levies_prod: CommodityLevyMap::default(), levies_cons: CommodityLevyMap::default(), demand: DemandMap::default(), + constraints: CommodityConstraintsMap::default(), units: "PJ".into(), } } diff --git a/src/input/process/flow.rs b/src/input/process/flow.rs index 69a323308..6248039ed 100644 --- a/src/input/process/flow.rs +++ b/src/input/process/flow.rs @@ -389,8 +389,9 @@ fn validate_secondary_flows( #[cfg(test)] mod tests { use super::*; - use crate::commodity::Commodity; - use crate::commodity::{CommodityLevyMap, DemandMap, PricingStrategy}; + use crate::commodity::{ + Commodity, CommodityConstraintsMap, CommodityLevyMap, DemandMap, PricingStrategy, + }; use crate::fixture::{ assert_error, assert_validate_fails_with_simple, assert_validate_ok_simple, other_commodity, process, sed_commodity, svd_commodity, @@ -445,6 +446,7 @@ mod tests { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), } } @@ -460,6 +462,7 @@ mod tests { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "tonnes".into(), } } diff --git a/src/process.rs b/src/process.rs index 48245a83f..2c9221b69 100644 --- a/src/process.rs +++ b/src/process.rs @@ -543,7 +543,9 @@ impl ProcessInvestmentConstraint { #[cfg(test)] mod tests { use super::*; - use crate::commodity::{CommodityLevyMap, CommodityType, DemandMap, PricingStrategy}; + use crate::commodity::{ + CommodityConstraintsMap, CommodityLevyMap, CommodityType, DemandMap, PricingStrategy, + }; use crate::fixture::{assert_error, region_id, time_slice, time_slice_info2}; use crate::time_slice::TimeSliceLevel; use crate::time_slice::TimeSliceSelection; @@ -611,6 +613,7 @@ mod tests { levies_prod, levies_cons, demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }) } @@ -632,6 +635,7 @@ mod tests { levies_prod: CommodityLevyMap::new(), levies_cons: levies, demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }) } @@ -653,6 +657,7 @@ mod tests { levies_prod: levies, levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }) } @@ -676,6 +681,7 @@ mod tests { levies_prod, levies_cons, demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }) } @@ -691,6 +697,7 @@ mod tests { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }) } @@ -707,6 +714,7 @@ mod tests { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }), coeff: FlowPerActivity(1.0), @@ -730,6 +738,7 @@ mod tests { levies_prod: levies, levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }), coeff: FlowPerActivity(1.0), @@ -753,6 +762,7 @@ mod tests { levies_prod: levies, levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), }), coeff: FlowPerActivity(1.0), @@ -1021,6 +1031,7 @@ mod tests { levies_prod: CommodityLevyMap::new(), levies_cons: CommodityLevyMap::new(), demand: DemandMap::new(), + constraints: CommodityConstraintsMap::new(), units: "PJ".into(), });