muse2/input/process/
availability.rs

1//! Code for reading process availabilities CSV file
2use super::super::{format_items_with_cap, input_err_msg, read_csv, try_insert};
3use crate::process::{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, ensure};
9use itertools::iproduct;
10use serde::Deserialize;
11use serde_string_enum::DeserializeLabeledStringEnum;
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    years: String,
25    time_slice: String,
26    limit_type: LimitType,
27    value: Dimensionless,
28}
29
30impl ProcessAvailabilityRaw {
31    fn validate(&self) -> Result<()> {
32        // Check availability value
33        ensure!(
34            self.value >= Dimensionless(0.0) && self.value <= Dimensionless(1.0),
35            "Value for availability must be between 0 and 1 inclusive"
36        );
37
38        Ok(())
39    }
40
41    /// Calculate fraction of annual energy as availability multiplied by time slice length.
42    ///
43    /// The resulting limits are max/min energy produced/consumed in each time slice per
44    /// `capacity_to_activity` units of capacity.
45    fn to_bounds(&self, ts_length: Year) -> RangeInclusive<Dimensionless> {
46        // We know ts_length also represents a fraction of a year, so this is ok.
47        let ts_frac = ts_length / Year(1.0);
48        let value = self.value * ts_frac;
49        match self.limit_type {
50            LimitType::LowerBound => value..=ts_frac,
51            LimitType::UpperBound => Dimensionless(0.0)..=value,
52            LimitType::Equality => value..=value,
53        }
54    }
55}
56
57/// The type of limit given for availability
58#[derive(DeserializeLabeledStringEnum)]
59enum LimitType {
60    #[string = "lo"]
61    LowerBound,
62    #[string = "up"]
63    UpperBound,
64    #[string = "fx"]
65    Equality,
66}
67
68/// Read the process availabilities CSV file.
69///
70/// This file contains information about the availability of processes over the course of a year as
71/// a proportion of their maximum capacity.
72///
73/// # Arguments
74///
75/// * `model_dir` - Folder containing model configuration files
76/// * `processes` - Map of processes
77/// * `time_slice_info` - Information about seasons and times of day
78/// * `base_year` - First milestone year of simulation
79///
80/// # Returns
81///
82/// A [`HashMap`] with process IDs as the keys and [`ProcessActivityLimitsMap`]s as the values or an
83/// error.
84pub fn read_process_availabilities(
85    model_dir: &Path,
86    processes: &ProcessMap,
87    time_slice_info: &TimeSliceInfo,
88    base_year: u32,
89) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>> {
90    let file_path = model_dir.join(PROCESS_AVAILABILITIES_FILE_NAME);
91    let process_availabilities_csv = read_csv(&file_path)?;
92    read_process_availabilities_from_iter(
93        process_availabilities_csv,
94        processes,
95        time_slice_info,
96        base_year,
97    )
98    .with_context(|| input_err_msg(&file_path))
99}
100
101/// Process raw process availabilities input data into [`ProcessActivityLimitsMap`]s.
102///
103/// # Arguments
104///
105/// * `iter` - Iterator of raw process availability records
106/// * `processes` - Map of processes
107/// * `time_slice_info` - Information about seasons and times of day
108/// * `base_year` - First milestone year of simulation
109///
110/// # Returns
111///
112/// A [`HashMap`] with process IDs as the keys and [`ProcessActivityLimitsMap`]s as the values or an
113/// error.
114fn read_process_availabilities_from_iter<I>(
115    iter: I,
116    processes: &ProcessMap,
117    time_slice_info: &TimeSliceInfo,
118    base_year: u32,
119) -> Result<HashMap<ProcessID, ProcessActivityLimitsMap>>
120where
121    I: Iterator<Item = ProcessAvailabilityRaw>,
122{
123    let mut map = HashMap::new();
124    for record in iter {
125        record.validate()?;
126
127        // Get process
128        let (id, process) = processes
129            .get_key_value(record.process_id.as_str())
130            .with_context(|| format!("Process {} not found", record.process_id))?;
131
132        // Get regions
133        let process_regions = &process.regions;
134        let record_regions =
135            parse_region_str(&record.regions, process_regions).with_context(|| {
136                format!("Invalid region for process {id}. Valid regions are {process_regions:?}")
137            })?;
138
139        // Get years
140        let process_years = &process.years;
141        let record_years = parse_year_str(&record.years, process_years).with_context(|| {
142            format!("Invalid year for process {id}. Valid years are {process_years:?}")
143        })?;
144
145        // Get time slices
146        let ts_selection = time_slice_info.get_selection(&record.time_slice)?;
147
148        // Insert the activity limit into the map
149        let limits_map = map
150            .entry(id.clone())
151            .or_insert_with(ProcessActivityLimitsMap::new);
152        for (region_id, year) in iproduct!(&record_regions, &record_years) {
153            let limits_map_inner = limits_map
154                .entry((region_id.clone(), *year))
155                .or_insert_with(|| Rc::new(HashMap::new()));
156            let limits_map_inner = Rc::get_mut(limits_map_inner).unwrap();
157            for (time_slice, ts_length) in ts_selection.iter(time_slice_info) {
158                let bounds = record.to_bounds(ts_length);
159                try_insert(limits_map_inner, time_slice, bounds.clone())?;
160            }
161        }
162    }
163
164    validate_activity_limits_maps(&map, processes, time_slice_info, base_year)?;
165
166    Ok(map)
167}
168
169/// Check that the activity limits cover every time slice and all regions/years of the process
170fn validate_activity_limits_maps(
171    all_availabilities: &HashMap<ProcessID, ProcessActivityLimitsMap>,
172    processes: &ProcessMap,
173    time_slice_info: &TimeSliceInfo,
174    base_year: u32,
175) -> Result<()> {
176    for (process_id, process) in processes {
177        // A map of maps: the outer map is keyed by region and year; the inner one by time slice
178        let map_for_process = all_availabilities
179            .get(process_id)
180            .with_context(|| format!("Missing availabilities for process {process_id}"))?;
181
182        check_missing_milestone_years(process, map_for_process, base_year)?;
183        check_missing_time_slices(process, map_for_process, time_slice_info)?;
184    }
185
186    Ok(())
187}
188
189/// Check every milestone year in which the process can be commissioned has availabilities.
190///
191/// Entries for non-milestone years in which the process can be commissioned (which are only
192/// required for pre-defined assets, if at all) are not required and will be checked lazily when
193/// assets requiring them are constructed.
194fn check_missing_milestone_years(
195    process: &Process,
196    map_for_process: &ProcessActivityLimitsMap,
197    base_year: u32,
198) -> Result<()> {
199    let process_milestone_years = process
200        .years
201        .iter()
202        .copied()
203        .filter(|&year| year >= base_year);
204    let mut missing = Vec::new();
205    for (region_id, year) in iproduct!(&process.regions, process_milestone_years) {
206        if !map_for_process.contains_key(&(region_id.clone(), year)) {
207            missing.push((region_id, year));
208        }
209    }
210
211    ensure!(
212        missing.is_empty(),
213        "Process {} is missing availabilities for the following regions and milestone years: {}",
214        &process.id,
215        format_items_with_cap(&missing)
216    );
217
218    Ok(())
219}
220
221/// Check that entries for all time slices are provided for any process/region/year combo for which
222/// we have any entries at all
223fn check_missing_time_slices(
224    process: &Process,
225    map_for_process: &ProcessActivityLimitsMap,
226    time_slice_info: &TimeSliceInfo,
227) -> Result<()> {
228    let mut missing = Vec::new();
229    for (region_id, &year) in iproduct!(&process.regions, &process.years) {
230        if let Some(map_for_region_year) = map_for_process.get(&(region_id.clone(), year)) {
231            // There are at least some entries for this region/year combo; check if there are
232            // any time slices not covered
233            missing.extend(
234                time_slice_info
235                    .iter_ids()
236                    .filter(|ts| !map_for_region_year.contains_key(ts))
237                    .map(|ts| (region_id, year, ts)),
238            );
239        }
240    }
241
242    ensure!(
243        missing.is_empty(),
244        "Availabilities supplied for some, but not all time slices, for process {}. The following \
245        regions, years and time slices are missing: {}",
246        &process.id,
247        format_items_with_cap(&missing)
248    );
249
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn create_process_availability_raw(
258        limit_type: LimitType,
259        value: Dimensionless,
260    ) -> ProcessAvailabilityRaw {
261        ProcessAvailabilityRaw {
262            process_id: "process".into(),
263            regions: "region".into(),
264            years: "2010".into(),
265            time_slice: "day".into(),
266            limit_type,
267            value,
268        }
269    }
270
271    #[test]
272    fn test_validate() {
273        // Valid
274        let valid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(0.5));
275        assert!(valid.validate().is_ok());
276        let valid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(0.0));
277        assert!(valid.validate().is_ok());
278        let valid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(1.0));
279        assert!(valid.validate().is_ok());
280
281        // Invalid: negative value
282        let invalid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(-0.5));
283        assert!(invalid.validate().is_err());
284
285        // Invalid: value greater than 1
286        let invalid = create_process_availability_raw(LimitType::LowerBound, Dimensionless(1.5));
287        assert!(invalid.validate().is_err());
288
289        // Invalid: infinity value
290        let invalid =
291            create_process_availability_raw(LimitType::LowerBound, Dimensionless(f64::INFINITY));
292        assert!(invalid.validate().is_err());
293
294        // Invalid: negative infinity value
295        let invalid = create_process_availability_raw(
296            LimitType::LowerBound,
297            Dimensionless(f64::NEG_INFINITY),
298        );
299        assert!(invalid.validate().is_err());
300
301        // Invalid: NaN value
302        let invalid =
303            create_process_availability_raw(LimitType::LowerBound, Dimensionless(f64::NAN));
304        assert!(invalid.validate().is_err());
305    }
306
307    #[test]
308    fn test_to_bounds() {
309        let ts_length = Year(0.1);
310
311        // Lower bound
312        let raw = create_process_availability_raw(LimitType::LowerBound, Dimensionless(0.5));
313        let bounds = raw.to_bounds(ts_length);
314        assert_eq!(bounds, Dimensionless(0.05)..=Dimensionless(0.1));
315
316        // Upper bound
317        let raw = create_process_availability_raw(LimitType::UpperBound, Dimensionless(0.5));
318        let bounds = raw.to_bounds(ts_length);
319        assert_eq!(bounds, Dimensionless(0.0)..=Dimensionless(0.05));
320
321        // Equality
322        let raw = create_process_availability_raw(LimitType::Equality, Dimensionless(0.5));
323        let bounds = raw.to_bounds(ts_length);
324        assert_eq!(bounds, Dimensionless(0.05)..=Dimensionless(0.05));
325    }
326}