muse2/input/process/
availability.rs

1//! Code for reading process availabilities CSV file
2use super::super::{input_err_msg, read_csv_optional, try_insert};
3use crate::process::{ActivityLimits, ProcessActivityLimitsMap, ProcessID, ProcessMap};
4use crate::region::parse_region_str;
5use crate::time_slice::TimeSliceInfo;
6use crate::units::{Dimensionless, Year};
7use crate::year::parse_year_str;
8use anyhow::{Context, Result, ensure};
9use itertools::iproduct;
10use serde::Deserialize;
11use std::collections::HashMap;
12use std::ops::RangeInclusive;
13use std::path::Path;
14use std::rc::Rc;
15
16const PROCESS_AVAILABILITIES_FILE_NAME: &str = "process_availabilities.csv";
17
18/// Represents a row of the process availabilities CSV file
19#[derive(Deserialize)]
20struct ProcessAvailabilityRaw {
21    process_id: String,
22    regions: String,
23    commission_years: String,
24    time_slice: String,
25    limits: String,
26}
27
28impl ProcessAvailabilityRaw {
29    /// Calculate fraction of annual energy as availability multiplied by the length of time covered.
30    ///
31    /// The resulting limits are max/min energy produced/consumed in the covered time period per
32    /// `capacity_to_activity` units of capacity.
33    fn to_bounds(&self, length: Year) -> Result<RangeInclusive<Dimensionless>> {
34        // Parse availability_range string
35        let availability_range = parse_availabilities_string(&self.limits)?;
36
37        // Convert to bounds based on fraction of the year covered
38        let ts_frac = length / Year(1.0);
39        let start = *availability_range.start() * ts_frac;
40        let end = *availability_range.end() * ts_frac;
41        Ok(start..=end)
42    }
43}
44
45/// Parse a string representing availability limits into a range.
46fn parse_availabilities_string(s: &str) -> Result<RangeInclusive<Dimensionless>> {
47    // Disallow empty string
48    ensure!(!s.trim().is_empty(), "Availability range cannot be empty");
49
50    // Require exactly one ".." separator so only forms lower..upper, lower.. or ..upper are allowed.
51    let parts: Vec<&str> = s.split("..").collect();
52    ensure!(
53        parts.len() == 2,
54        "Availability range must be of the form 'lower..upper', 'lower..' or '..upper'. Invalid: {s}"
55    );
56    let left = parts[0].trim();
57    let right = parts[1].trim();
58
59    // Parse lower limit
60    let lower = if left.is_empty() {
61        Dimensionless(0.0)
62    } else {
63        Dimensionless(
64            left.parse::<f64>()
65                .ok()
66                .with_context(|| format!("Invalid lower availability limit: {left}"))?,
67        )
68    };
69
70    // Parse upper limit
71    let upper = if right.is_empty() {
72        Dimensionless(1.0)
73    } else {
74        Dimensionless(
75            right
76                .parse::<f64>()
77                .ok()
78                .with_context(|| format!("Invalid upper availability limit: {right}"))?,
79        )
80    };
81
82    // Validation checks
83    ensure!(
84        upper >= lower,
85        "Upper availability limit must be greater than or equal to lower limit. Invalid: {s}"
86    );
87    ensure!(
88        lower >= Dimensionless(0.0),
89        "Lower availability limit must be >= 0. Invalid: {s}"
90    );
91    ensure!(
92        upper <= Dimensionless(1.0),
93        "Upper availability limit must be <= 1. Invalid: {s}"
94    );
95
96    // Return range
97    Ok(lower..=upper)
98}
99
100/// Read the process availabilities CSV file.
101///
102/// This file contains information about the availability of processes over the course of a year as
103/// a proportion of their maximum capacity.
104///
105/// # Arguments
106///
107/// * `model_dir` - Folder containing model configuration files
108/// * `processes` - Map of processes
109/// * `time_slice_info` - Information about seasons and times of day
110/// * `milestone_years` - Milestone years of simulation
111///
112/// # Returns
113///
114/// A [`HashMap`] with process IDs as the keys and [`ProcessActivityLimitsMap`]s as the values or an
115/// error.
116pub fn read_process_availabilities(
117    model_dir: &Path,
118    processes: &ProcessMap,
119    time_slice_info: &TimeSliceInfo,
120) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>> {
121    let file_path = model_dir.join(PROCESS_AVAILABILITIES_FILE_NAME);
122    let process_availabilities_csv = read_csv_optional(&file_path)?;
123    read_process_availabilities_from_iter(process_availabilities_csv, processes, time_slice_info)
124        .with_context(|| input_err_msg(&file_path))
125}
126
127/// Process raw process availabilities input data into [`ProcessActivityLimitsMap`]s.
128///
129/// # Arguments
130///
131/// * `iter` - Iterator of raw process availability records
132/// * `processes` - Map of processes
133/// * `time_slice_info` - Information about seasons and times of day
134///
135/// # Returns
136///
137/// A [`HashMap`] with process IDs as the keys and [`ProcessActivityLimitsMap`]s as the values or an
138/// error.
139fn read_process_availabilities_from_iter<I>(
140    iter: I,
141    processes: &ProcessMap,
142    time_slice_info: &TimeSliceInfo,
143) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>>
144where
145    I: Iterator<Item = ProcessAvailabilityRaw>,
146{
147    // Collect entries for all processes
148    let mut entries: HashMap<ProcessID, _> = processes
149        .iter()
150        .map(|(id, _)| (id.clone(), HashMap::new()))
151        .collect();
152
153    for record in iter {
154        // Get process
155        let (id, process) = processes
156            .get_key_value(record.process_id.as_str())
157            .with_context(|| format!("Process {} not found", record.process_id))?;
158
159        // Get regions
160        let process_regions = &process.regions;
161        let record_regions =
162            parse_region_str(&record.regions, process_regions).with_context(|| {
163                format!("Invalid region for process {id}. Valid regions are {process_regions:?}")
164            })?;
165
166        // Get years
167        let process_years: Vec<u32> = process.years.clone().collect();
168        let record_years =
169            parse_year_str(&record.commission_years, &process_years).with_context(|| {
170                format!("Invalid year for process {id}. Valid years are {process_years:?}")
171            })?;
172
173        // Get time slices
174        let ts_selection = time_slice_info.get_selection(&record.time_slice)?;
175
176        // Store the activity limit for each region/year
177        let entries_for_process = entries.get_mut(id).unwrap();
178        for (region_id, year) in iproduct!(&record_regions, &record_years) {
179            let entries_for_process_region_year = entries_for_process
180                .entry((region_id.clone(), *year))
181                .or_default();
182            let length = time_slice_info.length_for_selection(&ts_selection)?;
183            try_insert(
184                entries_for_process_region_year,
185                &ts_selection,
186                record.to_bounds(length)?,
187            )?;
188        }
189    }
190
191    // Create `ProcessActivityLimitsMap`s for each process.
192    // Maps are created for all regions and years defined for each process, gathering the limits
193    // defined in the entries above, or using default limits (full availability) if none were
194    // defined.
195    let mut map = HashMap::new();
196    for (process_id, process) in processes {
197        let mut inner_map = HashMap::new();
198        let entries_for_process = &entries[process_id];
199        for (region_id, year) in iproduct!(&process.regions, process.years.clone()) {
200            let limits = entries_for_process
201                .get(&(region_id.clone(), year))
202                .cloned()
203                .unwrap_or_default();
204            let availabilities = ActivityLimits::new_from_limits(&limits, time_slice_info)
205                .with_context(|| {
206                    format!("Error creating activity limits for process {process_id}")
207                })?;
208            inner_map.insert((region_id.clone(), year), Rc::new(availabilities));
209        }
210        map.insert(process_id.clone(), inner_map);
211    }
212
213    Ok(map)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::fixture::assert_error;
220    use float_cmp::assert_approx_eq;
221    use rstest::rstest;
222
223    fn create_process_availability_raw(limits: String) -> ProcessAvailabilityRaw {
224        ProcessAvailabilityRaw {
225            process_id: "process".into(),
226            regions: "region".into(),
227            commission_years: "2010".into(),
228            time_slice: "day".into(),
229            limits,
230        }
231    }
232
233    #[rstest]
234    #[case("0.1..0.9", Dimensionless(0.1)..=Dimensionless(0.9))]
235    #[case("..0.9", Dimensionless(0.0)..=Dimensionless(0.9))] // Empty lower
236    #[case("0.1..", Dimensionless(0.1)..=Dimensionless(1.0))] // Empty upper
237    #[case("0.5..0.5", Dimensionless(0.5)..=Dimensionless(0.5))] // Equality
238    fn test_parse_availabilities_string_valid(
239        #[case] input: &str,
240        #[case] expected: RangeInclusive<Dimensionless>,
241    ) {
242        assert_eq!(parse_availabilities_string(input).unwrap(), expected);
243    }
244
245    #[rstest]
246    #[case("", "Availability range cannot be empty")]
247    #[case(
248        "0.6..0.5",
249        "Upper availability limit must be greater than or equal to lower limit. Invalid: 0.6..0.5"
250    )]
251    #[case(
252        "..0.1..0.9",
253        "Availability range must be of the form 'lower..upper', 'lower..' or '..upper'. Invalid: ..0.1..0.9"
254    )]
255    #[case("0.1...0.9", "Invalid upper availability limit: .0.9")]
256    #[case(
257        "-0.1..0.5",
258        "Lower availability limit must be >= 0. Invalid: -0.1..0.5"
259    )]
260    #[case("0.1..1.5", "Upper availability limit must be <= 1. Invalid: 0.1..1.5")]
261    #[case("abc..0.5", "Invalid lower availability limit: abc")]
262    #[case(
263        "0.5",
264        "Availability range must be of the form 'lower..upper', 'lower..' or '..upper'. Invalid: 0.5"
265    )]
266    fn test_parse_availabilities_string_invalid(#[case] input: &str, #[case] error_msg: &str) {
267        assert_error!(parse_availabilities_string(input), error_msg);
268    }
269
270    #[rstest]
271    #[case("0.1..", Year(0.1), Dimensionless(0.01)..=Dimensionless(0.1))] // Lower bound
272    #[case("..0.5", Year(0.1), Dimensionless(0.0)..=Dimensionless(0.05))] // Upper bound
273    #[case("0.5..0.5", Year(0.1), Dimensionless(0.05)..=Dimensionless(0.05))] // Equality
274    fn test_to_bounds(
275        #[case] limits: &str,
276        #[case] ts_length: Year,
277        #[case] expected: RangeInclusive<Dimensionless>,
278    ) {
279        let raw = create_process_availability_raw(limits.into());
280        let bounds = raw.to_bounds(ts_length).unwrap();
281        assert_approx_eq!(Dimensionless, *bounds.start(), *expected.start());
282        assert_approx_eq!(Dimensionless, *bounds.end(), *expected.end());
283    }
284}