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::{Capacity, CapacityPerYear, Dimensionless, UnitType, Year};
11use anyhow::{Context, Result, ensure};
12use itertools::iproduct;
13use log::warn;
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::path::Path;
17use std::sync::Arc;
18
19const PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME: &str = "process_investment_constraints.csv";
20
21#[derive(PartialEq, Debug, Deserialize)]
23struct ProcessInvestmentConstraintRaw {
24 process_id: String,
25 regions: String,
26 commission_years: String,
27 addition_limit: Option<CapacityPerYear>,
28 capacity_growth_limit: Option<Dimensionless>,
29 growth_seed: Option<Dimensionless>,
30 total_capacity_limit: Option<Capacity>,
31}
32
33impl ProcessInvestmentConstraintRaw {
34 fn validate(&self) -> Result<()> {
36 self.validate_constraints_combination()?;
37
38 if let Some(limit) = self.addition_limit {
40 Self::validate_constraint_value(limit, "addition")?;
41 }
42
43 if let Some(limit) = self.capacity_growth_limit {
45 Self::validate_constraint_value(limit, "capacity growth")?;
46
47 if limit > Dimensionless(1.0) {
49 warn!(
50 "Interpreting capacity growth constraint '{limit}' as {}%",
51 limit * Dimensionless(100.0)
52 );
53 }
54 }
55
56 if let Some(growth_seed) = self.growth_seed {
58 ensure!(
59 growth_seed.is_finite() && growth_seed >= Dimensionless(1.0),
60 "Invalid value for growth seed: '{growth_seed}'; must be greater than or equal to \
61 1, and finite.",
62 );
63 }
64
65 if let Some(limit) = self.total_capacity_limit {
67 Self::validate_constraint_value(limit, "total capacity")?;
68 }
69
70 Ok(())
71 }
72
73 fn validate_constraint_value<T: UnitType>(constraint: T, constraint_name: &str) -> Result<()> {
75 ensure!(
76 constraint.is_finite() && constraint.value() >= 0.0,
77 "Invalid value for {} constraint: '{}'; must be non-negative and finite.",
78 constraint_name,
79 constraint.value(),
80 );
81
82 Ok(())
83 }
84
85 fn validate_constraints_combination(&self) -> Result<()> {
87 ensure!(
90 !(self.addition_limit.is_none()
91 && self.capacity_growth_limit.is_none()
92 && self.total_capacity_limit.is_none()),
93 "Invalid investment constraints for '{}': cannot have all process investment \
94 constraints undefined; rather omit the record for that process/region/year.",
95 self.process_id
96 );
97
98 Ok(())
99 }
100}
101
102pub fn read_process_investment_constraints(
118 model_dir: &Path,
119 processes: &ProcessMap,
120 milestone_years: &[u32],
121) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>> {
122 let file_path = model_dir.join(PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME);
123 let constraints_csv = read_csv_optional(&file_path)?;
124 read_process_investment_constraints_from_iter(constraints_csv, processes, milestone_years)
125 .with_context(|| input_err_msg(&file_path))
126}
127
128fn read_process_investment_constraints_from_iter<I>(
141 iter: I,
142 processes: &ProcessMap,
143 milestone_years: &[u32],
144) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>>
145where
146 I: Iterator<Item = ProcessInvestmentConstraintRaw>,
147{
148 let mut map: HashMap<ProcessID, ProcessInvestmentConstraintsMap> = HashMap::new();
149
150 for record in iter {
151 record.validate()?;
153
154 let (process_id, process) = processes.get_id_value(&record.process_id)?;
156
157 let process_regions = &process.regions;
159 let record_regions =
160 parse_region_str(&record.regions, process_regions).with_context(|| {
161 format!(
162 "Invalid region for process {process_id}. Valid regions are {process_regions:?}"
163 )
164 })?;
165
166 let milestone_years_in_process_range: Vec<u32> = milestone_years
168 .iter()
169 .copied()
170 .filter(|year| process.years.contains(year))
171 .collect();
172 let constraint_years = parse_year_str(&record.commission_years, &milestone_years_in_process_range)
173 .with_context(|| {
174 format!(
175 "Invalid year for constraint on process {process_id}. Valid years are {milestone_years_in_process_range:?}",
176 )
177 })?;
178
179 let process_map = map.entry(process_id.clone()).or_default();
184 for (region, &year) in iproduct!(&record_regions, &constraint_years) {
185 let idx = milestone_years.iter().position(|y| *y == year).expect(
188 "Year should be in milestone_years since it was validated by parse_year_str",
189 );
190 if idx == 0 {
191 continue;
192 }
193 let prev_year = milestone_years[idx - 1];
194 let years_since_prev = year - prev_year;
195
196 let addition_limit: Option<Capacity> = record
199 .addition_limit
200 .map(|limit| limit * Year(years_since_prev as f64));
201
202 let constraint = Arc::new(ProcessInvestmentConstraint {
203 addition_limit,
204 total_capacity_limit: record.total_capacity_limit,
205 });
206
207 try_insert(process_map, &(region.clone(), year), constraint.clone())?;
208 }
209 }
210 Ok(map)
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::fixture::{assert_error, processes};
217 use crate::region::RegionID;
218 use crate::units::Capacity;
219 use rstest::rstest;
220
221 fn validate_raw_constraint(
222 addition_limit: Option<CapacityPerYear>,
223 capacity_growth_limit: Option<Dimensionless>,
224 growth_seed: Option<Dimensionless>,
225 total_capacity_limit: Option<Capacity>,
226 ) -> Result<()> {
227 let constraint = ProcessInvestmentConstraintRaw {
228 process_id: "test_process".into(),
229 regions: "ALL".into(),
230 commission_years: "2030".into(),
231 addition_limit,
232 capacity_growth_limit,
233 growth_seed,
234 total_capacity_limit,
235 };
236 constraint.validate()
237 }
238
239 #[rstest]
240 fn read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
241 let milestone_years = vec![2008, 2012, 2016];
243
244 let constraints = vec![ProcessInvestmentConstraintRaw {
245 process_id: "process1".into(),
246 regions: "GBR".into(),
247 commission_years: "ALL".into(), addition_limit: Some(CapacityPerYear(100.0)),
249 capacity_growth_limit: Some(Dimensionless(0.5)),
250 growth_seed: Some(Dimensionless(1.0)),
251 total_capacity_limit: Some(Capacity(100.0)),
252 }];
253
254 let result = read_process_investment_constraints_from_iter(
255 constraints.into_iter(),
256 &processes,
257 &milestone_years,
258 )
259 .unwrap();
260
261 let process_id: ProcessID = "process1".into();
262 let process_constraints = result
263 .get(&process_id)
264 .expect("Process constraints should exist");
265
266 let gbr_region: RegionID = "GBR".into();
267
268 assert_eq!(process_constraints.len(), 2);
271 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
272 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
273
274 let process = processes.get(&process_id).unwrap();
276 for year in process.years.clone() {
277 if ![2012, 2016].contains(&year) {
278 assert!(
279 !process_constraints.contains_key(&(gbr_region.clone(), year)),
280 "Should not contain constraint for year {year}"
281 );
282 }
283 }
284 }
285
286 #[rstest]
287 fn read_process_investment_constraints_from_iter_works(processes: ProcessMap) {
288 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
290
291 let constraints = vec![
293 ProcessInvestmentConstraintRaw {
294 process_id: "process1".into(),
295 regions: "GBR".into(),
296 commission_years: "2010".into(),
297 addition_limit: Some(CapacityPerYear(100.0)),
298 capacity_growth_limit: Some(Dimensionless(0.5)),
299 growth_seed: Some(Dimensionless(1.0)),
300 total_capacity_limit: Some(Capacity(100.0)),
301 },
302 ProcessInvestmentConstraintRaw {
303 process_id: "process1".into(),
304 regions: "ALL".into(),
305 commission_years: "2015".into(),
306 addition_limit: Some(CapacityPerYear(200.0)),
307 capacity_growth_limit: Some(Dimensionless(0.5)),
308 growth_seed: Some(Dimensionless(1.0)),
309 total_capacity_limit: Some(Capacity(100.0)),
310 },
311 ProcessInvestmentConstraintRaw {
312 process_id: "process1".into(),
313 regions: "USA".into(),
314 commission_years: "2020".into(),
315 addition_limit: Some(CapacityPerYear(50.0)),
316 capacity_growth_limit: Some(Dimensionless(0.5)),
317 growth_seed: Some(Dimensionless(1.0)),
318 total_capacity_limit: Some(Capacity(100.0)),
319 },
320 ];
321
322 let result = read_process_investment_constraints_from_iter(
324 constraints.into_iter(),
325 &processes,
326 &milestone_years,
327 )
328 .unwrap();
329
330 let process_id: ProcessID = "process1".into();
332 let process_constraints = result
333 .get(&process_id)
334 .expect("Process constraints should exist");
335
336 let gbr_region: RegionID = "GBR".into();
337 let usa_region: RegionID = "USA".into();
338
339 assert!(
341 !process_constraints.contains_key(&(gbr_region.clone(), 2010)),
342 "GBR 2010 constraint should not exist"
343 );
344
345 let gbr_2015 = process_constraints
347 .get(&(gbr_region, 2015))
348 .expect("GBR 2015 constraint should exist");
349 assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
350
351 let usa_2015 = process_constraints
353 .get(&(usa_region.clone(), 2015))
354 .expect("USA 2015 constraint should exist");
355 assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
356
357 let usa_2020 = process_constraints
359 .get(&(usa_region, 2020))
360 .expect("USA 2020 constraint should exist");
361 assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0 * 5.0)));
362
363 assert_eq!(process_constraints.len(), 3);
365 }
366
367 #[rstest]
368 fn read_constraints_all_regions_all_years(processes: ProcessMap) {
369 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
371
372 let constraints = vec![ProcessInvestmentConstraintRaw {
374 process_id: "process1".into(),
375 regions: "ALL".into(),
376 commission_years: "ALL".into(),
377 addition_limit: Some(CapacityPerYear(75.0)),
378 capacity_growth_limit: Some(Dimensionless(0.5)),
379 growth_seed: Some(Dimensionless(1.0)),
380 total_capacity_limit: Some(Capacity(100.0)),
381 }];
382
383 let result = read_process_investment_constraints_from_iter(
385 constraints.into_iter(),
386 &processes,
387 &milestone_years,
388 )
389 .unwrap();
390
391 let process_id: ProcessID = "process1".into();
393 let process_constraints = result
394 .get(&process_id)
395 .expect("Process constraints should exist");
396
397 let gbr_region: RegionID = "GBR".into();
398 let usa_region: RegionID = "USA".into();
399
400 for &year in &milestone_years[1..] {
402 let gbr_constraint = process_constraints
403 .get(&(gbr_region.clone(), year))
404 .unwrap_or_else(|| panic!("GBR {year} constraint should exist"));
405 assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
407
408 let usa_constraint = process_constraints
409 .get(&(usa_region.clone(), year))
410 .unwrap_or_else(|| panic!("USA {year} constraint should exist"));
411 assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
412 }
413
414 assert_eq!(process_constraints.len(), 4);
416 }
417
418 #[rstest]
419 fn read_constraints_year_outside_milestone_years(processes: ProcessMap) {
420 let milestone_years = vec![2010, 2015, 2020];
423
424 let constraints = vec![ProcessInvestmentConstraintRaw {
425 process_id: "process1".into(),
426 regions: "GBR".into(),
427 commission_years: "2025".into(), addition_limit: Some(CapacityPerYear(100.0)),
429 capacity_growth_limit: Some(Dimensionless(0.5)),
430 growth_seed: Some(Dimensionless(1.0)),
431 total_capacity_limit: Some(Capacity(100.0)),
432 }];
433
434 let result = read_process_investment_constraints_from_iter(
436 constraints.into_iter(),
437 &processes,
438 &milestone_years,
439 );
440 assert_error!(
441 result,
442 "Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
443 );
444 }
445
446 #[rstest]
447 #[case(Some(CapacityPerYear(10.0)), None, None)] #[case(Some(CapacityPerYear(0.0)), None, None)] #[case(None, Some(Dimensionless(0.5)), None)] #[case(None, Some(Dimensionless(0.0)), None)] #[case(None, Some(Dimensionless(1.0)), None)] #[case(None, Some(Dimensionless(5.0)), None)] #[case(None, None, Some(Capacity(100.0)))] #[case(None, None, Some(Capacity(0.0)))] #[case(
456 Some(CapacityPerYear(1.0)),
457 Some(Dimensionless(0.1)),
458 Some(Capacity(100.0))
459 )] #[case(
461 Some(CapacityPerYear(0.0)),
462 Some(Dimensionless(0.0)),
463 Some(Capacity(0.0))
464 )] fn validate_constraints_valid(
466 #[case] addition_limit: Option<CapacityPerYear>,
467 #[case] capacity_growth_limit: Option<Dimensionless>,
468 #[case] total_capacity_limit: Option<Capacity>,
469 ) {
470 let valid = validate_raw_constraint(
471 addition_limit,
472 capacity_growth_limit,
473 None,
474 total_capacity_limit,
475 );
476 valid.unwrap();
477 }
478
479 #[rstest]
480 #[case(
481 Some(CapacityPerYear(-10.0)),
482 None,
483 None,
484 None,
485 "Invalid value for addition constraint: '-10'; must be non-negative and finite."
486 )]
487 #[case(
488 Some(CapacityPerYear(10.0)),
489 Some(Dimensionless(-0.5)),
490 None,
491 None,
492 "Invalid value for capacity growth constraint: '-0.5'; must be non-negative and finite."
493 )]
494 #[case(
495 Some(CapacityPerYear(10.0)),
496 None,
497 None,
498 Some(Capacity(-100.0)),
499 "Invalid value for total capacity constraint: '-100'; must be non-negative and finite."
500 )]
501 fn validate_constraints_rejects_negative(
502 #[case] addition_limit: Option<CapacityPerYear>,
503 #[case] capacity_growth_limit: Option<Dimensionless>,
504 #[case] growth_seed: Option<Dimensionless>,
505 #[case] total_capacity_limit: Option<Capacity>,
506 #[case] error_msg: &str,
507 ) {
508 let invalid = validate_raw_constraint(
510 addition_limit,
511 capacity_growth_limit,
512 growth_seed,
513 total_capacity_limit,
514 );
515 assert_error!(invalid, error_msg);
516 }
517
518 #[rstest]
519 #[case(
520 Some(CapacityPerYear(f64::INFINITY)),
521 None,
522 None,
523 None,
524 "Invalid value for addition constraint: 'inf'; must be non-negative and finite."
525 )]
526 #[case(
527 Some(CapacityPerYear(10.0)),
528 Some(Dimensionless(f64::INFINITY)),
529 None,
530 None,
531 "Invalid value for capacity growth constraint: 'inf'; must be non-negative and finite."
532 )]
533 #[case(
534 Some(CapacityPerYear(10.0)),
535 None,
536 None,
537 Some(Capacity(f64::INFINITY)),
538 "Invalid value for total capacity constraint: 'inf'; must be non-negative and finite."
539 )]
540 fn validate_constraints_rejects_infinite(
541 #[case] addition_limit: Option<CapacityPerYear>,
542 #[case] capacity_growth_limit: Option<Dimensionless>,
543 #[case] growth_seed: Option<Dimensionless>,
544 #[case] total_capacity_limit: Option<Capacity>,
545 #[case] error_msg: &str,
546 ) {
547 let invalid = validate_raw_constraint(
549 addition_limit,
550 capacity_growth_limit,
551 growth_seed,
552 total_capacity_limit,
553 );
554 assert_error!(invalid, error_msg);
555 }
556
557 #[rstest]
558 #[case(
559 Some(CapacityPerYear(f64::NAN)),
560 None,
561 None,
562 None,
563 "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
564 )]
565 #[case(
566 Some(CapacityPerYear(10.0)),
567 Some(Dimensionless(f64::NAN)),
568 None,
569 None,
570 "Invalid value for capacity growth constraint: 'NaN'; must be non-negative and finite."
571 )]
572 #[case(
573 Some(CapacityPerYear(10.0)),
574 None,
575 None,
576 Some(Capacity(f64::NAN)),
577 "Invalid value for total capacity constraint: 'NaN'; must be non-negative and finite."
578 )]
579 fn validate_constraints_rejects_nan(
580 #[case] addition_limit: Option<CapacityPerYear>,
581 #[case] capacity_growth_limit: Option<Dimensionless>,
582 #[case] growth_seed: Option<Dimensionless>,
583 #[case] total_capacity_limit: Option<Capacity>,
584 #[case] error_msg: &str,
585 ) {
586 let invalid = validate_raw_constraint(
588 addition_limit,
589 capacity_growth_limit,
590 growth_seed,
591 total_capacity_limit,
592 );
593 assert_error!(invalid, error_msg);
594 }
595
596 #[rstest]
597 #[case(Some(CapacityPerYear(10.0)), None, None, None)]
598 #[case(Some(CapacityPerYear(10.0)), None, Some(Dimensionless(1.0)), None)]
599 #[case(Some(CapacityPerYear(10.0)), None, Some(Dimensionless(42.0)), None)]
600 fn validate_growth_seed_valid(
601 #[case] addition_limit: Option<CapacityPerYear>,
602 #[case] capacity_growth_limit: Option<Dimensionless>,
603 #[case] growth_seed: Option<Dimensionless>,
604 #[case] total_capacity_limit: Option<Capacity>,
605 ) {
606 let valid = validate_raw_constraint(
608 addition_limit,
609 capacity_growth_limit,
610 growth_seed,
611 total_capacity_limit,
612 );
613 valid.unwrap();
614 }
615
616 #[rstest]
617 #[case(
618 Some(CapacityPerYear(10.0)),
619 None,
620 Some(Dimensionless(0.9)),
621 None,
622 "Invalid value for growth seed: '0.9'; must be greater than or equal to 1, and finite."
623 )]
624 #[case(
625 Some(CapacityPerYear(10.0)),
626 None,
627 Some(Dimensionless(-42.0)),
628 None,
629 "Invalid value for growth seed: '-42'; must be greater than or equal to 1, and finite.",
630 )]
631 #[case(
632 Some(CapacityPerYear(10.0)),
633 None,
634 Some(Dimensionless(f64::NAN)),
635 None,
636 "Invalid value for growth seed: 'NaN'; must be greater than or equal to 1, and finite."
637 )]
638 #[case(
639 Some(CapacityPerYear(10.0)),
640 None,
641 Some(Dimensionless(f64::INFINITY)),
642 None,
643 "Invalid value for growth seed: 'inf'; must be greater than or equal to 1, and finite."
644 )]
645 fn validate_growth_seed_invalid(
646 #[case] addition_limit: Option<CapacityPerYear>,
647 #[case] capacity_growth_limit: Option<Dimensionless>,
648 #[case] growth_seed: Option<Dimensionless>,
649 #[case] total_capacity_limit: Option<Capacity>,
650 #[case] error_msg: &str,
651 ) {
652 let invalid = validate_raw_constraint(
654 addition_limit,
655 capacity_growth_limit,
656 growth_seed,
657 total_capacity_limit,
658 );
659 assert_error!(invalid, error_msg);
660 }
661
662 #[test]
663 fn validate_constraints_combination_rejects_all_undefined() {
664 let invalid = validate_raw_constraint(None, None, Some(Dimensionless(1.25)), None);
667 assert_error!(
668 invalid,
669 "Invalid investment constraints for 'test_process': cannot have all process \
670 investment constraints undefined; rather omit the record for that process/\
671 region/year."
672 );
673 }
674}