muse2/input/process/
investment_constraints.rs

1//! Code for reading process investment constraints 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, either through growth rates, absolute additions, or 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 map keyed by process ID containing investment constraints maps, or an error.
55pub fn read_process_investment_constraints(
56    model_dir: &Path,
57    processes: &ProcessMap,
58    milestone_years: &[u32],
59) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>> {
60    let file_path = model_dir.join(PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME);
61    let constraints_csv = read_csv_optional(&file_path)?;
62    read_process_investment_constraints_from_iter(constraints_csv, processes, milestone_years)
63        .with_context(|| input_err_msg(&file_path))
64}
65
66/// Process raw process investment constraint input data into a constraints map.
67///
68/// # Arguments
69///
70/// * `iter` - Iterator of raw process investment constraint records
71/// * `processes` - Map of processes to validate against
72/// * `milestone_years` - Milestone years of simulation to validate against
73///
74/// # Returns
75///
76/// A map keyed by process ID containing investment constraints maps, or an error.
77fn read_process_investment_constraints_from_iter<I>(
78    iter: I,
79    processes: &ProcessMap,
80    milestone_years: &[u32],
81) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>>
82where
83    I: Iterator<Item = ProcessInvestmentConstraintRaw>,
84{
85    let mut map: HashMap<ProcessID, ProcessInvestmentConstraintsMap> = HashMap::new();
86
87    for record in iter {
88        // Validate the raw record
89        record.validate()?;
90
91        // Verify the process exists
92        let (process_id, process) = processes
93            .get_key_value(record.process_id.as_str())
94            .with_context(|| format!("Process {} not found", record.process_id))?;
95
96        // Parse and validate regions
97        let process_regions = &process.regions;
98        let record_regions =
99            parse_region_str(&record.regions, process_regions).with_context(|| {
100                format!(
101                    "Invalid region for process {process_id}. Valid regions are {process_regions:?}"
102                )
103            })?;
104
105        // Parse associated commission years
106        let milestone_years_in_process_range: Vec<u32> = milestone_years
107            .iter()
108            .copied()
109            .filter(|year| process.years.contains(year))
110            .collect();
111        let constraint_years = parse_year_str(&record.commission_years, &milestone_years_in_process_range)
112            .with_context(|| {
113                format!(
114                    "Invalid year for constraint on process {process_id}. Valid years are {milestone_years_in_process_range:?}",
115                )
116            })?;
117
118        // Create constraints for each region and year combination
119        let constraint = Rc::new(ProcessInvestmentConstraint {
120            addition_limit: Some(record.addition_limit),
121        });
122        let process_map = map.entry(process_id.clone()).or_default();
123        for (region, &year) in iproduct!(&record_regions, &constraint_years) {
124            try_insert(process_map, &(region.clone(), year), constraint.clone())?;
125        }
126    }
127    Ok(map)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::fixture::{assert_error, processes};
134    use crate::region::RegionID;
135    use rstest::rstest;
136
137    fn validate_raw_constraint(addition_limit: f64) -> Result<()> {
138        let constraint = ProcessInvestmentConstraintRaw {
139            process_id: "test_process".into(),
140            regions: "ALL".into(),
141            commission_years: "2030".into(),
142            addition_limit: addition_limit,
143        };
144        constraint.validate()
145    }
146
147    #[rstest]
148    fn test_read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
149        // Process years are 2010..=2020 from the fixture (excludes 2008)
150        let milestone_years = vec![2008, 2012, 2016];
151
152        let constraints = vec![ProcessInvestmentConstraintRaw {
153            process_id: "process1".into(),
154            regions: "GBR".into(),
155            commission_years: "ALL".into(), // Should apply to milestone years [2012, 2016]
156            addition_limit: 100.0,
157        }];
158
159        let result = read_process_investment_constraints_from_iter(
160            constraints.into_iter(),
161            &processes,
162            &milestone_years,
163        )
164        .unwrap();
165
166        let process_id: ProcessID = "process1".into();
167        let process_constraints = result
168            .get(&process_id)
169            .expect("Process constraints should exist");
170
171        let gbr_region: RegionID = "GBR".into();
172
173        // Should have constraints for milestone years within process year
174        // range
175        assert_eq!(process_constraints.len(), 2);
176        assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
177        assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
178
179        // All other years should not have constraints
180        let process = processes.get(&process_id).unwrap();
181        for year in process.years.clone() {
182            if ![2012, 2016].contains(&year) {
183                assert!(
184                    !process_constraints.contains_key(&(gbr_region.clone(), year)),
185                    "Should not contain constraint for year {}",
186                    year
187                );
188            }
189        }
190    }
191
192    #[rstest]
193    fn test_read_process_investment_constraints_from_iter(processes: ProcessMap) {
194        // Create milestone years matching the process years
195        let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
196
197        // Create constraint records for the test process
198        let constraints = vec![
199            ProcessInvestmentConstraintRaw {
200                process_id: "process1".into(),
201                regions: "GBR".into(),
202                commission_years: "2010".into(),
203                addition_limit: 100.0,
204            },
205            ProcessInvestmentConstraintRaw {
206                process_id: "process1".into(),
207                regions: "ALL".into(),
208                commission_years: "2015".into(),
209                addition_limit: 200.0,
210            },
211            ProcessInvestmentConstraintRaw {
212                process_id: "process1".into(),
213                regions: "USA".into(),
214                commission_years: "2020".into(),
215                addition_limit: 50.0,
216            },
217        ];
218
219        // Read constraints into the map
220        let result = read_process_investment_constraints_from_iter(
221            constraints.into_iter(),
222            &processes,
223            &milestone_years,
224        )
225        .unwrap();
226
227        // Verify the constraints were correctly stored
228        let process_id: ProcessID = "process1".into();
229        let process_constraints = result
230            .get(&process_id)
231            .expect("Process constraints should exist");
232
233        let gbr_region: RegionID = "GBR".into();
234        let usa_region: RegionID = "USA".into();
235
236        // Check GBR 2010 constraint
237        let gbr_2010 = process_constraints
238            .get(&(gbr_region.clone(), 2010))
239            .expect("GBR 2010 constraint should exist");
240        assert_eq!(gbr_2010.addition_limit, Some(100.0));
241
242        // Check GBR 2015 constraint (from ALL regions)
243        let gbr_2015 = process_constraints
244            .get(&(gbr_region, 2015))
245            .expect("GBR 2015 constraint should exist");
246        assert_eq!(gbr_2015.addition_limit, Some(200.0));
247
248        // Check USA 2015 constraint (from ALL regions)
249        let usa_2015 = process_constraints
250            .get(&(usa_region.clone(), 2015))
251            .expect("USA 2015 constraint should exist");
252        assert_eq!(usa_2015.addition_limit, Some(200.0));
253
254        // Check USA 2020 constraint
255        let usa_2020 = process_constraints
256            .get(&(usa_region, 2020))
257            .expect("USA 2020 constraint should exist");
258        assert_eq!(usa_2020.addition_limit, Some(50.0));
259
260        // Verify total number of constraints (2 GBR + 2 USA = 4)
261        assert_eq!(process_constraints.len(), 4);
262    }
263
264    #[rstest]
265    fn test_read_constraints_all_regions_all_years(processes: ProcessMap) {
266        // Create milestone years matching the process years
267        let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
268
269        // Create a constraint that applies to all regions and all years
270        let constraints = vec![ProcessInvestmentConstraintRaw {
271            process_id: "process1".into(),
272            regions: "ALL".into(),
273            commission_years: "ALL".into(),
274            addition_limit: 75.0,
275        }];
276
277        // Read constraints into the map
278        let result = read_process_investment_constraints_from_iter(
279            constraints.into_iter(),
280            &processes,
281            &milestone_years,
282        )
283        .unwrap();
284
285        // Verify the constraints were correctly stored
286        let process_id: ProcessID = "process1".into();
287        let process_constraints = result
288            .get(&process_id)
289            .expect("Process constraints should exist");
290
291        let gbr_region: RegionID = "GBR".into();
292        let usa_region: RegionID = "USA".into();
293
294        // Verify constraint exists for all region-year combinations
295        for &year in &milestone_years {
296            let gbr_constraint = process_constraints
297                .get(&(gbr_region.clone(), year))
298                .expect(&format!("GBR {} constraint should exist", year));
299            assert_eq!(gbr_constraint.addition_limit, Some(75.0));
300
301            let usa_constraint = process_constraints
302                .get(&(usa_region.clone(), year))
303                .expect(&format!("USA {} constraint should exist", year));
304            assert_eq!(usa_constraint.addition_limit, Some(75.0));
305        }
306
307        // Verify total number of constraints (2 regions × 3 years = 6)
308        assert_eq!(process_constraints.len(), 6);
309    }
310
311    #[rstest]
312    fn test_read_constraints_year_outside_milestone_years(processes: ProcessMap) {
313        // Create constraint with year outside milestone years
314        // Process years are 2010..=2020 from the fixture
315        let milestone_years = vec![2010, 2015, 2020];
316
317        let constraints = vec![ProcessInvestmentConstraintRaw {
318            process_id: "process1".into(),
319            regions: "GBR".into(),
320            commission_years: "2025".into(), // Outside milestone years (2010-2020)
321            addition_limit: 100.0,
322        }];
323
324        // Should fail with milestone year validation error
325        let result = read_process_investment_constraints_from_iter(
326            constraints.into_iter(),
327            &processes,
328            &milestone_years,
329        );
330        assert_error!(
331            result,
332            "Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
333        );
334    }
335
336    #[test]
337    fn test_validate_addition_with_finite_value() {
338        // Valid: addition constraint with positive value
339        let valid = validate_raw_constraint(10.0);
340        assert!(valid.is_ok());
341
342        // Valid: addition constraint with zero value
343        let valid = validate_raw_constraint(0.0);
344        assert!(valid.is_ok());
345
346        // Not valid: addition constraint with negative value
347        let invalid = validate_raw_constraint(-10.0);
348        assert_error!(
349            invalid,
350            "Invalid value for addition constraint: '-10'; must be non-negative and finite."
351        );
352    }
353
354    #[test]
355    fn test_validate_addition_rejects_infinite() {
356        // Invalid: infinite value
357        let invalid = validate_raw_constraint(f64::INFINITY);
358        assert_error!(
359            invalid,
360            "Invalid value for addition constraint: 'inf'; must be non-negative and finite."
361        );
362
363        // Invalid: NaN value
364        let invalid = validate_raw_constraint(f64::NAN);
365        assert_error!(
366            invalid,
367            "Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
368        );
369    }
370}