muse2/input/commodity/
demand_slicing.rs

1//! Demand slicing determines how annual demand is distributed across the year.
2use super::super::*;
3use crate::commodity::CommodityID;
4use crate::id::IDCollection;
5use crate::region::RegionID;
6use crate::time_slice::{TimeSliceID, TimeSliceInfo};
7use anyhow::{ensure, Context, Result};
8use itertools::Itertools;
9use serde::Deserialize;
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13const DEMAND_SLICING_FILE_NAME: &str = "demand_slicing.csv";
14
15#[derive(Clone, Deserialize)]
16struct DemandSlice {
17    commodity_id: String,
18    region_id: String,
19    time_slice: String,
20    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
21    fraction: f64,
22}
23
24/// A map relating commodity, region and time slice to the fraction of annual demand
25pub type DemandSliceMap = HashMap<(CommodityID, RegionID, TimeSliceID), f64>;
26
27/// Read demand slices from specified model directory.
28///
29/// # Arguments
30///
31/// * `model_dir` - Folder containing model configuration files
32/// * `commodity_ids` - All possible IDs of commodities
33/// * `region_ids` - All possible IDs for regions
34/// * `commodity_regions` - Pairs of commodities + regions listed in demand CSV file
35/// * `time_slice_info` - Information about seasons and times of day
36pub fn read_demand_slices(
37    model_dir: &Path,
38    svd_commodity_ids: &HashSet<CommodityID>,
39    region_ids: &HashSet<RegionID>,
40    time_slice_info: &TimeSliceInfo,
41) -> Result<DemandSliceMap> {
42    let file_path = model_dir.join(DEMAND_SLICING_FILE_NAME);
43    let demand_slices_csv = read_csv(&file_path)?;
44    read_demand_slices_from_iter(
45        demand_slices_csv,
46        svd_commodity_ids,
47        region_ids,
48        time_slice_info,
49    )
50    .with_context(|| input_err_msg(file_path))
51}
52
53/// Read demand slices from an iterator
54fn read_demand_slices_from_iter<I>(
55    iter: I,
56    svd_commodity_ids: &HashSet<CommodityID>,
57    region_ids: &HashSet<RegionID>,
58    time_slice_info: &TimeSliceInfo,
59) -> Result<DemandSliceMap>
60where
61    I: Iterator<Item = DemandSlice>,
62{
63    let mut demand_slices = DemandSliceMap::new();
64
65    for slice in iter {
66        let commodity_id = svd_commodity_ids
67            .get_id_by_str(&slice.commodity_id)
68            .with_context(|| {
69                format!(
70                    "Can only provide demand slice data for SVD commodities. Found entry for '{}'",
71                    slice.commodity_id
72                )
73            })?;
74        let region_id = region_ids.get_id_by_str(&slice.region_id)?;
75
76        // We need to know how many time slices are covered by the current demand slice entry and
77        // how long they are relative to one another so that we can divide up the demand for this
78        // entry appropriately
79        let ts_selection = time_slice_info.get_selection(&slice.time_slice)?;
80        for (ts, demand_fraction) in time_slice_info.calculate_share(&ts_selection, slice.fraction)
81        {
82            // Share demand between the time slices in proportion to duration
83            ensure!(demand_slices.insert((commodity_id.clone(), region_id.clone(), ts.clone()), demand_fraction).is_none(),
84                "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
85                (commodity: {commodity_id}, region: {region_id}, time slice: {ts})"
86            );
87        }
88    }
89
90    validate_demand_slices(
91        svd_commodity_ids,
92        region_ids,
93        &demand_slices,
94        time_slice_info,
95    )?;
96
97    Ok(demand_slices)
98}
99
100/// Check that the [`DemandSliceMap`] is well formed.
101///
102/// Specifically, check:
103///
104/// * It is non-empty
105/// * For every commodity + region pair, there must be entries covering every time slice
106/// * The demand fractions for all entries related to a commodity + region pair sum to one
107fn validate_demand_slices(
108    svd_commodity_ids: &HashSet<CommodityID>,
109    region_ids: &HashSet<RegionID>,
110    demand_slices: &DemandSliceMap,
111    time_slice_info: &TimeSliceInfo,
112) -> Result<()> {
113    let commodity_regions = svd_commodity_ids
114        .iter()
115        .cartesian_product(region_ids.iter())
116        .collect::<HashSet<_>>();
117    for (commodity_id, region_id) in commodity_regions {
118        time_slice_info
119            .iter_ids()
120            .map(|time_slice| {
121                demand_slices
122                    .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
123                    .with_context(|| {
124                        format!(
125                            "Demand slice missing for time slice {} (commodity: {}, region {})",
126                            time_slice, commodity_id, region_id
127                        )
128                    })
129            })
130            .process_results(|iter| {
131                check_fractions_sum_to_one(iter.copied()).context("Invalid demand fractions")
132            })??;
133    }
134
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::time_slice::TimeSliceID;
142    use std::iter;
143
144    #[test]
145    fn test_read_demand_slices_from_iter() {
146        let time_slice_info = TimeSliceInfo {
147            seasons: iter::once("winter".into()).collect(),
148            times_of_day: iter::once("day".into()).collect(),
149            fractions: [(
150                TimeSliceID {
151                    season: "winter".into(),
152                    time_of_day: "day".into(),
153                },
154                1.0,
155            )]
156            .into_iter()
157            .collect(),
158        };
159        let commodity_ids = HashSet::from_iter(iter::once("COM1".into()));
160        let region_ids = HashSet::from_iter(iter::once("GBR".into()));
161
162        // Valid
163        let demand_slice = DemandSlice {
164            commodity_id: "COM1".into(),
165            region_id: "GBR".into(),
166            time_slice: "winter".into(),
167            fraction: 1.0,
168        };
169        let time_slice = time_slice_info
170            .get_time_slice_id_from_str("winter.day")
171            .unwrap();
172        let key = ("COM1".into(), "GBR".into(), time_slice);
173        let expected = DemandSliceMap::from_iter(iter::once((key, 1.0)));
174        assert_eq!(
175            read_demand_slices_from_iter(
176                iter::once(demand_slice.clone()),
177                &commodity_ids,
178                &region_ids,
179                &time_slice_info,
180            )
181            .unwrap(),
182            expected
183        );
184
185        // Valid, multiple time slices
186        {
187            let time_slice_info = TimeSliceInfo {
188                seasons: ["winter".into(), "summer".into()].into_iter().collect(),
189                times_of_day: ["day".into(), "night".into()].into_iter().collect(),
190                fractions: [
191                    (
192                        TimeSliceID {
193                            season: "summer".into(),
194                            time_of_day: "day".into(),
195                        },
196                        3.0 / 16.0,
197                    ),
198                    (
199                        TimeSliceID {
200                            season: "summer".into(),
201                            time_of_day: "night".into(),
202                        },
203                        5.0 / 16.0,
204                    ),
205                    (
206                        TimeSliceID {
207                            season: "winter".into(),
208                            time_of_day: "day".into(),
209                        },
210                        3.0 / 16.0,
211                    ),
212                    (
213                        TimeSliceID {
214                            season: "winter".into(),
215                            time_of_day: "night".into(),
216                        },
217                        5.0 / 16.0,
218                    ),
219                ]
220                .into_iter()
221                .collect(),
222            };
223            let demand_slices = [
224                DemandSlice {
225                    commodity_id: "COM1".into(),
226                    region_id: "GBR".into(),
227                    time_slice: "winter".into(),
228                    fraction: 0.5,
229                },
230                DemandSlice {
231                    commodity_id: "COM1".into(),
232                    region_id: "GBR".into(),
233                    time_slice: "summer".into(),
234                    fraction: 0.5,
235                },
236            ];
237            let expected = DemandSliceMap::from_iter([
238                (
239                    (
240                        "COM1".into(),
241                        "GBR".into(),
242                        TimeSliceID {
243                            season: "summer".into(),
244                            time_of_day: "day".into(),
245                        },
246                    ),
247                    3.0 / 16.0,
248                ),
249                (
250                    (
251                        "COM1".into(),
252                        "GBR".into(),
253                        TimeSliceID {
254                            season: "summer".into(),
255                            time_of_day: "night".into(),
256                        },
257                    ),
258                    5.0 / 16.0,
259                ),
260                (
261                    (
262                        "COM1".into(),
263                        "GBR".into(),
264                        TimeSliceID {
265                            season: "winter".into(),
266                            time_of_day: "day".into(),
267                        },
268                    ),
269                    3.0 / 16.0,
270                ),
271                (
272                    (
273                        "COM1".into(),
274                        "GBR".into(),
275                        TimeSliceID {
276                            season: "winter".into(),
277                            time_of_day: "night".into(),
278                        },
279                    ),
280                    5.0 / 16.0,
281                ),
282            ]);
283            assert_eq!(
284                read_demand_slices_from_iter(
285                    demand_slices.into_iter(),
286                    &commodity_ids,
287                    &region_ids,
288                    &time_slice_info,
289                )
290                .unwrap(),
291                expected
292            );
293        }
294
295        // Empty CSV file
296        assert!(read_demand_slices_from_iter(
297            iter::empty(),
298            &commodity_ids,
299            &region_ids,
300            &time_slice_info,
301        )
302        .is_err());
303
304        // Bad commodity
305        let demand_slice = DemandSlice {
306            commodity_id: "COM2".into(),
307            region_id: "GBR".into(),
308            time_slice: "winter.day".into(),
309            fraction: 1.0,
310        };
311        assert!(read_demand_slices_from_iter(
312            iter::once(demand_slice.clone()),
313            &commodity_ids,
314            &region_ids,
315            &time_slice_info,
316        )
317        .is_err());
318
319        // Bad region
320        let demand_slice = DemandSlice {
321            commodity_id: "COM1".into(),
322            region_id: "FRA".into(),
323            time_slice: "winter.day".into(),
324            fraction: 1.0,
325        };
326        assert!(read_demand_slices_from_iter(
327            iter::once(demand_slice.clone()),
328            &commodity_ids,
329            &region_ids,
330            &time_slice_info,
331        )
332        .is_err());
333
334        // Bad time slice selection
335        let demand_slice = DemandSlice {
336            commodity_id: "COM1".into(),
337            region_id: "GBR".into(),
338            time_slice: "summer".into(),
339            fraction: 1.0,
340        };
341        assert!(read_demand_slices_from_iter(
342            iter::once(demand_slice.clone()),
343            &commodity_ids,
344            &region_ids,
345            &time_slice_info,
346        )
347        .is_err());
348
349        {
350            // Some time slices uncovered
351            let time_slice_info = TimeSliceInfo {
352                seasons: ["winter".into(), "summer".into()].into_iter().collect(),
353                times_of_day: iter::once("day".into()).collect(),
354                fractions: [
355                    (
356                        TimeSliceID {
357                            season: "winter".into(),
358                            time_of_day: "day".into(),
359                        },
360                        0.5,
361                    ),
362                    (
363                        TimeSliceID {
364                            season: "summer".into(),
365                            time_of_day: "day".into(),
366                        },
367                        0.5,
368                    ),
369                ]
370                .into_iter()
371                .collect(),
372            };
373            let demand_slice = DemandSlice {
374                commodity_id: "COM1".into(),
375                region_id: "GBR".into(),
376                time_slice: "winter".into(),
377                fraction: 1.0,
378            };
379            assert!(read_demand_slices_from_iter(
380                iter::once(demand_slice.clone()),
381                &commodity_ids,
382                &region_ids,
383                &time_slice_info,
384            )
385            .is_err());
386        }
387
388        // Same time slice twice
389        let demand_slice = DemandSlice {
390            commodity_id: "COM1".into(),
391            region_id: "GBR".into(),
392            time_slice: "winter.day".into(),
393            fraction: 0.5,
394        };
395        assert!(read_demand_slices_from_iter(
396            iter::repeat_n(demand_slice.clone(), 2),
397            &commodity_ids,
398            &region_ids,
399            &time_slice_info,
400        )
401        .is_err());
402
403        // Whole season and single time slice conflicting
404        let demand_slice_season = DemandSlice {
405            commodity_id: "COM1".into(),
406            region_id: "GBR".into(),
407            time_slice: "winter".into(),
408            fraction: 0.5,
409        };
410        assert!(read_demand_slices_from_iter(
411            [demand_slice, demand_slice_season].into_iter(),
412            &commodity_ids,
413            &region_ids,
414            &time_slice_info,
415        )
416        .is_err());
417
418        // Fractions don't sum to one
419        let demand_slice = DemandSlice {
420            commodity_id: "COM1".into(),
421            region_id: "GBR".into(),
422            time_slice: "winter".into(),
423            fraction: 0.5,
424        };
425        assert!(read_demand_slices_from_iter(
426            iter::once(demand_slice),
427            &commodity_ids,
428            &region_ids,
429            &time_slice_info,
430        )
431        .is_err());
432    }
433}