Skip to main content

muse2/input/process/
investment_constraints.rs

1//! Code for reading process investment constraints from a CSV file.
2use 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/// Represents a row of the process investment constraints CSV file
22#[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    /// Validate the constraint record for logical consistency and required fields
35    fn validate(&self) -> Result<()> {
36        self.validate_constraints_combination()?;
37
38        // Validate addition_limit
39        if let Some(limit) = self.addition_limit {
40            Self::validate_constraint_value(limit, "addition")?;
41        }
42
43        // Validate capacity_growth_limit
44        if let Some(limit) = self.capacity_growth_limit {
45            Self::validate_constraint_value(limit, "capacity growth")?;
46
47            // Warn user they may have specified limit as percentage, rather than fraction
48            if limit > Dimensionless(1.0) {
49                warn!(
50                    "Interpreting capacity growth constraint '{limit}' as {}%",
51                    limit * Dimensionless(100.0)
52                );
53            }
54        }
55
56        // Validate growth seed: must be in range [1, inf)
57        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        // Validate total_capacity_limit
66        if let Some(limit) = self.total_capacity_limit {
67            Self::validate_constraint_value(limit, "total capacity")?;
68        }
69
70        Ok(())
71    }
72
73    /// Check whether the limit is finite, otherwise return an error
74    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    /// Check that the combination of constraints is valid
86    fn validate_constraints_combination(&self) -> Result<()> {
87        // Allow any of addition_limit, capacity_growth_limit and total_capacity_limit to be empty,
88        // but not all three.
89        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
102/// Read the process investment constraints CSV file.
103///
104/// This file contains information about investment constraints that limit how processes can be
105/// deployed (growth rates, absolute additions, capacity limits).
106///
107/// # Arguments
108///
109/// * `model_dir` - Folder containing model configuration files
110/// * `processes` - Map of processes to validate against
111/// * `milestone_years` - Milestone years of simulation to validate against
112///
113/// # Returns
114///
115/// A `HashMap<ProcessID, ProcessInvestmentConstraintsMap>` mapping process IDs to their
116/// investment-constraints maps, or an error.
117pub 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
128/// Process raw investment-constraint records into a constraints map.
129///
130/// # Arguments
131///
132/// * `iter` - Iterator over `ProcessInvestmentConstraintRaw` records
133/// * `processes` - Map of known processes to validate against
134/// * `milestone_years` - Milestone years used by the model
135///
136/// # Returns
137///
138/// A `HashMap<ProcessID, ProcessInvestmentConstraintsMap>` mapping process IDs to their
139/// investment-constraints maps, or an error.
140fn 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        // Validate the raw record
152        record.validate()?;
153
154        // Verify the process exists
155        let (process_id, process) = processes.get_id_value(&record.process_id)?;
156
157        // Parse and validate regions
158        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        // Parse associated commission years
167        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        // Create constraints for each region and year combination
180        // For a given milestone year, the addition limit should be multiplied
181        // by the number of years since the previous milestone year. Any
182        // addition limits specified for the first milestone year are ignored.
183        let process_map = map.entry(process_id.clone()).or_default();
184        for (region, &year) in iproduct!(&record_regions, &constraint_years) {
185            // Calculate years since previous milestone year
186            // We can ignore constraints in the first milestone year as no investments are performed then
187            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            // Multiply the addition limit (if provided) by the number of years since previous
197            // milestone.
198            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        // Process years are 2010..=2020 from the fixture (excludes 2008)
240        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(), // Should apply to milestone years [2012, 2016]
246            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        // Should have constraints for milestone years within process year
267        // range
268        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        // All other years should not have constraints
273        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        // Create milestone years matching the process years
287        let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
288
289        // Create constraint records for the test process
290        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        // Read constraints into the map
321        let result = read_process_investment_constraints_from_iter(
322            constraints.into_iter(),
323            &processes,
324            &milestone_years,
325        )
326        .unwrap();
327
328        // Verify the constraints were correctly stored
329        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        // GBR 2010 constraint is for the first milestone year and should be ignored
338        assert!(
339            !process_constraints.contains_key(&(gbr_region.clone(), 2010)),
340            "GBR 2010 constraint should not exist"
341        );
342
343        // Check GBR 2015 constraint (from ALL regions), scaled by years since previous milestone (5 years)
344        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        // Check USA 2015 constraint (from ALL regions), scaled by 5 years
350        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        // Check USA 2020 constraint, scaled by years since previous milestone (5 years)
356        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        // Verify total number of constraints (GBR 2015, USA 2015, USA 2020 = 3)
362        assert_eq!(process_constraints.len(), 3);
363    }
364
365    #[rstest]
366    fn read_constraints_all_regions_all_years(processes: ProcessMap) {
367        // Create milestone years matching the process years
368        let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
369
370        // Create a constraint that applies to all regions and all years
371        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        // Read constraints into the map
382        let result = read_process_investment_constraints_from_iter(
383            constraints.into_iter(),
384            &processes,
385            &milestone_years,
386        )
387        .unwrap();
388
389        // Verify the constraints were correctly stored
390        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        // Verify constraint exists for all region-year combinations except the first milestone year
399        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            // scaled by years since previous milestone (5 years)
404            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        // Verify total number of constraints (2 regions × 2 years = 4)
413        assert_eq!(process_constraints.len(), 4);
414    }
415
416    #[rstest]
417    fn read_constraints_year_outside_milestone_years(processes: ProcessMap) {
418        // Create constraint with year outside milestone years
419        // Process years are 2010..=2020 from the fixture
420        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(), // Outside milestone years (2010-2020)
426            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        // Should fail with milestone year validation error
433        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        // Valid: capacity constraints with values >= 0, and capacity_growth_limit and
458        // total_capacity_limit as None
459        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        // Not valid: capacity constraints with negative value
498        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        // Not valid: capacity constraints with infinite value
537        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        // Not valid: capacity constraints with NaN value
576        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        // Check warning raised if value above 1 provided
588        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        // Valid: growth seed with finite values >= 1, or None
612        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        // Invalid: growth seed with values < 1, NaN or inf
658        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        // Check an error is raised if all of addition_limit, capacity_growth_limit and
670        // total_capacity_limit are undefined.
671        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}