1use 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#[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 fn to_bounds(&self, length: Year) -> Result<RangeInclusive<Dimensionless>> {
34 let availability_range = parse_availabilities_string(&self.limits)?;
36
37 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
45fn parse_availabilities_string(s: &str) -> Result<RangeInclusive<Dimensionless>> {
47 ensure!(!s.trim().is_empty(), "Availability range cannot be empty");
49
50 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 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 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 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 Ok(lower..=upper)
98}
99
100pub 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
127fn 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 let mut entries: HashMap<ProcessID, _> = processes
149 .iter()
150 .map(|(id, _)| (id.clone(), HashMap::new()))
151 .collect();
152
153 for record in iter {
154 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 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 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 let ts_selection = time_slice_info.get_selection(&record.time_slice)?;
175
176 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 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))] #[case("0.1..", Dimensionless(0.1)..=Dimensionless(1.0))] #[case("0.5..0.5", Dimensionless(0.5)..=Dimensionless(0.5))] 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))] #[case("..0.5", Year(0.1), Dimensionless(0.0)..=Dimensionless(0.05))] #[case("0.5..0.5", Year(0.1), Dimensionless(0.05)..=Dimensionless(0.05))] 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}