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::rc::Rc;
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 = Rc::new(ProcessInvestmentConstraint { addition_limit });
203
204 try_insert(process_map, &(region.clone(), year), constraint.clone())?;
205 }
206 }
207 Ok(map)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::fixture::{assert_error, processes};
214 use crate::region::RegionID;
215 use crate::units::Capacity;
216 use logtest::Logger;
217 use rstest::rstest;
218
219 fn validate_raw_constraint(
220 addition_limit: Option<CapacityPerYear>,
221 capacity_growth_limit: Option<Dimensionless>,
222 growth_seed: Option<Dimensionless>,
223 total_capacity_limit: Option<Capacity>,
224 ) -> Result<()> {
225 let constraint = ProcessInvestmentConstraintRaw {
226 process_id: "test_process".into(),
227 regions: "ALL".into(),
228 commission_years: "2030".into(),
229 addition_limit,
230 capacity_growth_limit,
231 growth_seed,
232 total_capacity_limit,
233 };
234 constraint.validate()
235 }
236
237 #[rstest]
238 fn read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
239 let milestone_years = vec![2008, 2012, 2016];
241
242 let constraints = vec![ProcessInvestmentConstraintRaw {
243 process_id: "process1".into(),
244 regions: "GBR".into(),
245 commission_years: "ALL".into(), addition_limit: Some(CapacityPerYear(100.0)),
247 capacity_growth_limit: Some(Dimensionless(0.5)),
248 growth_seed: Some(Dimensionless(1.0)),
249 total_capacity_limit: Some(Capacity(100.0)),
250 }];
251
252 let result = read_process_investment_constraints_from_iter(
253 constraints.into_iter(),
254 &processes,
255 &milestone_years,
256 )
257 .unwrap();
258
259 let process_id: ProcessID = "process1".into();
260 let process_constraints = result
261 .get(&process_id)
262 .expect("Process constraints should exist");
263
264 let gbr_region: RegionID = "GBR".into();
265
266 assert_eq!(process_constraints.len(), 2);
269 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
270 assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
271
272 let process = processes.get(&process_id).unwrap();
274 for year in process.years.clone() {
275 if ![2012, 2016].contains(&year) {
276 assert!(
277 !process_constraints.contains_key(&(gbr_region.clone(), year)),
278 "Should not contain constraint for year {year}"
279 );
280 }
281 }
282 }
283
284 #[rstest]
285 fn read_process_investment_constraints_from_iter_works(processes: ProcessMap) {
286 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
288
289 let constraints = vec![
291 ProcessInvestmentConstraintRaw {
292 process_id: "process1".into(),
293 regions: "GBR".into(),
294 commission_years: "2010".into(),
295 addition_limit: Some(CapacityPerYear(100.0)),
296 capacity_growth_limit: Some(Dimensionless(0.5)),
297 growth_seed: Some(Dimensionless(1.0)),
298 total_capacity_limit: Some(Capacity(100.0)),
299 },
300 ProcessInvestmentConstraintRaw {
301 process_id: "process1".into(),
302 regions: "ALL".into(),
303 commission_years: "2015".into(),
304 addition_limit: Some(CapacityPerYear(200.0)),
305 capacity_growth_limit: Some(Dimensionless(0.5)),
306 growth_seed: Some(Dimensionless(1.0)),
307 total_capacity_limit: Some(Capacity(100.0)),
308 },
309 ProcessInvestmentConstraintRaw {
310 process_id: "process1".into(),
311 regions: "USA".into(),
312 commission_years: "2020".into(),
313 addition_limit: Some(CapacityPerYear(50.0)),
314 capacity_growth_limit: Some(Dimensionless(0.5)),
315 growth_seed: Some(Dimensionless(1.0)),
316 total_capacity_limit: Some(Capacity(100.0)),
317 },
318 ];
319
320 let result = read_process_investment_constraints_from_iter(
322 constraints.into_iter(),
323 &processes,
324 &milestone_years,
325 )
326 .unwrap();
327
328 let process_id: ProcessID = "process1".into();
330 let process_constraints = result
331 .get(&process_id)
332 .expect("Process constraints should exist");
333
334 let gbr_region: RegionID = "GBR".into();
335 let usa_region: RegionID = "USA".into();
336
337 assert!(
339 !process_constraints.contains_key(&(gbr_region.clone(), 2010)),
340 "GBR 2010 constraint should not exist"
341 );
342
343 let gbr_2015 = process_constraints
345 .get(&(gbr_region, 2015))
346 .expect("GBR 2015 constraint should exist");
347 assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
348
349 let usa_2015 = process_constraints
351 .get(&(usa_region.clone(), 2015))
352 .expect("USA 2015 constraint should exist");
353 assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
354
355 let usa_2020 = process_constraints
357 .get(&(usa_region, 2020))
358 .expect("USA 2020 constraint should exist");
359 assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0 * 5.0)));
360
361 assert_eq!(process_constraints.len(), 3);
363 }
364
365 #[rstest]
366 fn read_constraints_all_regions_all_years(processes: ProcessMap) {
367 let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
369
370 let constraints = vec![ProcessInvestmentConstraintRaw {
372 process_id: "process1".into(),
373 regions: "ALL".into(),
374 commission_years: "ALL".into(),
375 addition_limit: Some(CapacityPerYear(75.0)),
376 capacity_growth_limit: Some(Dimensionless(0.5)),
377 growth_seed: Some(Dimensionless(1.0)),
378 total_capacity_limit: Some(Capacity(100.0)),
379 }];
380
381 let result = read_process_investment_constraints_from_iter(
383 constraints.into_iter(),
384 &processes,
385 &milestone_years,
386 )
387 .unwrap();
388
389 let process_id: ProcessID = "process1".into();
391 let process_constraints = result
392 .get(&process_id)
393 .expect("Process constraints should exist");
394
395 let gbr_region: RegionID = "GBR".into();
396 let usa_region: RegionID = "USA".into();
397
398 for &year in &milestone_years[1..] {
400 let gbr_constraint = process_constraints
401 .get(&(gbr_region.clone(), year))
402 .unwrap_or_else(|| panic!("GBR {year} constraint should exist"));
403 assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
405
406 let usa_constraint = process_constraints
407 .get(&(usa_region.clone(), year))
408 .unwrap_or_else(|| panic!("USA {year} constraint should exist"));
409 assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
410 }
411
412 assert_eq!(process_constraints.len(), 4);
414 }
415
416 #[rstest]
417 fn read_constraints_year_outside_milestone_years(processes: ProcessMap) {
418 let milestone_years = vec![2010, 2015, 2020];
421
422 let constraints = vec![ProcessInvestmentConstraintRaw {
423 process_id: "process1".into(),
424 regions: "GBR".into(),
425 commission_years: "2025".into(), addition_limit: Some(CapacityPerYear(100.0)),
427 capacity_growth_limit: Some(Dimensionless(0.5)),
428 growth_seed: Some(Dimensionless(1.0)),
429 total_capacity_limit: Some(Capacity(100.0)),
430 }];
431
432 let result = read_process_investment_constraints_from_iter(
434 constraints.into_iter(),
435 &processes,
436 &milestone_years,
437 );
438 assert_error!(
439 result,
440 "Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
441 );
442 }
443
444 #[rstest]
445 #[case(Some(CapacityPerYear(10.0)), None, None, None)]
446 #[case(Some(CapacityPerYear(0.0)), None, None, None)]
447 #[case(Some(CapacityPerYear(10.0)), Some(Dimensionless(0.5)), None, None)]
448 #[case(Some(CapacityPerYear(10.0)), Some(Dimensionless(0.0)), None, None)]
449 #[case(Some(CapacityPerYear(10.0)), None, None, Some(Capacity(100.0)))]
450 #[case(Some(CapacityPerYear(10.0)), None, None, Some(Capacity(0.0)))]
451 fn validate_constraints_valid(
452 #[case] addition_limit: Option<CapacityPerYear>,
453 #[case] capacity_growth_limit: Option<Dimensionless>,
454 #[case] growth_seed: Option<Dimensionless>,
455 #[case] total_capacity_limit: Option<Capacity>,
456 ) {
457 let valid = validate_raw_constraint(
460 addition_limit,
461 capacity_growth_limit,
462 growth_seed,
463 total_capacity_limit,
464 );
465 valid.unwrap();
466 }
467
468 #[rstest]
469 #[case(
470 Some(CapacityPerYear(-10.0)),
471 None,
472 None,
473 None,
474 "Invalid value for addition constraint: '-10'; must be non-negative and finite."
475 )]
476 #[case(
477 Some(CapacityPerYear(10.0)),
478 Some(Dimensionless(-0.5)),
479 None,
480 None,
481 "Invalid value for capacity growth constraint: '-0.5'; must be non-negative and finite."
482 )]
483 #[case(
484 Some(CapacityPerYear(10.0)),
485 None,
486 None,
487 Some(Capacity(-100.0)),
488 "Invalid value for total capacity constraint: '-100'; must be non-negative and finite."
489 )]
490 fn validate_constraints_rejects_negative(
491 #[case] addition_limit: Option<CapacityPerYear>,
492 #[case] capacity_growth_limit: Option<Dimensionless>,
493 #[case] growth_seed: Option<Dimensionless>,
494 #[case] total_capacity_limit: Option<Capacity>,
495 #[case] error_msg: &str,
496 ) {
497 let invalid = validate_raw_constraint(
499 addition_limit,
500 capacity_growth_limit,
501 growth_seed,
502 total_capacity_limit,
503 );
504 assert_error!(invalid, error_msg);
505 }
506
507 #[rstest]
508 #[case(
509 Some(CapacityPerYear(f64::INFINITY)),
510 None,
511 None,
512 None,
513 "Invalid value for addition constraint: 'inf'; must be non-negative and finite."
514 )]
515 #[case(
516 Some(CapacityPerYear(10.0)),
517 Some(Dimensionless(f64::INFINITY)),
518 None,
519 None,
520 "Invalid value for capacity growth constraint: 'inf'; must be non-negative and finite."
521 )]
522 #[case(
523 Some(CapacityPerYear(10.0)),
524 None,
525 None,
526 Some(Capacity(f64::INFINITY)),
527 "Invalid value for total capacity constraint: 'inf'; must be non-negative and finite."
528 )]
529 fn validate_constraints_rejects_infinite(
530 #[case] addition_limit: Option<CapacityPerYear>,
531 #[case] capacity_growth_limit: Option<Dimensionless>,
532 #[case] growth_seed: Option<Dimensionless>,
533 #[case] total_capacity_limit: Option<Capacity>,
534 #[case] error_msg: &str,
535 ) {
536 let invalid = validate_raw_constraint(
538 addition_limit,
539 capacity_growth_limit,
540 growth_seed,
541 total_capacity_limit,
542 );
543 assert_error!(invalid, error_msg);
544 }
545
546 #[rstest]
547 #[case(
548 Some(CapacityPerYear(f64::NAN)),
549 None,
550 None,
551 None,
552 "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
553 )]
554 #[case(
555 Some(CapacityPerYear(10.0)),
556 Some(Dimensionless(f64::NAN)),
557 None,
558 None,
559 "Invalid value for capacity growth constraint: 'NaN'; must be non-negative and finite."
560 )]
561 #[case(
562 Some(CapacityPerYear(10.0)),
563 None,
564 None,
565 Some(Capacity(f64::NAN)),
566 "Invalid value for total capacity constraint: 'NaN'; must be non-negative and finite."
567 )]
568 fn validate_constraints_rejects_nan(
569 #[case] addition_limit: Option<CapacityPerYear>,
570 #[case] capacity_growth_limit: Option<Dimensionless>,
571 #[case] growth_seed: Option<Dimensionless>,
572 #[case] total_capacity_limit: Option<Capacity>,
573 #[case] error_msg: &str,
574 ) {
575 let invalid = validate_raw_constraint(
577 addition_limit,
578 capacity_growth_limit,
579 growth_seed,
580 total_capacity_limit,
581 );
582 assert_error!(invalid, error_msg);
583 }
584
585 #[test]
586 fn validate_capacity_growth_limit_warning() {
587 let mut logger = Logger::start();
589 let _ = validate_raw_constraint(
590 Some(CapacityPerYear(10.0)),
591 Some(Dimensionless(5.0)),
592 None,
593 None,
594 );
595 assert_eq!(
596 logger.pop().unwrap().args(),
597 "Interpreting capacity growth constraint '5' as 500%"
598 );
599 }
600
601 #[rstest]
602 #[case(Some(CapacityPerYear(10.0)), None, None, None)]
603 #[case(Some(CapacityPerYear(10.0)), None, Some(Dimensionless(1.0)), None)]
604 #[case(Some(CapacityPerYear(10.0)), None, Some(Dimensionless(42.0)), None)]
605 fn validate_growth_seed_valid(
606 #[case] addition_limit: Option<CapacityPerYear>,
607 #[case] capacity_growth_limit: Option<Dimensionless>,
608 #[case] growth_seed: Option<Dimensionless>,
609 #[case] total_capacity_limit: Option<Capacity>,
610 ) {
611 let valid = validate_raw_constraint(
613 addition_limit,
614 capacity_growth_limit,
615 growth_seed,
616 total_capacity_limit,
617 );
618 valid.unwrap();
619 }
620
621 #[rstest]
622 #[case(
623 Some(CapacityPerYear(10.0)),
624 None,
625 Some(Dimensionless(0.9)),
626 None,
627 "Invalid value for growth seed: '0.9'; must be greater than or equal to 1, and finite."
628 )]
629 #[case(
630 Some(CapacityPerYear(10.0)),
631 None,
632 Some(Dimensionless(-42.0)),
633 None,
634 "Invalid value for growth seed: '-42'; must be greater than or equal to 1, and finite.",
635 )]
636 #[case(
637 Some(CapacityPerYear(10.0)),
638 None,
639 Some(Dimensionless(f64::NAN)),
640 None,
641 "Invalid value for growth seed: 'NaN'; must be greater than or equal to 1, and finite."
642 )]
643 #[case(
644 Some(CapacityPerYear(10.0)),
645 None,
646 Some(Dimensionless(f64::INFINITY)),
647 None,
648 "Invalid value for growth seed: 'inf'; must be greater than or equal to 1, and finite."
649 )]
650 fn validate_growth_seed_invalid(
651 #[case] addition_limit: Option<CapacityPerYear>,
652 #[case] capacity_growth_limit: Option<Dimensionless>,
653 #[case] growth_seed: Option<Dimensionless>,
654 #[case] total_capacity_limit: Option<Capacity>,
655 #[case] error_msg: &str,
656 ) {
657 let invalid = validate_raw_constraint(
659 addition_limit,
660 capacity_growth_limit,
661 growth_seed,
662 total_capacity_limit,
663 );
664 assert_error!(invalid, error_msg);
665 }
666
667 #[test]
668 fn validate_constraints_combination_rejects_all_undefined() {
669 let invalid = validate_raw_constraint(None, None, Some(Dimensionless(1.25)), None);
672 assert_error!(
673 invalid,
674 "Invalid investment constraints for 'test_process': cannot have all process \
675 investment constraints undefined; rather omit the record for that process/\
676 region/year."
677 );
678 }
679}