1use super::super::input_err_msg;
3use crate::input::{read_csv_optional, try_insert};
4use crate::process::{
5 ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap,
6};
7use crate::region::parse_region_str;
8use crate::year::parse_year_str;
9use anyhow::{Context, Result, ensure};
10use itertools::iproduct;
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::Path;
14use std::rc::Rc;
15
16const PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME: &str = "process_investment_constraints.csv";
17
18#[derive(PartialEq, Debug, Deserialize)]
20struct ProcessInvestmentConstraintRaw {
21 process_id: String,
22 regions: String,
23 commission_years: String,
24 addition_limit: f64,
25}
26
27impl ProcessInvestmentConstraintRaw {
28 fn validate(&self) -> Result<()> {
30 ensure!(
32 self.addition_limit.is_finite() && self.addition_limit >= 0.0,
33 "Invalid value for addition constraint: '{}'; must be non-negative and finite.",
34 self.addition_limit
35 );
36
37 Ok(())
38 }
39}
40
41pub fn read_process_investment_constraints(
57 model_dir: &Path,
58 processes: &ProcessMap,
59 milestone_years: &[u32],
60) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>> {
61 let file_path = model_dir.join(PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME);
62 let constraints_csv = read_csv_optional(&file_path)?;
63 read_process_investment_constraints_from_iter(constraints_csv, processes, milestone_years)
64 .with_context(|| input_err_msg(&file_path))
65}
66
67fn read_process_investment_constraints_from_iter<I>(
80 iter: I,
81 processes: &ProcessMap,
82 milestone_years: &[u32],
83) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>>
84where
85 I: Iterator<Item = ProcessInvestmentConstraintRaw>,
86{
87 let mut map: HashMap<ProcessID, ProcessInvestmentConstraintsMap> = HashMap::new();
88
89 for record in iter {
90 record.validate()?;
92
93 let (process_id, process) = processes
95 .get_key_value(record.process_id.as_str())
96 .with_context(|| format!("Process {} not found", record.process_id))?;
97
98 let process_regions = &process.regions;
100 let record_regions =
101 parse_region_str(&record.regions, process_regions).with_context(|| {
102 format!(
103 "Invalid region for process {process_id}. Valid regions are {process_regions:?}"
104 )
105 })?;
106
107 let milestone_years_in_process_range: Vec<u32> = milestone_years
109 .iter()
110 .copied()
111 .filter(|year| process.years.contains(year))
112 .collect();
113 let constraint_years = parse_year_str(&record.commission_years, &milestone_years_in_process_range)
114 .with_context(|| {
115 format!(
116 "Invalid year for constraint on process {process_id}. Valid years are {milestone_years_in_process_range:?}",
117 )
118 })?;
119
120 let constraint = Rc::new(ProcessInvestmentConstraint {
122 addition_limit: Some(record.addition_limit),
123 });
124 let process_map = map.entry(process_id.clone()).or_default();
125 for (region, &year) in iproduct!(&record_regions, &constraint_years) {
126 try_insert(process_map, &(region.clone(), year), constraint.clone())?;
127 }
128 }
129 Ok(map)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::fixture::{assert_error, processes};
136 use crate::region::RegionID;
137 use rstest::rstest;
138
139 fn validate_raw_constraint(addition_limit: f64) -> Result<()> {
140 let constraint = ProcessInvestmentConstraintRaw {
141 process_id: "test_process".into(),
142 regions: "ALL".into(),
143 commission_years: "2030".into(),
144 addition_limit,
145 };
146 constraint.validate()
147 }
148
149 #[rstest]
150 fn read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
151 let milestone_years = vec![2008, 2012, 2016];
153
154 let constraints = vec![ProcessInvestmentConstraintRaw {
155 process_id: "process1".into(),
156 regions: "GBR".into(),
157 commission_years: "ALL".into(), addition_limit: 100.0,
159 }];
160
161 let result = read_process_investment_constraints_from_iter(
162 constraints.into_iter(),
163 &processes,
164 &milestone_years,
165 )
166 .unwrap();
167
168 let process_id: ProcessID = "process1".into();
169 let process_constraints = result
170 .get(&process_id)
171 .expect("Process constraints should exist");
172
173 let gbr_region: RegionID = "GBR".into();
174
175 assert_eq!(process_constraints.len(), 2);
178 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
179 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
180
181 let process = processes.get(&process_id).unwrap();
183 for year in process.years.clone() {
184 if ![2012, 2016].contains(&year) {
185 assert!(
186 !process_constraints.contains_key(&(gbr_region.clone(), year)),
187 "Should not contain constraint for year {year}"
188 );
189 }
190 }
191 }
192
193 #[rstest]
194 fn read_process_investment_constraints_from_iter_works(processes: ProcessMap) {
195 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
197
198 let constraints = vec![
200 ProcessInvestmentConstraintRaw {
201 process_id: "process1".into(),
202 regions: "GBR".into(),
203 commission_years: "2010".into(),
204 addition_limit: 100.0,
205 },
206 ProcessInvestmentConstraintRaw {
207 process_id: "process1".into(),
208 regions: "ALL".into(),
209 commission_years: "2015".into(),
210 addition_limit: 200.0,
211 },
212 ProcessInvestmentConstraintRaw {
213 process_id: "process1".into(),
214 regions: "USA".into(),
215 commission_years: "2020".into(),
216 addition_limit: 50.0,
217 },
218 ];
219
220 let result = read_process_investment_constraints_from_iter(
222 constraints.into_iter(),
223 &processes,
224 &milestone_years,
225 )
226 .unwrap();
227
228 let process_id: ProcessID = "process1".into();
230 let process_constraints = result
231 .get(&process_id)
232 .expect("Process constraints should exist");
233
234 let gbr_region: RegionID = "GBR".into();
235 let usa_region: RegionID = "USA".into();
236
237 let gbr_2010 = process_constraints
239 .get(&(gbr_region.clone(), 2010))
240 .expect("GBR 2010 constraint should exist");
241 assert_eq!(gbr_2010.addition_limit, Some(100.0));
242
243 let gbr_2015 = process_constraints
245 .get(&(gbr_region, 2015))
246 .expect("GBR 2015 constraint should exist");
247 assert_eq!(gbr_2015.addition_limit, Some(200.0));
248
249 let usa_2015 = process_constraints
251 .get(&(usa_region.clone(), 2015))
252 .expect("USA 2015 constraint should exist");
253 assert_eq!(usa_2015.addition_limit, Some(200.0));
254
255 let usa_2020 = process_constraints
257 .get(&(usa_region, 2020))
258 .expect("USA 2020 constraint should exist");
259 assert_eq!(usa_2020.addition_limit, Some(50.0));
260
261 assert_eq!(process_constraints.len(), 4);
263 }
264
265 #[rstest]
266 fn read_constraints_all_regions_all_years(processes: ProcessMap) {
267 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
269
270 let constraints = vec![ProcessInvestmentConstraintRaw {
272 process_id: "process1".into(),
273 regions: "ALL".into(),
274 commission_years: "ALL".into(),
275 addition_limit: 75.0,
276 }];
277
278 let result = read_process_investment_constraints_from_iter(
280 constraints.into_iter(),
281 &processes,
282 &milestone_years,
283 )
284 .unwrap();
285
286 let process_id: ProcessID = "process1".into();
288 let process_constraints = result
289 .get(&process_id)
290 .expect("Process constraints should exist");
291
292 let gbr_region: RegionID = "GBR".into();
293 let usa_region: RegionID = "USA".into();
294
295 for &year in &milestone_years {
297 let gbr_constraint = process_constraints
298 .get(&(gbr_region.clone(), year))
299 .unwrap_or_else(|| panic!("GBR {year} constraint should exist"));
300 assert_eq!(gbr_constraint.addition_limit, Some(75.0));
301
302 let usa_constraint = process_constraints
303 .get(&(usa_region.clone(), year))
304 .unwrap_or_else(|| panic!("USA {year} constraint should exist"));
305 assert_eq!(usa_constraint.addition_limit, Some(75.0));
306 }
307
308 assert_eq!(process_constraints.len(), 6);
310 }
311
312 #[rstest]
313 fn read_constraints_year_outside_milestone_years(processes: ProcessMap) {
314 let milestone_years = vec![2010, 2015, 2020];
317
318 let constraints = vec![ProcessInvestmentConstraintRaw {
319 process_id: "process1".into(),
320 regions: "GBR".into(),
321 commission_years: "2025".into(), addition_limit: 100.0,
323 }];
324
325 let result = read_process_investment_constraints_from_iter(
327 constraints.into_iter(),
328 &processes,
329 &milestone_years,
330 );
331 assert_error!(
332 result,
333 "Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
334 );
335 }
336
337 #[test]
338 fn validate_addition_with_finite_value() {
339 let valid = validate_raw_constraint(10.0);
341 valid.unwrap();
342
343 let valid = validate_raw_constraint(0.0);
345 valid.unwrap();
346
347 let invalid = validate_raw_constraint(-10.0);
349 assert_error!(
350 invalid,
351 "Invalid value for addition constraint: '-10'; must be non-negative and finite."
352 );
353 }
354
355 #[test]
356 fn validate_addition_rejects_infinite() {
357 let invalid = validate_raw_constraint(f64::INFINITY);
359 assert_error!(
360 invalid,
361 "Invalid value for addition constraint: 'inf'; must be non-negative and finite."
362 );
363
364 let invalid = validate_raw_constraint(f64::NAN);
366 assert_error!(
367 invalid,
368 "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
369 );
370 }
371}