Skip to main content

muse2/input/commodity/
constraints.rs

1//! Code for reading commodity constraints from a CSV file.
2//!
3//! The `commodity_constraints.csv` file is optional. If it is provided, the
4//! `please_give_me_broken_results` option in `model.toml` must be set to `true` because commodity
5//! constraints are experimental, and not yet factored in to the investment algorithm.
6use super::super::{input_err_msg, read_csv_optional};
7use crate::commodity::{
8    BalanceType, Commodity, CommodityConstraint, CommodityConstraintsMap, CommodityID,
9    CommodityType,
10};
11use crate::id::{GetIDValue, IDCollection};
12use crate::input::{parse_range, parse_year_str};
13use crate::model::{ALLOW_DANGEROUS_OPTION_NAME, dangerous_model_options_enabled};
14use crate::region::RegionID;
15use crate::time_slice::TimeSliceInfo;
16use crate::units::Flow;
17use anyhow::{Context, Result, ensure};
18use indexmap::{IndexMap, IndexSet};
19use serde::Deserialize;
20use std::collections::HashMap;
21use std::path::Path;
22
23const COMMODITY_CONSTRAINTS_FILE_NAME: &str = "commodity_constraints.csv";
24
25/// Constraints for each commodity
26#[derive(PartialEq, Debug, Deserialize)]
27struct CommodityConstraintRaw {
28    /// Unique identifier for the commodity
29    commodity_id: String,
30    /// Region id
31    region_id: String,
32    /// Type of balance
33    balance_type: BalanceType,
34    /// The year(s) to which the constraint applies
35    years: String,
36    /// The time slice to which the constraint applies
37    time_slice: String,
38    /// Limits on the value of the commodity
39    limits: String,
40}
41
42impl CommodityConstraintRaw {
43    fn validate(&self) -> Result<()> {
44        // Net production already constrained by commodity balance constraints
45        ensure!(
46            self.balance_type != BalanceType::Net,
47            "Balance type cannot be 'net' for commodity constraints"
48        );
49
50        Ok(())
51    }
52}
53
54/// Read the commodity constraints CSV file.
55///
56/// # Arguments
57///
58/// * `model_dir` - Folder containing model configuration files
59/// * `commodities` - The commodities in the model
60/// * `region_ids` - All possible region IDs
61/// * `time_slice_info` - Information about time slices
62/// * `milestone_years` - All milestone years
63///
64/// # Returns
65///
66/// A `HashMap<CommodityID, CommodityConstraintsMap>` mapping commodity IDs to their
67/// commodity-constraints maps, or an error.
68pub fn read_commodity_constraints(
69    model_dir: &Path,
70    commodities: &IndexMap<CommodityID, Commodity>,
71    region_ids: &IndexSet<RegionID>,
72    time_slice_info: &TimeSliceInfo,
73    milestone_years: &[u32],
74) -> Result<HashMap<CommodityID, CommodityConstraintsMap>> {
75    let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME);
76    let commodity_constraints_csv = read_csv_optional(&file_path)?;
77    let commodity_constraints = read_commodity_constraints_from_iter(
78        commodity_constraints_csv,
79        commodities,
80        region_ids,
81        time_slice_info,
82        milestone_years,
83    )
84    .with_context(|| input_err_msg(&file_path))?;
85
86    ensure!(
87        commodity_constraints.is_empty() || dangerous_model_options_enabled(),
88        "Commodity constraints are currently experimental. To use them, set the \
89        {ALLOW_DANGEROUS_OPTION_NAME} option to true."
90    );
91
92    Ok(commodity_constraints)
93}
94
95/// Process raw commodity-constraint records into a constraints map.
96///
97/// # Arguments
98///
99/// * `iter` - Iterator over `CommodityConstraintRaw` records
100/// * `commodities` - The commodities in the model
101/// * `region_ids` - All possible region IDs
102/// * `time_slice_info` - Information about time slices
103/// * `milestone_years` - All milestone years
104///
105/// # Returns
106///
107/// A `HashMap<CommodityID, CommodityConstraintsMap>` mapping commodity IDs to their
108/// commodity-constraints maps, or an error.
109fn read_commodity_constraints_from_iter<I>(
110    iter: I,
111    commodities: &IndexMap<CommodityID, Commodity>,
112    region_ids: &IndexSet<RegionID>,
113    time_slice_info: &TimeSliceInfo,
114    milestone_years: &[u32],
115) -> Result<HashMap<CommodityID, CommodityConstraintsMap>>
116where
117    I: Iterator<Item = CommodityConstraintRaw>,
118{
119    let mut map: HashMap<CommodityID, CommodityConstraintsMap> = HashMap::new();
120
121    for record in iter {
122        record.validate()?;
123
124        // Extract fields from record
125        let (commodity_id, commodity) = commodities.get_id_value(&record.commodity_id)?;
126        ensure!(
127            commodity.kind != CommodityType::ServiceDemand,
128            "SVD commodities are not permitted to have commodity constraints"
129        );
130        let region_id = region_ids.get_id(&record.region_id)?;
131        let years = parse_year_str(&record.years, milestone_years)?;
132        let ts_selection = time_slice_info.get_selection(&record.time_slice)?;
133        let limits = parse_range(&record.limits, Flow(0.0)..=Flow(f64::INFINITY))
134            .with_context(|| format!("Could not parse constraint range: {}", record.limits))?;
135
136        // For each record, store that constraint per year
137        let commodity_map = map.entry(commodity_id.clone()).or_default();
138        for year in &years {
139            let constraint = CommodityConstraint {
140                region_id: region_id.clone(),
141                balance_type: record.balance_type.clone(),
142                ts_selection: ts_selection.clone(),
143                limits: limits.clone(),
144            };
145            commodity_map
146                .entry(*year)
147                .and_modify(|constraints| constraints.push(constraint.clone()))
148                .or_insert(vec![constraint]);
149        }
150    }
151    Ok(map)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::fixture::assert_error;
158    use crate::input::commodity::{COMMODITY_FILE_NAME, read_commodities_file};
159    use crate::time_slice::{TimeSliceID, TimeSliceSelection};
160    use crate::units::Year;
161    use float_cmp::assert_approx_eq;
162    use std::fs;
163    use tempfile::tempdir;
164
165    fn validate_raw_constraint(balance_type: BalanceType) -> Result<()> {
166        let constraint = CommodityConstraintRaw {
167            commodity_id: "test_commodity".into(),
168            region_id: "test_region".into(),
169            balance_type,
170            years: "2020".into(),
171            time_slice: "annual".into(),
172            limits: "1.2..2.3".into(),
173        };
174        constraint.validate()
175    }
176
177    #[test]
178    fn validate_constraints_valid() {
179        validate_raw_constraint(BalanceType::Production).unwrap();
180    }
181
182    #[test]
183    fn validate_constraints_invalid() {
184        // Invalid balance_type specified
185        let invalid = validate_raw_constraint(BalanceType::Net);
186        assert_error!(
187            invalid,
188            "Balance type cannot be 'net' for commodity constraints"
189        );
190    }
191
192    #[test]
193    #[allow(clippy::too_many_lines)]
194    fn read_commodity_constraints_from_iter_success() {
195        // Create a model dir and write a simple commodities CSV file
196        let dir = tempdir().unwrap();
197        let model_dir = dir.path();
198        let commodities_csv = concat!(
199            "id,description,type,time_slice_level,units\n",
200            "ELCTRI,Electricity,sed,season,PJ\n",
201            "CO2EMT,CO2 emitted,oth,season,ktCO2\n",
202        );
203        fs::write(model_dir.join(COMMODITY_FILE_NAME), commodities_csv).unwrap();
204
205        // Create basic model inputs
206        let commodities = read_commodities_file(model_dir).unwrap();
207
208        let region_ids: IndexSet<RegionID> = ["GBR".into()].into_iter().collect();
209        let time_slice_info = TimeSliceInfo {
210            seasons: [("summer".into(), Year(0.5)), ("winter".into(), Year(0.5))].into(),
211            times_of_day: ["day".into(), "night".into()].into(),
212            time_slices: [
213                (
214                    TimeSliceID {
215                        season: "summer".into(),
216                        time_of_day: "day".into(),
217                    },
218                    Year(0.25),
219                ),
220                (
221                    TimeSliceID {
222                        season: "summer".into(),
223                        time_of_day: "night".into(),
224                    },
225                    Year(0.25),
226                ),
227                (
228                    TimeSliceID {
229                        season: "winter".into(),
230                        time_of_day: "day".into(),
231                    },
232                    Year(0.25),
233                ),
234                (
235                    TimeSliceID {
236                        season: "winter".into(),
237                        time_of_day: "night".into(),
238                    },
239                    Year(0.25),
240                ),
241            ]
242            .into(),
243        };
244
245        let milestone_years = vec![2030];
246
247        let constraints = [
248            CommodityConstraintRaw {
249                commodity_id: "ELCTRI".into(),
250                region_id: "GBR".into(),
251                balance_type: BalanceType::Consumption,
252                years: "2030".into(),
253                time_slice: "summer".into(),
254                limits: "12.34..56.78".into(),
255            },
256            CommodityConstraintRaw {
257                commodity_id: "CO2EMT".into(),
258                region_id: "GBR".into(),
259                balance_type: BalanceType::Consumption,
260                years: "2030".into(),
261                time_slice: "winter".into(),
262                limits: "..9.99".into(),
263            },
264            CommodityConstraintRaw {
265                commodity_id: "CO2EMT".into(),
266                region_id: "GBR".into(),
267                balance_type: BalanceType::Production,
268                years: "2030".into(),
269                time_slice: "summer".into(),
270                limits: "9.99..".into(),
271            },
272        ];
273
274        // Create the constraints map
275        let constraints_map = read_commodity_constraints_from_iter(
276            constraints.into_iter(),
277            &commodities,
278            &region_ids,
279            &time_slice_info,
280            &milestone_years,
281        )
282        .unwrap();
283
284        // ELCTRI constraint
285        let elctri = &constraints_map[&CommodityID::from("ELCTRI")][&2030][0];
286        assert_eq!(elctri.balance_type, BalanceType::Consumption);
287        assert_eq!(
288            elctri.ts_selection,
289            TimeSliceSelection::Season("summer".into())
290        );
291        assert_approx_eq!(f64, elctri.limits.start().value(), 12.34);
292        assert_approx_eq!(f64, elctri.limits.end().value(), 56.78);
293
294        // CO2EMT constraints
295        let co2emt = &constraints_map[&CommodityID::from("CO2EMT")][&2030];
296        assert_eq!(co2emt[0].balance_type, BalanceType::Consumption);
297        assert_eq!(
298            co2emt[0].ts_selection,
299            TimeSliceSelection::Season("winter".into())
300        );
301        assert_approx_eq!(f64, co2emt[0].limits.start().value(), 0.0);
302        assert_approx_eq!(f64, co2emt[0].limits.end().value(), 9.99);
303
304        assert_eq!(co2emt[1].balance_type, BalanceType::Production);
305        assert_eq!(
306            co2emt[1].ts_selection,
307            TimeSliceSelection::Season("summer".into())
308        );
309        assert_approx_eq!(f64, co2emt[1].limits.start().value(), 9.99);
310        assert_approx_eq!(f64, co2emt[1].limits.end().value(), f64::INFINITY);
311    }
312
313    #[test]
314    fn read_commodity_constraints_fails_with_invalid_csv() {
315        // Create a model dir and write invalid CSV content to force
316        // read_commodity_constraints_from_iter failure
317        let dir = tempdir().unwrap();
318        let model_dir = dir.path();
319
320        // Create invalid commodity constraints CSV content
321        let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME);
322        fs::write(&file_path, "invalid,commodity,constraints\nbad,row\n").unwrap();
323
324        // Create empty model inputs
325        let commodities: IndexMap<CommodityID, Commodity> = IndexMap::new();
326        let region_ids: IndexSet<RegionID> = IndexSet::new();
327        let time_slice_info = TimeSliceInfo::default();
328        let milestone_years: Vec<u32> = vec![2020, 2030];
329
330        // Try to create the constraints map
331        let result = read_commodity_constraints(
332            model_dir,
333            &commodities,
334            &region_ids,
335            &time_slice_info,
336            &milestone_years,
337        );
338
339        // Check failure and file path present in error message
340        assert!(result.is_err());
341        let err_text = format!("{:#}", result.unwrap_err());
342        assert!(
343            err_text.contains(COMMODITY_CONSTRAINTS_FILE_NAME),
344            "error message should include file name context, got: {err_text}"
345        );
346    }
347}