Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/simple/commodity_constraints.csv
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions schemas/input/commodity_constraints.yaml
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion src/commodity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -16,6 +17,9 @@ pub type CommodityMap = IndexMap<CommodityID, Rc<Commodity>>;
/// 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<CommodityConstraint>>;

@tsmbland tsmbland Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can have multiple constraints for each (region, year), so this should be

HashMap<(RegionID, u32), Vec<CommodityConstraint>>

Not sure you necessarily need the Rc, but may be wrong (probably). If required, it should probably be Arc to keep things parallel-compatible

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah OK yeah, I understand what you were referring to in your comment on the issue now.


/// A map of demand values, keyed by region ID, year and time slice selection
pub type DemandMap = HashMap<(RegionID, u32, TimeSliceSelection), Flow>;

Expand Down Expand Up @@ -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<Money>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also needs to store the TimeSliceSelection that the constraint applies to, and the BalanceType

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. I wasn't sure what to do with those.

}

impl CommodityConstraint {}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
138 changes: 138 additions & 0 deletions src/input/commodity/constraints.rs
Original file line number Diff line number Diff line change
@@ -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"
);
Comment on lines +38 to +41

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be validated by get_id (see comment below)


// 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<CommodityID, CommodityConstraintsMap>` mapping commodity IDs to their
/// commodity-constraints maps, or an error.
pub fn read_commodity_constraints(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also want to disallow SVD commodities from having commodity constraints, so you'll need to pass in the map of the commodities rather than just the IDs (i.e. &IndexMap<CommodityID, Commodity>)

Another slightly tricky thing is that, since OTH commodities can either be consumed or produced (but not both), we don't want users to supply production constraints for OTH commodities that are consumed, and vice-versa. Probably worth opening an issue about this rather than attempting this here, as we may have to do this in the graph validation stage

model_dir: &Path,
commodity_ids: &IndexSet<CommodityID>,
region_ids: &IndexSet<RegionID>,
time_slice_info: &TimeSliceInfo,
milestone_years: &[u32],
) -> Result<HashMap<CommodityID, CommodityConstraintsMap>> {
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<CommodityID, CommodityConstraintsMap>` mapping commodity IDs to their
/// commodity-constraints maps, or an error.
fn read_commodity_constraints_from_iter<I>(
iter: I,
commodity_ids: &IndexSet<CommodityID>,
region_ids: &IndexSet<RegionID>,
time_slice_info: &TimeSliceInfo,
milestone_years: &[u32],
) -> Result<HashMap<CommodityID, CommodityConstraintsMap>>
where
I: Iterator<Item = CommodityConstraintRaw>,
{
let mut map: HashMap<CommodityID, CommodityConstraintsMap> = 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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let limits = parse_range(&record.limits, Money(0.0)..=Money(f64::INFINITY))
let limits = parse_range(&record.limits, Flow(0.0)..=Flow(f64::INFINITY))

Limits are on amount of commodity consumed/produced (i.e. Flow), rather than a monetary amount

.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)
}