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