Skip to main content

muse2/input/process/
availability.rs

1//! Code for reading process availabilities from a CSV file.
2use super::super::{input_err_msg, parse_range, read_csv_optional, try_insert};
3use crate::id::GetIDValue;
4use crate::input::parse_year_str;
5use crate::process::{ActivityLimits, ProcessActivityLimitsMap, ProcessID, ProcessMap};
6use crate::region::parse_region_str;
7use crate::time_slice::TimeSliceInfo;
8use crate::units::{Dimensionless, Year};
9use anyhow::{Context, Result};
10use itertools::iproduct;
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::ops::RangeInclusive;
14use std::path::Path;
15use std::rc::Rc;
16
17const PROCESS_AVAILABILITIES_FILE_NAME: &str = "process_availabilities.csv";
18
19/// Represents a row of the process availabilities CSV file
20#[derive(Deserialize)]
21struct ProcessAvailabilityRaw {
22    process_id: String,
23    regions: String,
24    commission_years: String,
25    time_slice: String,
26    limits: String,
27}
28
29impl ProcessAvailabilityRaw {
30    /// Calculate fraction of annual energy as availability multiplied by the length of time covered.
31    ///
32    /// The resulting limits are the max/min energy produced or consumed in the covered time
33    /// period, scaled to the time slice length and expressed per `capacity_to_activity` units of
34    /// capacity.
35    fn to_bounds(&self, length: Year) -> Result<RangeInclusive<Dimensionless>> {
36        // Parse availability_range string
37        let availability_range = parse_range(&self.limits, Dimensionless(0.0)..=Dimensionless(1.0))
38            .with_context(|| format!("Could not parse availabilities range: {}", &self.limits))?;
39
40        // Convert to bounds based on fraction of the year covered
41        let ts_frac = length / Year(1.0);
42        let start = *availability_range.start() * ts_frac;
43        let end = *availability_range.end() * ts_frac;
44        Ok(start..=end)
45    }
46}
47
48/// Read the process availabilities CSV file.
49///
50/// This file contains information about the availability of processes over the course of a year as
51/// a proportion of their maximum capacity.
52///
53/// # Arguments
54///
55/// * `model_dir` - Folder containing model configuration files
56/// * `processes` - Map of known processes
57/// * `time_slice_info` - Time slice configuration
58///
59/// # Returns
60///
61/// A `HashMap<ProcessID, ProcessActivityLimitsMap>` mapping each process ID to its activity
62/// limits, or an error.
63pub fn read_process_availabilities(
64    model_dir: &Path,
65    processes: &ProcessMap,
66    time_slice_info: &TimeSliceInfo,
67) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>> {
68    let file_path = model_dir.join(PROCESS_AVAILABILITIES_FILE_NAME);
69    let process_availabilities_csv = read_csv_optional(&file_path)?;
70    read_process_availabilities_from_iter(process_availabilities_csv, processes, time_slice_info)
71        .with_context(|| input_err_msg(&file_path))
72}
73
74/// Process raw process availabilities input data into [`ProcessActivityLimitsMap`]s.
75///
76/// # Arguments
77///
78/// * `iter` - Iterator of raw process availability records
79/// * `processes` - Map of processes
80/// * `time_slice_info` - Information about seasons and times of day
81///
82/// # Returns
83///
84/// A [`HashMap`] with process IDs as the keys and [`ProcessActivityLimitsMap`]s as the values or an
85/// error.
86fn read_process_availabilities_from_iter<I>(
87    iter: I,
88    processes: &ProcessMap,
89    time_slice_info: &TimeSliceInfo,
90) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>>
91where
92    I: Iterator<Item = ProcessAvailabilityRaw>,
93{
94    // Collect entries for all processes
95    let mut entries: HashMap<ProcessID, _> = processes
96        .iter()
97        .map(|(id, _)| (id.clone(), HashMap::new()))
98        .collect();
99
100    for record in iter {
101        // Get process
102        let (id, process) = processes.get_id_value(&record.process_id)?;
103
104        // Get regions
105        let process_regions = &process.regions;
106        let record_regions =
107            parse_region_str(&record.regions, process_regions).with_context(|| {
108                format!("Invalid region for process {id}. Valid regions are {process_regions:?}")
109            })?;
110
111        // Get years
112        let process_years: Vec<u32> = process.years.clone().collect();
113        let record_years =
114            parse_year_str(&record.commission_years, &process_years).with_context(|| {
115                format!("Invalid year for process {id}. Valid years are {process_years:?}")
116            })?;
117
118        // Get time slices
119        let ts_selection = time_slice_info.get_selection(&record.time_slice)?;
120
121        // Store the activity limit for each region/year
122        let entries_for_process = entries.get_mut(id).unwrap();
123        for (region_id, year) in iproduct!(&record_regions, &record_years) {
124            let entries_for_process_region_year = entries_for_process
125                .entry((region_id.clone(), *year))
126                .or_default();
127            let length = time_slice_info.length_for_selection(&ts_selection)?;
128            try_insert(
129                entries_for_process_region_year,
130                &ts_selection,
131                record.to_bounds(length)?,
132            )?;
133        }
134    }
135
136    // Create `ProcessActivityLimitsMap`s for each process.
137    // Maps are created for all regions and years defined for each process, gathering the limits
138    // defined in the entries above, or using default limits (full availability) if none were
139    // defined.
140    let mut map = HashMap::new();
141    for (process_id, process) in processes {
142        let mut inner_map = HashMap::new();
143        let entries_for_process = &entries[process_id];
144        for (region_id, year) in iproduct!(&process.regions, process.years.clone()) {
145            let limits = entries_for_process
146                .get(&(region_id.clone(), year))
147                .cloned()
148                .unwrap_or_default();
149            let availabilities = ActivityLimits::new_from_limits(&limits, time_slice_info)
150                .with_context(|| {
151                    format!("Error creating activity limits for process {process_id}")
152                })?;
153            inner_map.insert((region_id.clone(), year), Rc::new(availabilities));
154        }
155        map.insert(process_id.clone(), inner_map);
156    }
157
158    Ok(map)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use float_cmp::assert_approx_eq;
165    use rstest::rstest;
166
167    fn create_process_availability_raw(limits: String) -> ProcessAvailabilityRaw {
168        ProcessAvailabilityRaw {
169            process_id: "process".into(),
170            regions: "region".into(),
171            commission_years: "2010".into(),
172            time_slice: "day".into(),
173            limits,
174        }
175    }
176
177    #[rstest]
178    #[case("0.1..", Year(0.1), Dimensionless(0.01)..=Dimensionless(0.1))] // Lower bound
179    #[case("..0.5", Year(0.1), Dimensionless(0.0)..=Dimensionless(0.05))] // Upper bound
180    #[case("0.5..0.5", Year(0.1), Dimensionless(0.05)..=Dimensionless(0.05))] // Equality
181    fn to_bounds(
182        #[case] limits: &str,
183        #[case] ts_length: Year,
184        #[case] expected: RangeInclusive<Dimensionless>,
185    ) {
186        let raw = create_process_availability_raw(limits.into());
187        let bounds = raw.to_bounds(ts_length).unwrap();
188        assert_approx_eq!(Dimensionless, *bounds.start(), *expected.start());
189        assert_approx_eq!(Dimensionless, *bounds.end(), *expected.end());
190    }
191}