From 37891225b0f6269ed593591aa262038009daddf3 Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Thu, 13 Aug 2026 16:08:13 +0100 Subject: [PATCH 1/2] Add commodity constraint file reading and validation --- examples/simple/commodity_constraints.csv | 4 + src/commodity.rs | 15 ++- src/input/commodity/constraints.rs | 138 ++++++++++++++++++++++ 3 files changed, 156 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 80d8cb1a7..984a8f783 100644 --- a/src/commodity.rs +++ b/src/commodity.rs @@ -2,10 +2,11 @@ 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; define_id_type! {CommodityID, "commodity ID"} @@ -16,6 +17,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 +119,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 f8c2e7496370c6de8e3e587934c4621f9ce47d2c Mon Sep 17 00:00:00 2001 From: Dan Cummins Date: Thu, 13 Aug 2026 16:17:02 +0100 Subject: [PATCH 2/2] 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.