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