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