1use 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#[derive(PartialEq, Debug, Deserialize)]
27struct CommodityConstraintRaw {
28 commodity_id: String,
30 region_id: String,
32 balance_type: BalanceType,
34 years: String,
36 time_slice: String,
38 limits: String,
40}
41
42impl CommodityConstraintRaw {
43 fn validate(&self) -> Result<()> {
44 ensure!(
46 self.balance_type != BalanceType::Net,
47 "Balance type cannot be 'net' for commodity constraints"
48 );
49
50 Ok(())
51 }
52}
53
54pub 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
95fn 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 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 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 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 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 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 let constraints_map = read_commodity_constraints_from_iter(
276 constraints.into_iter(),
277 &commodities,
278 ®ion_ids,
279 &time_slice_info,
280 &milestone_years,
281 )
282 .unwrap();
283
284 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 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 let dir = tempdir().unwrap();
318 let model_dir = dir.path();
319
320 let file_path = model_dir.join(COMMODITY_CONSTRAINTS_FILE_NAME);
322 fs::write(&file_path, "invalid,commodity,constraints\nbad,row\n").unwrap();
323
324 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 let result = read_commodity_constraints(
332 model_dir,
333 &commodities,
334 ®ion_ids,
335 &time_slice_info,
336 &milestone_years,
337 );
338
339 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}