muse2/input/commodity/
demand_slicing.rs

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