1use super::super::input_err_msg;
3use crate::id::GetIDValue;
4use crate::input::parse_year_str;
5use crate::input::{read_csv_optional, try_insert};
6use crate::process::{
7 ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap,
8};
9use crate::region::parse_region_str;
10use crate::units::{CapacityPerYear, Year};
11use anyhow::{Context, Result, ensure};
12use itertools::iproduct;
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::path::Path;
16use std::rc::Rc;
17
18const PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME: &str = "process_investment_constraints.csv";
19
20#[derive(PartialEq, Debug, Deserialize)]
22struct ProcessInvestmentConstraintRaw {
23 process_id: String,
24 regions: String,
25 commission_years: String,
26 addition_limit: CapacityPerYear,
27}
28
29impl ProcessInvestmentConstraintRaw {
30 fn validate(&self) -> Result<()> {
32 ensure!(
34 self.addition_limit.is_finite() && self.addition_limit >= CapacityPerYear(0.0),
35 "Invalid value for addition constraint: '{}'; must be non-negative and finite.",
36 self.addition_limit
37 );
38
39 Ok(())
40 }
41}
42
43pub fn read_process_investment_constraints(
59 model_dir: &Path,
60 processes: &ProcessMap,
61 milestone_years: &[u32],
62) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>> {
63 let file_path = model_dir.join(PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME);
64 let constraints_csv = read_csv_optional(&file_path)?;
65 read_process_investment_constraints_from_iter(constraints_csv, processes, milestone_years)
66 .with_context(|| input_err_msg(&file_path))
67}
68
69fn read_process_investment_constraints_from_iter<I>(
82 iter: I,
83 processes: &ProcessMap,
84 milestone_years: &[u32],
85) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>>
86where
87 I: Iterator<Item = ProcessInvestmentConstraintRaw>,
88{
89 let mut map: HashMap<ProcessID, ProcessInvestmentConstraintsMap> = HashMap::new();
90
91 for record in iter {
92 record.validate()?;
94
95 let (process_id, process) = processes.get_id_value(&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 process_map = map.entry(process_id.clone()).or_default();
125 for (region, &year) in iproduct!(&record_regions, &constraint_years) {
126 let idx = milestone_years.iter().position(|y| *y == year).expect(
129 "Year should be in milestone_years since it was validated by parse_year_str",
130 );
131 if idx == 0 {
132 continue;
133 }
134 let prev_year = milestone_years[idx - 1];
135 let years_since_prev = year - prev_year;
136
137 let scaled_limit = record.addition_limit * Year(years_since_prev as f64);
139
140 let constraint = Rc::new(ProcessInvestmentConstraint {
141 addition_limit: Some(scaled_limit),
142 });
143
144 try_insert(process_map, &(region.clone(), year), constraint.clone())?;
145 }
146 }
147 Ok(map)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::fixture::{assert_error, processes};
154 use crate::region::RegionID;
155 use crate::units::Capacity;
156 use rstest::rstest;
157
158 fn validate_raw_constraint(addition_limit: CapacityPerYear) -> Result<()> {
159 let constraint = ProcessInvestmentConstraintRaw {
160 process_id: "test_process".into(),
161 regions: "ALL".into(),
162 commission_years: "2030".into(),
163 addition_limit,
164 };
165 constraint.validate()
166 }
167
168 #[rstest]
169 fn read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
170 let milestone_years = vec![2008, 2012, 2016];
172
173 let constraints = vec![ProcessInvestmentConstraintRaw {
174 process_id: "process1".into(),
175 regions: "GBR".into(),
176 commission_years: "ALL".into(), addition_limit: CapacityPerYear(100.0),
178 }];
179
180 let result = read_process_investment_constraints_from_iter(
181 constraints.into_iter(),
182 &processes,
183 &milestone_years,
184 )
185 .unwrap();
186
187 let process_id: ProcessID = "process1".into();
188 let process_constraints = result
189 .get(&process_id)
190 .expect("Process constraints should exist");
191
192 let gbr_region: RegionID = "GBR".into();
193
194 assert_eq!(process_constraints.len(), 2);
197 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
198 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
199
200 let process = processes.get(&process_id).unwrap();
202 for year in process.years.clone() {
203 if ![2012, 2016].contains(&year) {
204 assert!(
205 !process_constraints.contains_key(&(gbr_region.clone(), year)),
206 "Should not contain constraint for year {year}"
207 );
208 }
209 }
210 }
211
212 #[rstest]
213 fn read_process_investment_constraints_from_iter_works(processes: ProcessMap) {
214 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
216
217 let constraints = vec![
219 ProcessInvestmentConstraintRaw {
220 process_id: "process1".into(),
221 regions: "GBR".into(),
222 commission_years: "2010".into(),
223 addition_limit: CapacityPerYear(100.0),
224 },
225 ProcessInvestmentConstraintRaw {
226 process_id: "process1".into(),
227 regions: "ALL".into(),
228 commission_years: "2015".into(),
229 addition_limit: CapacityPerYear(200.0),
230 },
231 ProcessInvestmentConstraintRaw {
232 process_id: "process1".into(),
233 regions: "USA".into(),
234 commission_years: "2020".into(),
235 addition_limit: CapacityPerYear(50.0),
236 },
237 ];
238
239 let result = read_process_investment_constraints_from_iter(
241 constraints.into_iter(),
242 &processes,
243 &milestone_years,
244 )
245 .unwrap();
246
247 let process_id: ProcessID = "process1".into();
249 let process_constraints = result
250 .get(&process_id)
251 .expect("Process constraints should exist");
252
253 let gbr_region: RegionID = "GBR".into();
254 let usa_region: RegionID = "USA".into();
255
256 assert!(
258 !process_constraints.contains_key(&(gbr_region.clone(), 2010)),
259 "GBR 2010 constraint should not exist"
260 );
261
262 let gbr_2015 = process_constraints
264 .get(&(gbr_region, 2015))
265 .expect("GBR 2015 constraint should exist");
266 assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
267
268 let usa_2015 = process_constraints
270 .get(&(usa_region.clone(), 2015))
271 .expect("USA 2015 constraint should exist");
272 assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
273
274 let usa_2020 = process_constraints
276 .get(&(usa_region, 2020))
277 .expect("USA 2020 constraint should exist");
278 assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0 * 5.0)));
279
280 assert_eq!(process_constraints.len(), 3);
282 }
283
284 #[rstest]
285 fn read_constraints_all_regions_all_years(processes: ProcessMap) {
286 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
288
289 let constraints = vec![ProcessInvestmentConstraintRaw {
291 process_id: "process1".into(),
292 regions: "ALL".into(),
293 commission_years: "ALL".into(),
294 addition_limit: CapacityPerYear(75.0),
295 }];
296
297 let result = read_process_investment_constraints_from_iter(
299 constraints.into_iter(),
300 &processes,
301 &milestone_years,
302 )
303 .unwrap();
304
305 let process_id: ProcessID = "process1".into();
307 let process_constraints = result
308 .get(&process_id)
309 .expect("Process constraints should exist");
310
311 let gbr_region: RegionID = "GBR".into();
312 let usa_region: RegionID = "USA".into();
313
314 for &year in &milestone_years[1..] {
316 let gbr_constraint = process_constraints
317 .get(&(gbr_region.clone(), year))
318 .unwrap_or_else(|| panic!("GBR {year} constraint should exist"));
319 assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
321
322 let usa_constraint = process_constraints
323 .get(&(usa_region.clone(), year))
324 .unwrap_or_else(|| panic!("USA {year} constraint should exist"));
325 assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
326 }
327
328 assert_eq!(process_constraints.len(), 4);
330 }
331
332 #[rstest]
333 fn read_constraints_year_outside_milestone_years(processes: ProcessMap) {
334 let milestone_years = vec![2010, 2015, 2020];
337
338 let constraints = vec![ProcessInvestmentConstraintRaw {
339 process_id: "process1".into(),
340 regions: "GBR".into(),
341 commission_years: "2025".into(), addition_limit: CapacityPerYear(100.0),
343 }];
344
345 let result = read_process_investment_constraints_from_iter(
347 constraints.into_iter(),
348 &processes,
349 &milestone_years,
350 );
351 assert_error!(
352 result,
353 "Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
354 );
355 }
356
357 #[test]
358 fn validate_addition_with_finite_value() {
359 let valid = validate_raw_constraint(CapacityPerYear(10.0));
361 valid.unwrap();
362
363 let valid = validate_raw_constraint(CapacityPerYear(0.0));
365 valid.unwrap();
366
367 let invalid = validate_raw_constraint(CapacityPerYear(-10.0));
369 assert_error!(
370 invalid,
371 "Invalid value for addition constraint: '-10'; must be non-negative and finite."
372 );
373 }
374
375 #[test]
376 fn validate_addition_rejects_infinite() {
377 let invalid = validate_raw_constraint(CapacityPerYear(f64::INFINITY));
379 assert_error!(
380 invalid,
381 "Invalid value for addition constraint: 'inf'; must be non-negative and finite."
382 );
383
384 let invalid = validate_raw_constraint(CapacityPerYear(f64::NAN));
386 assert_error!(
387 invalid,
388 "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
389 );
390 }
391}