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::{CapacityPerYear, Year};
11use anyhow::{Context, Result, ensure};
12use itertools::iproduct;
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::path::Path;
16use std::rc::Rc;
17
18const PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME: &str = "process_investment_constraints.csv";
19
20/// Represents a row of the process investment constraints CSV file
21#[derive(PartialEq, Debug, Deserialize)]
22struct ProcessInvestmentConstraintRaw {
23    process_id: String,
24    regions: String,
25    commission_years: String,
26    addition_limit: CapacityPerYear,
27}
28
29impl ProcessInvestmentConstraintRaw {
30    /// Validate the constraint record for logical consistency and required fields
31    fn validate(&self) -> Result<()> {
32        // Validate that value is finite
33        ensure!(
34            self.addition_limit.is_finite() && self.addition_limit >= CapacityPerYear(0.0),
35            "Invalid value for addition constraint: '{}'; must be non-negative and finite.",
36            self.addition_limit
37        );
38
39        Ok(())
40    }
41}
42
43/// Read the process investment constraints CSV file.
44///
45/// This file contains information about investment constraints that limit how processes can be
46/// deployed (growth rates, absolute additions, capacity limits).
47///
48/// # Arguments
49///
50/// * `model_dir` - Folder containing model configuration files
51/// * `processes` - Map of processes to validate against
52/// * `milestone_years` - Milestone years of simulation to validate against
53///
54/// # Returns
55///
56/// A `HashMap<ProcessID, ProcessInvestmentConstraintsMap>` mapping process IDs to their
57/// investment-constraints maps, or an error.
58pub fn read_process_investment_constraints(
59    model_dir: &Path,
60    processes: &ProcessMap,
61    milestone_years: &[u32],
62) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>> {
63    let file_path = model_dir.join(PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME);
64    let constraints_csv = read_csv_optional(&file_path)?;
65    read_process_investment_constraints_from_iter(constraints_csv, processes, milestone_years)
66        .with_context(|| input_err_msg(&file_path))
67}
68
69/// Process raw investment-constraint records into a constraints map.
70///
71/// # Arguments
72///
73/// * `iter` - Iterator over `ProcessInvestmentConstraintRaw` records
74/// * `processes` - Map of known processes to validate against
75/// * `milestone_years` - Milestone years used by the model
76///
77/// # Returns
78///
79/// A `HashMap<ProcessID, ProcessInvestmentConstraintsMap>` mapping process IDs to their
80/// investment-constraints maps, or an error.
81fn read_process_investment_constraints_from_iter<I>(
82    iter: I,
83    processes: &ProcessMap,
84    milestone_years: &[u32],
85) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>>
86where
87    I: Iterator<Item = ProcessInvestmentConstraintRaw>,
88{
89    let mut map: HashMap<ProcessID, ProcessInvestmentConstraintsMap> = HashMap::new();
90
91    for record in iter {
92        // Validate the raw record
93        record.validate()?;
94
95        // Verify the process exists
96        let (process_id, process) = processes.get_id_value(&record.process_id)?;
97
98        // Parse and validate regions
99        let process_regions = &process.regions;
100        let record_regions =
101            parse_region_str(&record.regions, process_regions).with_context(|| {
102                format!(
103                    "Invalid region for process {process_id}. Valid regions are {process_regions:?}"
104                )
105            })?;
106
107        // Parse associated commission years
108        let milestone_years_in_process_range: Vec<u32> = milestone_years
109            .iter()
110            .copied()
111            .filter(|year| process.years.contains(year))
112            .collect();
113        let constraint_years = parse_year_str(&record.commission_years, &milestone_years_in_process_range)
114            .with_context(|| {
115                format!(
116                    "Invalid year for constraint on process {process_id}. Valid years are {milestone_years_in_process_range:?}",
117                )
118            })?;
119
120        // Create constraints for each region and year combination
121        // For a given milestone year, the addition limit should be multiplied
122        // by the number of years since the previous milestone year. Any
123        // addition limits specified for the first milestone year are ignored.
124        let process_map = map.entry(process_id.clone()).or_default();
125        for (region, &year) in iproduct!(&record_regions, &constraint_years) {
126            // Calculate years since previous milestone year
127            // We can ignore constraints in the first milestone year as no investments are performed then
128            let idx = milestone_years.iter().position(|y| *y == year).expect(
129                "Year should be in milestone_years since it was validated by parse_year_str",
130            );
131            if idx == 0 {
132                continue;
133            }
134            let prev_year = milestone_years[idx - 1];
135            let years_since_prev = year - prev_year;
136
137            // Multiply the addition limit by the number of years since previous milestone.
138            let scaled_limit = record.addition_limit * Year(years_since_prev as f64);
139
140            let constraint = Rc::new(ProcessInvestmentConstraint {
141                addition_limit: Some(scaled_limit),
142            });
143
144            try_insert(process_map, &(region.clone(), year), constraint.clone())?;
145        }
146    }
147    Ok(map)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::fixture::{assert_error, processes};
154    use crate::region::RegionID;
155    use crate::units::Capacity;
156    use rstest::rstest;
157
158    fn validate_raw_constraint(addition_limit: CapacityPerYear) -> Result<()> {
159        let constraint = ProcessInvestmentConstraintRaw {
160            process_id: "test_process".into(),
161            regions: "ALL".into(),
162            commission_years: "2030".into(),
163            addition_limit,
164        };
165        constraint.validate()
166    }
167
168    #[rstest]
169    fn read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
170        // Process years are 2010..=2020 from the fixture (excludes 2008)
171        let milestone_years = vec![2008, 2012, 2016];
172
173        let constraints = vec![ProcessInvestmentConstraintRaw {
174            process_id: "process1".into(),
175            regions: "GBR".into(),
176            commission_years: "ALL".into(), // Should apply to milestone years [2012, 2016]
177            addition_limit: CapacityPerYear(100.0),
178        }];
179
180        let result = read_process_investment_constraints_from_iter(
181            constraints.into_iter(),
182            &processes,
183            &milestone_years,
184        )
185        .unwrap();
186
187        let process_id: ProcessID = "process1".into();
188        let process_constraints = result
189            .get(&process_id)
190            .expect("Process constraints should exist");
191
192        let gbr_region: RegionID = "GBR".into();
193
194        // Should have constraints for milestone years within process year
195        // range
196        assert_eq!(process_constraints.len(), 2);
197        assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
198        assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
199
200        // All other years should not have constraints
201        let process = processes.get(&process_id).unwrap();
202        for year in process.years.clone() {
203            if ![2012, 2016].contains(&year) {
204                assert!(
205                    !process_constraints.contains_key(&(gbr_region.clone(), year)),
206                    "Should not contain constraint for year {year}"
207                );
208            }
209        }
210    }
211
212    #[rstest]
213    fn read_process_investment_constraints_from_iter_works(processes: ProcessMap) {
214        // Create milestone years matching the process years
215        let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
216
217        // Create constraint records for the test process
218        let constraints = vec![
219            ProcessInvestmentConstraintRaw {
220                process_id: "process1".into(),
221                regions: "GBR".into(),
222                commission_years: "2010".into(),
223                addition_limit: CapacityPerYear(100.0),
224            },
225            ProcessInvestmentConstraintRaw {
226                process_id: "process1".into(),
227                regions: "ALL".into(),
228                commission_years: "2015".into(),
229                addition_limit: CapacityPerYear(200.0),
230            },
231            ProcessInvestmentConstraintRaw {
232                process_id: "process1".into(),
233                regions: "USA".into(),
234                commission_years: "2020".into(),
235                addition_limit: CapacityPerYear(50.0),
236            },
237        ];
238
239        // Read constraints into the map
240        let result = read_process_investment_constraints_from_iter(
241            constraints.into_iter(),
242            &processes,
243            &milestone_years,
244        )
245        .unwrap();
246
247        // Verify the constraints were correctly stored
248        let process_id: ProcessID = "process1".into();
249        let process_constraints = result
250            .get(&process_id)
251            .expect("Process constraints should exist");
252
253        let gbr_region: RegionID = "GBR".into();
254        let usa_region: RegionID = "USA".into();
255
256        // GBR 2010 constraint is for the first milestone year and should be ignored
257        assert!(
258            !process_constraints.contains_key(&(gbr_region.clone(), 2010)),
259            "GBR 2010 constraint should not exist"
260        );
261
262        // Check GBR 2015 constraint (from ALL regions), scaled by years since previous milestone (5 years)
263        let gbr_2015 = process_constraints
264            .get(&(gbr_region, 2015))
265            .expect("GBR 2015 constraint should exist");
266        assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
267
268        // Check USA 2015 constraint (from ALL regions), scaled by 5 years
269        let usa_2015 = process_constraints
270            .get(&(usa_region.clone(), 2015))
271            .expect("USA 2015 constraint should exist");
272        assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
273
274        // Check USA 2020 constraint, scaled by years since previous milestone (5 years)
275        let usa_2020 = process_constraints
276            .get(&(usa_region, 2020))
277            .expect("USA 2020 constraint should exist");
278        assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0 * 5.0)));
279
280        // Verify total number of constraints (GBR 2015, USA 2015, USA 2020 = 3)
281        assert_eq!(process_constraints.len(), 3);
282    }
283
284    #[rstest]
285    fn read_constraints_all_regions_all_years(processes: ProcessMap) {
286        // Create milestone years matching the process years
287        let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
288
289        // Create a constraint that applies to all regions and all years
290        let constraints = vec![ProcessInvestmentConstraintRaw {
291            process_id: "process1".into(),
292            regions: "ALL".into(),
293            commission_years: "ALL".into(),
294            addition_limit: CapacityPerYear(75.0),
295        }];
296
297        // Read constraints into the map
298        let result = read_process_investment_constraints_from_iter(
299            constraints.into_iter(),
300            &processes,
301            &milestone_years,
302        )
303        .unwrap();
304
305        // Verify the constraints were correctly stored
306        let process_id: ProcessID = "process1".into();
307        let process_constraints = result
308            .get(&process_id)
309            .expect("Process constraints should exist");
310
311        let gbr_region: RegionID = "GBR".into();
312        let usa_region: RegionID = "USA".into();
313
314        // Verify constraint exists for all region-year combinations except the first milestone year
315        for &year in &milestone_years[1..] {
316            let gbr_constraint = process_constraints
317                .get(&(gbr_region.clone(), year))
318                .unwrap_or_else(|| panic!("GBR {year} constraint should exist"));
319            // scaled by years since previous milestone (5 years)
320            assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
321
322            let usa_constraint = process_constraints
323                .get(&(usa_region.clone(), year))
324                .unwrap_or_else(|| panic!("USA {year} constraint should exist"));
325            assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
326        }
327
328        // Verify total number of constraints (2 regions × 2 years = 4)
329        assert_eq!(process_constraints.len(), 4);
330    }
331
332    #[rstest]
333    fn read_constraints_year_outside_milestone_years(processes: ProcessMap) {
334        // Create constraint with year outside milestone years
335        // Process years are 2010..=2020 from the fixture
336        let milestone_years = vec![2010, 2015, 2020];
337
338        let constraints = vec![ProcessInvestmentConstraintRaw {
339            process_id: "process1".into(),
340            regions: "GBR".into(),
341            commission_years: "2025".into(), // Outside milestone years (2010-2020)
342            addition_limit: CapacityPerYear(100.0),
343        }];
344
345        // Should fail with milestone year validation error
346        let result = read_process_investment_constraints_from_iter(
347            constraints.into_iter(),
348            &processes,
349            &milestone_years,
350        );
351        assert_error!(
352            result,
353            "Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
354        );
355    }
356
357    #[test]
358    fn validate_addition_with_finite_value() {
359        // Valid: addition constraint with positive value
360        let valid = validate_raw_constraint(CapacityPerYear(10.0));
361        valid.unwrap();
362
363        // Valid: addition constraint with zero value
364        let valid = validate_raw_constraint(CapacityPerYear(0.0));
365        valid.unwrap();
366
367        // Not valid: addition constraint with negative value
368        let invalid = validate_raw_constraint(CapacityPerYear(-10.0));
369        assert_error!(
370            invalid,
371            "Invalid value for addition constraint: '-10'; must be non-negative and finite."
372        );
373    }
374
375    #[test]
376    fn validate_addition_rejects_infinite() {
377        // Invalid: infinite value
378        let invalid = validate_raw_constraint(CapacityPerYear(f64::INFINITY));
379        assert_error!(
380            invalid,
381            "Invalid value for addition constraint: 'inf'; must be non-negative and finite."
382        );
383
384        // Invalid: NaN value
385        let invalid = validate_raw_constraint(CapacityPerYear(f64::NAN));
386        assert_error!(
387            invalid,
388            "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
389        );
390    }
391}