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(
56 model_dir: &Path,
57 processes: &ProcessMap,
58 milestone_years: &[u32],
59) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>> {
60 let file_path = model_dir.join(PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME);
61 let constraints_csv = read_csv_optional(&file_path)?;
62 read_process_investment_constraints_from_iter(constraints_csv, processes, milestone_years)
63 .with_context(|| input_err_msg(&file_path))
64}
65
66fn read_process_investment_constraints_from_iter<I>(
78 iter: I,
79 processes: &ProcessMap,
80 milestone_years: &[u32],
81) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>>
82where
83 I: Iterator<Item = ProcessInvestmentConstraintRaw>,
84{
85 let mut map: HashMap<ProcessID, ProcessInvestmentConstraintsMap> = HashMap::new();
86
87 for record in iter {
88 record.validate()?;
90
91 let (process_id, process) = processes
93 .get_key_value(record.process_id.as_str())
94 .with_context(|| format!("Process {} not found", record.process_id))?;
95
96 let process_regions = &process.regions;
98 let record_regions =
99 parse_region_str(&record.regions, process_regions).with_context(|| {
100 format!(
101 "Invalid region for process {process_id}. Valid regions are {process_regions:?}"
102 )
103 })?;
104
105 let milestone_years_in_process_range: Vec<u32> = milestone_years
107 .iter()
108 .copied()
109 .filter(|year| process.years.contains(year))
110 .collect();
111 let constraint_years = parse_year_str(&record.commission_years, &milestone_years_in_process_range)
112 .with_context(|| {
113 format!(
114 "Invalid year for constraint on process {process_id}. Valid years are {milestone_years_in_process_range:?}",
115 )
116 })?;
117
118 let constraint = Rc::new(ProcessInvestmentConstraint {
120 addition_limit: Some(record.addition_limit),
121 });
122 let process_map = map.entry(process_id.clone()).or_default();
123 for (region, &year) in iproduct!(&record_regions, &constraint_years) {
124 try_insert(process_map, &(region.clone(), year), constraint.clone())?;
125 }
126 }
127 Ok(map)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::fixture::{assert_error, processes};
134 use crate::region::RegionID;
135 use rstest::rstest;
136
137 fn validate_raw_constraint(addition_limit: f64) -> Result<()> {
138 let constraint = ProcessInvestmentConstraintRaw {
139 process_id: "test_process".into(),
140 regions: "ALL".into(),
141 commission_years: "2030".into(),
142 addition_limit: addition_limit,
143 };
144 constraint.validate()
145 }
146
147 #[rstest]
148 fn test_read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
149 let milestone_years = vec![2008, 2012, 2016];
151
152 let constraints = vec![ProcessInvestmentConstraintRaw {
153 process_id: "process1".into(),
154 regions: "GBR".into(),
155 commission_years: "ALL".into(), addition_limit: 100.0,
157 }];
158
159 let result = read_process_investment_constraints_from_iter(
160 constraints.into_iter(),
161 &processes,
162 &milestone_years,
163 )
164 .unwrap();
165
166 let process_id: ProcessID = "process1".into();
167 let process_constraints = result
168 .get(&process_id)
169 .expect("Process constraints should exist");
170
171 let gbr_region: RegionID = "GBR".into();
172
173 assert_eq!(process_constraints.len(), 2);
176 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
177 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
178
179 let process = processes.get(&process_id).unwrap();
181 for year in process.years.clone() {
182 if ![2012, 2016].contains(&year) {
183 assert!(
184 !process_constraints.contains_key(&(gbr_region.clone(), year)),
185 "Should not contain constraint for year {}",
186 year
187 );
188 }
189 }
190 }
191
192 #[rstest]
193 fn test_read_process_investment_constraints_from_iter(processes: ProcessMap) {
194 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
196
197 let constraints = vec![
199 ProcessInvestmentConstraintRaw {
200 process_id: "process1".into(),
201 regions: "GBR".into(),
202 commission_years: "2010".into(),
203 addition_limit: 100.0,
204 },
205 ProcessInvestmentConstraintRaw {
206 process_id: "process1".into(),
207 regions: "ALL".into(),
208 commission_years: "2015".into(),
209 addition_limit: 200.0,
210 },
211 ProcessInvestmentConstraintRaw {
212 process_id: "process1".into(),
213 regions: "USA".into(),
214 commission_years: "2020".into(),
215 addition_limit: 50.0,
216 },
217 ];
218
219 let result = read_process_investment_constraints_from_iter(
221 constraints.into_iter(),
222 &processes,
223 &milestone_years,
224 )
225 .unwrap();
226
227 let process_id: ProcessID = "process1".into();
229 let process_constraints = result
230 .get(&process_id)
231 .expect("Process constraints should exist");
232
233 let gbr_region: RegionID = "GBR".into();
234 let usa_region: RegionID = "USA".into();
235
236 let gbr_2010 = process_constraints
238 .get(&(gbr_region.clone(), 2010))
239 .expect("GBR 2010 constraint should exist");
240 assert_eq!(gbr_2010.addition_limit, Some(100.0));
241
242 let gbr_2015 = process_constraints
244 .get(&(gbr_region, 2015))
245 .expect("GBR 2015 constraint should exist");
246 assert_eq!(gbr_2015.addition_limit, Some(200.0));
247
248 let usa_2015 = process_constraints
250 .get(&(usa_region.clone(), 2015))
251 .expect("USA 2015 constraint should exist");
252 assert_eq!(usa_2015.addition_limit, Some(200.0));
253
254 let usa_2020 = process_constraints
256 .get(&(usa_region, 2020))
257 .expect("USA 2020 constraint should exist");
258 assert_eq!(usa_2020.addition_limit, Some(50.0));
259
260 assert_eq!(process_constraints.len(), 4);
262 }
263
264 #[rstest]
265 fn test_read_constraints_all_regions_all_years(processes: ProcessMap) {
266 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
268
269 let constraints = vec![ProcessInvestmentConstraintRaw {
271 process_id: "process1".into(),
272 regions: "ALL".into(),
273 commission_years: "ALL".into(),
274 addition_limit: 75.0,
275 }];
276
277 let result = read_process_investment_constraints_from_iter(
279 constraints.into_iter(),
280 &processes,
281 &milestone_years,
282 )
283 .unwrap();
284
285 let process_id: ProcessID = "process1".into();
287 let process_constraints = result
288 .get(&process_id)
289 .expect("Process constraints should exist");
290
291 let gbr_region: RegionID = "GBR".into();
292 let usa_region: RegionID = "USA".into();
293
294 for &year in &milestone_years {
296 let gbr_constraint = process_constraints
297 .get(&(gbr_region.clone(), year))
298 .expect(&format!("GBR {} constraint should exist", year));
299 assert_eq!(gbr_constraint.addition_limit, Some(75.0));
300
301 let usa_constraint = process_constraints
302 .get(&(usa_region.clone(), year))
303 .expect(&format!("USA {} constraint should exist", year));
304 assert_eq!(usa_constraint.addition_limit, Some(75.0));
305 }
306
307 assert_eq!(process_constraints.len(), 6);
309 }
310
311 #[rstest]
312 fn test_read_constraints_year_outside_milestone_years(processes: ProcessMap) {
313 let milestone_years = vec![2010, 2015, 2020];
316
317 let constraints = vec![ProcessInvestmentConstraintRaw {
318 process_id: "process1".into(),
319 regions: "GBR".into(),
320 commission_years: "2025".into(), addition_limit: 100.0,
322 }];
323
324 let result = read_process_investment_constraints_from_iter(
326 constraints.into_iter(),
327 &processes,
328 &milestone_years,
329 );
330 assert_error!(
331 result,
332 "Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
333 );
334 }
335
336 #[test]
337 fn test_validate_addition_with_finite_value() {
338 let valid = validate_raw_constraint(10.0);
340 assert!(valid.is_ok());
341
342 let valid = validate_raw_constraint(0.0);
344 assert!(valid.is_ok());
345
346 let invalid = validate_raw_constraint(-10.0);
348 assert_error!(
349 invalid,
350 "Invalid value for addition constraint: '-10'; must be non-negative and finite."
351 );
352 }
353
354 #[test]
355 fn test_validate_addition_rejects_infinite() {
356 let invalid = validate_raw_constraint(f64::INFINITY);
358 assert_error!(
359 invalid,
360 "Invalid value for addition constraint: 'inf'; must be non-negative and finite."
361 );
362
363 let invalid = validate_raw_constraint(f64::NAN);
365 assert_error!(
366 invalid,
367 "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
368 );
369 }
370}