muse2/input/process/
availability.rs

1//! Code for reading process availabilities CSV file
2use super::super::*;
3use crate::process::{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};
9use serde::Deserialize;
10use serde_string_enum::DeserializeLabeledStringEnum;
11use std::collections::HashMap;
12use std::ops::RangeInclusive;
13use std::path::Path;
14
15const PROCESS_AVAILABILITIES_FILE_NAME: &str = "process_availabilities.csv";
16
17/// Represents a row of the process availabilities CSV file
18#[derive(Deserialize)]
19struct ProcessAvailabilityRaw {
20    process_id: String,
21    regions: String,
22    years: String,
23    time_slice: String,
24    limit_type: LimitType,
25    value: Dimensionless,
26}
27
28impl ProcessAvailabilityRaw {
29    fn validate(&self) -> Result<()> {
30        // Check availability value
31        ensure!(
32            self.value >= Dimensionless(0.0) && self.value <= Dimensionless(1.0),
33            "Value for availability must be between 0 and 1 inclusive"
34        );
35
36        Ok(())
37    }
38
39    /// Calculate fraction of annual energy as availability multiplied by time slice length.
40    ///
41    /// The resulting limits are max/min energy produced/consumed in each timeslice per
42    /// `capacity_to_activity` units of capacity.
43    fn to_bounds(&self, ts_length: Year) -> RangeInclusive<Dimensionless> {
44        // We know ts_length also represents a fraction of a year, so this is ok.
45        let value = self.value * ts_length / Year(1.0);
46        match self.limit_type {
47            LimitType::LowerBound => value..=Dimensionless(f64::INFINITY),
48            LimitType::UpperBound => Dimensionless(0.0)..=value,
49            LimitType::Equality => value..=value,
50        }
51    }
52}
53
54/// The type of limit given for availability
55#[derive(DeserializeLabeledStringEnum)]
56enum LimitType {
57    #[string = "lo"]
58    LowerBound,
59    #[string = "up"]
60    UpperBound,
61    #[string = "fx"]
62    Equality,
63}
64
65/// Read the process availabilities CSV file.
66///
67/// This file contains information about the availability of processes over the course of a year as
68/// a proportion of their maximum capacity.
69///
70/// # Arguments
71///
72/// * `model_dir` - Folder containing model configuration files
73/// * `processes` - Map of processes
74/// * `time_slice_info` - Information about seasons and times of day
75///
76/// # Returns
77///
78/// A [`HashMap`] with process IDs as the keys and [`ProcessActivityLimitsMap`]s as the values or an
79/// error.
80pub fn read_process_availabilities(
81    model_dir: &Path,
82    processes: &ProcessMap,
83    time_slice_info: &TimeSliceInfo,
84) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>> {
85    let file_path = model_dir.join(PROCESS_AVAILABILITIES_FILE_NAME);
86    let process_availabilities_csv = read_csv(&file_path)?;
87    read_process_availabilities_from_iter(process_availabilities_csv, processes, time_slice_info)
88        .with_context(|| input_err_msg(&file_path))
89}
90
91/// Process raw process availabilities input data into [`ProcessActivityLimitsMap`]s
92fn read_process_availabilities_from_iter<I>(
93    iter: I,
94    processes: &ProcessMap,
95    time_slice_info: &TimeSliceInfo,
96) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>>
97where
98    I: Iterator<Item = ProcessAvailabilityRaw>,
99{
100    let mut map = HashMap::new();
101    for record in iter {
102        record.validate()?;
103
104        // Get process
105        let (id, process) = processes
106            .get_key_value(record.process_id.as_str())
107            .with_context(|| format!("Process {} not found", record.process_id))?;
108
109        // Get regions
110        let process_regions = &process.regions;
111        let record_regions =
112            parse_region_str(&record.regions, process_regions).with_context(|| {
113                format!("Invalid region for process {id}. Valid regions are {process_regions:?}")
114            })?;
115
116        // Get years
117        let process_years = &process.years;
118        let record_years = parse_year_str(&record.years, process_years).with_context(|| {
119            format!("Invalid year for process {id}. Valid years are {process_years:?}")
120        })?;
121
122        // Get timeslices
123        let ts_selection = time_slice_info.get_selection(&record.time_slice)?;
124
125        // Insert the activity limit into the map
126        let entry = map
127            .entry(id.clone())
128            .or_insert_with(ProcessActivityLimitsMap::new);
129        for (time_slice, ts_length) in ts_selection.iter(time_slice_info) {
130            let bounds = record.to_bounds(ts_length);
131
132            for region in &record_regions {
133                for year in record_years.iter().copied() {
134                    try_insert(
135                        entry,
136                        (region.clone(), year, time_slice.clone()),
137                        bounds.clone(),
138                    )?;
139                }
140            }
141        }
142    }
143
144    validate_activity_limits_maps(&map, processes, time_slice_info)?;
145
146    Ok(map)
147}
148
149/// Check that the activity limits cover every time slice and all regions/years of the process
150fn validate_activity_limits_maps(
151    map: &HashMap<ProcessID, ProcessActivityLimitsMap>,
152    processes: &ProcessMap,
153    time_slice_info: &TimeSliceInfo,
154) -> Result<()> {
155    for (process_id, process) in processes.iter() {
156        let map = map
157            .get(process_id)
158            .with_context(|| format!("Missing availabilities for process {process_id}"))?;
159
160        let reference_years = &process.years.clone();
161        let reference_regions = &process.regions;
162        let mut missing_keys = Vec::new();
163        for year in reference_years {
164            for region in reference_regions {
165                for time_slice in time_slice_info.iter_ids() {
166                    let key = (region.clone(), *year, time_slice.clone());
167                    if !map.contains_key(&key) {
168                        missing_keys.push(key);
169                    }
170                }
171            }
172        }
173        ensure!(
174            missing_keys.is_empty(),
175            "Process {} is missing availabilities for the following regions, years and timeslice: {:?}",
176            process_id,
177            missing_keys
178        );
179    }
180
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn create_process_availability_raw(
189        limit_type: LimitType,
190        value: Dimensionless,
191    ) -> ProcessAvailabilityRaw {
192        ProcessAvailabilityRaw {
193            process_id: "process".into(),
194            regions: "region".into(),
195            years: "2010".into(),
196            time_slice: "day".into(),
197            limit_type,
198            value,
199        }
200    }
201
202    #[test]
203    fn test_validate() {
204        // Valid
205        let valid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(0.5));
206        assert!(valid.validate().is_ok());
207        let valid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(0.0));
208        assert!(valid.validate().is_ok());
209        let valid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(1.0));
210        assert!(valid.validate().is_ok());
211
212        // Invalid: negative value
213        let invalid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(-0.5));
214        assert!(invalid.validate().is_err());
215
216        // Invalid: value greater than 1
217        let invalid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(1.5));
218        assert!(invalid.validate().is_err());
219
220        // Invalid: infinity value
221        let invalid =
222            create_process_availability_raw(LimitType::LowerBound, Dimensionless(f64::INFINITY));
223        assert!(invalid.validate().is_err());
224
225        // Invalid: negative infinity value
226        let invalid = create_process_availability_raw(
227            LimitType::LowerBound,
228            Dimensionless(f64::NEG_INFINITY),
229        );
230        assert!(invalid.validate().is_err());
231
232        // Invalid: NaN value
233        let invalid =
234            create_process_availability_raw(LimitType::LowerBound, Dimensionless(f64::NAN));
235        assert!(invalid.validate().is_err());
236    }
237
238    #[test]
239    fn test_to_bounds() {
240        let ts_length = Year(0.1);
241
242        // Lower bound
243        let raw = create_process_availability_raw(LimitType::LowerBound, Dimensionless(0.5));
244        let bounds = raw.to_bounds(ts_length);
245        assert_eq!(bounds, Dimensionless(0.05)..=Dimensionless(f64::INFINITY));
246
247        // Upper bound
248        let raw = create_process_availability_raw(LimitType::UpperBound, Dimensionless(0.5));
249        let bounds = raw.to_bounds(ts_length);
250        assert_eq!(bounds, Dimensionless(0.0)..=Dimensionless(0.05));
251
252        // Equality
253        let raw = create_process_availability_raw(LimitType::Equality, Dimensionless(0.5));
254        let bounds = raw.to_bounds(ts_length);
255        assert_eq!(bounds, Dimensionless(0.05)..=Dimensionless(0.05));
256    }
257}