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::input::commodity::demand::BorrowedCommodityMap;
6use crate::region::RegionID;
7use crate::time_slice::{TimeSliceInfo, TimeSliceSelection};
8use crate::units::Dimensionless;
9use anyhow::{ensure, Context, Result};
10use indexmap::IndexSet;
11use itertools::{iproduct, Itertools};
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::Path;
15
16const DEMAND_SLICING_FILE_NAME: &str = "demand_slicing.csv";
17
18#[derive(Clone, Deserialize)]
19struct DemandSlice {
20    commodity_id: String,
21    region_id: String,
22    time_slice: String,
23    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
24    fraction: Dimensionless,
25}
26
27/// A map relating commodity, region and time slice selection to the fraction of annual demand
28pub type DemandSliceMap = HashMap<(CommodityID, RegionID, TimeSliceSelection), Dimensionless>;
29
30/// Read demand slices from specified model directory.
31///
32/// # Arguments
33///
34/// * `model_dir` - Folder containing model configuration files
35/// * `svd_commodities` - Map of service demand commodities
36/// * `region_ids` - All possible IDs for regions
37/// * `commodity_regions` - Pairs of commodities + regions listed in demand CSV file
38/// * `time_slice_info` - Information about seasons and times of day
39pub fn read_demand_slices(
40    model_dir: &Path,
41    svd_commodities: &BorrowedCommodityMap,
42    region_ids: &IndexSet<RegionID>,
43    time_slice_info: &TimeSliceInfo,
44) -> Result<DemandSliceMap> {
45    let file_path = model_dir.join(DEMAND_SLICING_FILE_NAME);
46    let demand_slices_csv = read_csv(&file_path)?;
47    read_demand_slices_from_iter(
48        demand_slices_csv,
49        svd_commodities,
50        region_ids,
51        time_slice_info,
52    )
53    .with_context(|| input_err_msg(file_path))
54}
55
56/// Read demand slices from an iterator
57fn read_demand_slices_from_iter<I>(
58    iter: I,
59    svd_commodities: &BorrowedCommodityMap,
60    region_ids: &IndexSet<RegionID>,
61    time_slice_info: &TimeSliceInfo,
62) -> Result<DemandSliceMap>
63where
64    I: Iterator<Item = DemandSlice>,
65{
66    let mut demand_slices = DemandSliceMap::new();
67
68    for slice in iter {
69        let commodity = svd_commodities
70            .get(slice.commodity_id.as_str())
71            .with_context(|| {
72                format!(
73                    "Can only provide demand slice data for SVD commodities. Found entry for '{}'",
74                    slice.commodity_id
75                )
76            })?;
77        let region_id = region_ids.get_id(&slice.region_id)?;
78
79        // We need to know how many time slices are covered by the current demand slice entry and
80        // how long they are relative to one another so that we can divide up the demand for this
81        // entry appropriately
82        let ts_selection = time_slice_info.get_selection(&slice.time_slice)?;
83
84        // Share demand between the time slice selections in proportion to duration
85        let iter = time_slice_info
86            .calculate_share(&ts_selection, commodity.time_slice_level, slice.fraction)
87            .with_context(|| {
88                format!(
89                    "Cannot provide demand at {:?} level when commodity time slice level is {:?}",
90                    ts_selection.level(),
91                    commodity.time_slice_level
92                )
93            })?;
94        for (ts_selection, demand_fraction) in iter {
95            let existing = demand_slices
96                .insert(
97                    (
98                        commodity.id.clone(),
99                        region_id.clone(),
100                        ts_selection.clone(),
101                    ),
102                    demand_fraction,
103                )
104                .is_some();
105            ensure!(!existing,
106                "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
107                (commodity: {}, region: {}, time slice(s): {})"
108                ,commodity.id,region_id,ts_selection
109            );
110        }
111    }
112
113    validate_demand_slices(svd_commodities, region_ids, &demand_slices, time_slice_info)?;
114
115    Ok(demand_slices)
116}
117
118/// Check that the [`DemandSliceMap`] is well formed.
119///
120/// Specifically, check:
121///
122/// * It is non-empty
123/// * For every commodity + region pair, there must be entries covering every time slice
124/// * The demand fractions for all entries related to a commodity + region pair sum to one
125fn validate_demand_slices(
126    svd_commodities: &BorrowedCommodityMap,
127    region_ids: &IndexSet<RegionID>,
128    demand_slices: &DemandSliceMap,
129    time_slice_info: &TimeSliceInfo,
130) -> Result<()> {
131    for (commodity, region_id) in iproduct!(svd_commodities.values(), region_ids) {
132        time_slice_info
133            .iter_selections_at_level(commodity.time_slice_level)
134            .map(|ts_selection| {
135                demand_slices
136                    .get(&(
137                        commodity.id.clone(),
138                        region_id.clone(),
139                        ts_selection.clone(),
140                    ))
141                    .with_context(|| {
142                        format!(
143                            "Demand slice missing for time slice(s) '{}' (commodity: {}, region {})",
144                            ts_selection, commodity.id, region_id
145                        )
146                    })
147            })
148            .process_results(|iter| {
149                check_fractions_sum_to_one(iter.copied()).context("Invalid demand fractions")
150            })??;
151    }
152
153    Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::commodity::Commodity;
160    use crate::fixture::{assert_error, get_svd_map, svd_commodity, time_slice_info};
161    use crate::time_slice::TimeSliceID;
162    use rstest::{fixture, rstest};
163    use std::iter;
164
165    #[fixture]
166    pub fn region_ids() -> IndexSet<RegionID> {
167        IndexSet::from(["GBR".into()])
168    }
169
170    #[rstest]
171    fn test_read_demand_slices_from_iter_valid(
172        svd_commodity: Commodity,
173        region_ids: IndexSet<RegionID>,
174        time_slice_info: TimeSliceInfo,
175    ) {
176        // Valid
177        let svd_commodities = get_svd_map(&svd_commodity);
178        let demand_slice = DemandSlice {
179            commodity_id: "commodity1".into(),
180            region_id: "GBR".into(),
181            time_slice: "winter".into(),
182            fraction: Dimensionless(1.0),
183        };
184        let time_slice = time_slice_info
185            .get_time_slice_id_from_str("winter.day")
186            .unwrap();
187        let key = ("commodity1".into(), "GBR".into(), time_slice.into());
188        let expected = DemandSliceMap::from_iter(iter::once((key, Dimensionless(1.0))));
189        assert_eq!(
190            read_demand_slices_from_iter(
191                iter::once(demand_slice.clone()),
192                &svd_commodities,
193                &region_ids,
194                &time_slice_info,
195            )
196            .unwrap(),
197            expected
198        );
199    }
200
201    #[rstest]
202    fn test_read_demand_slices_from_iter_valid_multiple_time_slices(
203        svd_commodity: Commodity,
204        region_ids: IndexSet<RegionID>,
205    ) {
206        // Valid, multiple time slices
207        let svd_commodities = get_svd_map(&svd_commodity);
208        let time_slice_info = TimeSliceInfo {
209            seasons: [
210                ("winter".into(), Dimensionless(0.5)),
211                ("summer".into(), Dimensionless(0.5)),
212            ]
213            .into_iter()
214            .collect(),
215            times_of_day: ["day".into(), "night".into()].into_iter().collect(),
216            time_slices: [
217                (
218                    TimeSliceID {
219                        season: "summer".into(),
220                        time_of_day: "day".into(),
221                    },
222                    Dimensionless(3.0 / 16.0),
223                ),
224                (
225                    TimeSliceID {
226                        season: "summer".into(),
227                        time_of_day: "night".into(),
228                    },
229                    Dimensionless(5.0 / 16.0),
230                ),
231                (
232                    TimeSliceID {
233                        season: "winter".into(),
234                        time_of_day: "day".into(),
235                    },
236                    Dimensionless(3.0 / 16.0),
237                ),
238                (
239                    TimeSliceID {
240                        season: "winter".into(),
241                        time_of_day: "night".into(),
242                    },
243                    Dimensionless(5.0 / 16.0),
244                ),
245            ]
246            .into_iter()
247            .collect(),
248        };
249        let demand_slices = [
250            DemandSlice {
251                commodity_id: "commodity1".into(),
252                region_id: "GBR".into(),
253                time_slice: "winter".into(),
254                fraction: Dimensionless(0.5),
255            },
256            DemandSlice {
257                commodity_id: "commodity1".into(),
258                region_id: "GBR".into(),
259                time_slice: "summer".into(),
260                fraction: Dimensionless(0.5),
261            },
262        ];
263
264        fn demand_slice_entry(
265            season: &str,
266            time_of_day: &str,
267            fraction: Dimensionless,
268        ) -> ((CommodityID, RegionID, TimeSliceSelection), Dimensionless) {
269            (
270                (
271                    "commodity1".into(),
272                    "GBR".into(),
273                    TimeSliceID {
274                        season: season.into(),
275                        time_of_day: time_of_day.into(),
276                    }
277                    .into(),
278                ),
279                fraction,
280            )
281        }
282        let expected = DemandSliceMap::from_iter([
283            demand_slice_entry("summer", "day", Dimensionless(3.0 / 16.0)),
284            demand_slice_entry("summer", "night", Dimensionless(5.0 / 16.0)),
285            demand_slice_entry("winter", "day", Dimensionless(3.0 / 16.0)),
286            demand_slice_entry("winter", "night", Dimensionless(5.0 / 16.0)),
287        ]);
288
289        assert_eq!(
290            read_demand_slices_from_iter(
291                demand_slices.into_iter(),
292                &svd_commodities,
293                &region_ids,
294                &time_slice_info,
295            )
296            .unwrap(),
297            expected
298        );
299    }
300
301    #[rstest]
302    fn test_read_demand_slices_from_iter_invalid_empty_file(
303        svd_commodity: Commodity,
304        region_ids: IndexSet<RegionID>,
305        time_slice_info: TimeSliceInfo,
306    ) {
307        // Empty CSV file
308        let svd_commodities = get_svd_map(&svd_commodity);
309        assert_error!(
310            read_demand_slices_from_iter(
311                iter::empty(),
312                &svd_commodities,
313                &region_ids,
314                &time_slice_info,
315            ),
316            "Demand slice missing for time slice(s) 'winter.day' (commodity: commodity1, region GBR)"
317        );
318    }
319
320    #[rstest]
321    fn test_read_demand_slices_from_iter_invalid_bad_commodity(
322        svd_commodity: Commodity,
323        region_ids: IndexSet<RegionID>,
324        time_slice_info: TimeSliceInfo,
325    ) {
326        // Bad commodity
327        let svd_commodities = get_svd_map(&svd_commodity);
328        let demand_slice = DemandSlice {
329            commodity_id: "commodity2".into(),
330            region_id: "GBR".into(),
331            time_slice: "winter.day".into(),
332            fraction: Dimensionless(1.0),
333        };
334        assert_error!(
335            read_demand_slices_from_iter(
336                iter::once(demand_slice.clone()),
337                &svd_commodities,
338                &region_ids,
339                &time_slice_info,
340            ),
341            "Can only provide demand slice data for SVD commodities. Found entry for 'commodity2'"
342        );
343    }
344
345    #[rstest]
346    fn test_read_demand_slices_from_iter_invalid_bad_region(
347        svd_commodity: Commodity,
348        region_ids: IndexSet<RegionID>,
349        time_slice_info: TimeSliceInfo,
350    ) {
351        // Bad region
352        let svd_commodities = get_svd_map(&svd_commodity);
353        let demand_slice = DemandSlice {
354            commodity_id: "commodity1".into(),
355            region_id: "FRA".into(),
356            time_slice: "winter.day".into(),
357            fraction: Dimensionless(1.0),
358        };
359        assert_error!(
360            read_demand_slices_from_iter(
361                iter::once(demand_slice.clone()),
362                &svd_commodities,
363                &region_ids,
364                &time_slice_info,
365            ),
366            "Unknown ID FRA found"
367        );
368    }
369
370    #[rstest]
371    fn test_read_demand_slices_from_iter_invalid_bad_time_slice(
372        svd_commodity: Commodity,
373        region_ids: IndexSet<RegionID>,
374        time_slice_info: TimeSliceInfo,
375    ) {
376        // Bad time slice selection
377        let svd_commodities = get_svd_map(&svd_commodity);
378        let demand_slice = DemandSlice {
379            commodity_id: "commodity1".into(),
380            region_id: "GBR".into(),
381            time_slice: "summer".into(),
382            fraction: Dimensionless(1.0),
383        };
384        assert_error!(
385            read_demand_slices_from_iter(
386                iter::once(demand_slice.clone()),
387                &svd_commodities,
388                &region_ids,
389                &time_slice_info,
390            ),
391            "'summer' is not a valid season"
392        );
393    }
394
395    #[rstest]
396    fn test_read_demand_slices_from_iter_invalid_missing_time_slices(
397        svd_commodity: Commodity,
398        region_ids: IndexSet<RegionID>,
399    ) {
400        // Some time slices uncovered
401        let svd_commodities = get_svd_map(&svd_commodity);
402        let time_slice_info = TimeSliceInfo {
403            seasons: [
404                ("winter".into(), Dimensionless(0.5)),
405                ("summer".into(), Dimensionless(0.5)),
406            ]
407            .into_iter()
408            .collect(),
409            times_of_day: iter::once("day".into()).collect(),
410            time_slices: [
411                (
412                    TimeSliceID {
413                        season: "winter".into(),
414                        time_of_day: "day".into(),
415                    },
416                    Dimensionless(0.5),
417                ),
418                (
419                    TimeSliceID {
420                        season: "summer".into(),
421                        time_of_day: "day".into(),
422                    },
423                    Dimensionless(0.5),
424                ),
425            ]
426            .into_iter()
427            .collect(),
428        };
429        let demand_slice = DemandSlice {
430            commodity_id: "commodity1".into(),
431            region_id: "GBR".into(),
432            time_slice: "winter".into(),
433            fraction: Dimensionless(1.0),
434        };
435        assert_error!(
436            read_demand_slices_from_iter(
437                iter::once(demand_slice.clone()),
438                &svd_commodities,
439                &region_ids,
440                &time_slice_info,
441            ),
442            "Demand slice missing for time slice(s) 'summer.day' (commodity: commodity1, region GBR)"
443        );
444    }
445
446    #[rstest]
447    fn test_read_demand_slices_from_iter_invalid_duplicate_time_slice(
448        svd_commodity: Commodity,
449        region_ids: IndexSet<RegionID>,
450        time_slice_info: TimeSliceInfo,
451    ) {
452        // Same time slice twice
453        let svd_commodities = get_svd_map(&svd_commodity);
454        let demand_slice = DemandSlice {
455            commodity_id: "commodity1".into(),
456            region_id: "GBR".into(),
457            time_slice: "winter.day".into(),
458            fraction: Dimensionless(0.5),
459        };
460        assert_error!(
461            read_demand_slices_from_iter(
462                iter::repeat_n(demand_slice.clone(), 2),
463                &svd_commodities,
464                &region_ids,
465                &time_slice_info,
466            ),
467            "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
468                (commodity: commodity1, region: GBR, time slice(s): winter.day)"
469        );
470    }
471
472    #[rstest]
473    fn test_read_demand_slices_from_iter_invalid_season_time_slice_conflict(
474        svd_commodity: Commodity,
475        region_ids: IndexSet<RegionID>,
476        time_slice_info: TimeSliceInfo,
477    ) {
478        // Whole season and single time slice conflicting
479        let svd_commodities = get_svd_map(&svd_commodity);
480        let demand_slice = DemandSlice {
481            commodity_id: "commodity1".into(),
482            region_id: "GBR".into(),
483            time_slice: "winter.day".into(),
484            fraction: Dimensionless(0.5),
485        };
486        let demand_slice_season = DemandSlice {
487            commodity_id: "commodity1".into(),
488            region_id: "GBR".into(),
489            time_slice: "winter".into(),
490            fraction: Dimensionless(0.5),
491        };
492        assert_error!(
493            read_demand_slices_from_iter(
494                [demand_slice, demand_slice_season].into_iter(),
495                &svd_commodities,
496                &region_ids,
497                &time_slice_info,
498            ),
499            "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
500                (commodity: commodity1, region: GBR, time slice(s): winter.day)"
501        );
502    }
503
504    #[rstest]
505    fn test_read_demand_slices_from_iter_invalid_bad_fractions(
506        svd_commodity: Commodity,
507        region_ids: IndexSet<RegionID>,
508        time_slice_info: TimeSliceInfo,
509    ) {
510        // Fractions don't sum to one
511        let svd_commodities = get_svd_map(&svd_commodity);
512        let demand_slice = DemandSlice {
513            commodity_id: "commodity1".into(),
514            region_id: "GBR".into(),
515            time_slice: "winter".into(),
516            fraction: Dimensionless(0.5),
517        };
518        assert_error!(
519            read_demand_slices_from_iter(
520                iter::once(demand_slice),
521                &svd_commodities,
522                &region_ids,
523                &time_slice_info,
524            ),
525            "Invalid demand fractions"
526        );
527    }
528}