muse2/input/commodity/
demand_slicing.rs

1//! Demand slicing determines how annual demand is distributed across the year.
2use super::super::{check_values_sum_to_one_approx, input_err_msg, read_csv};
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::{Context, Result, ensure};
10use indexmap::IndexSet;
11use itertools::{Itertools, iproduct};
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    fraction: Dimensionless,
24}
25
26/// A map relating commodity, region and time slice selection to the fraction of annual demand
27pub type DemandSliceMap = HashMap<(CommodityID, RegionID, TimeSliceSelection), Dimensionless>;
28
29/// Read demand slices from specified model directory.
30///
31/// # Arguments
32///
33/// * `model_dir` - Folder containing model configuration files
34/// * `svd_commodities` - Map of service demand commodities
35/// * `region_ids` - All possible IDs for regions
36/// * `commodity_regions` - Pairs of commodities + regions listed in demand CSV file
37/// * `time_slice_info` - Information about seasons and times of day
38pub fn read_demand_slices(
39    model_dir: &Path,
40    svd_commodities: &BorrowedCommodityMap,
41    region_ids: &IndexSet<RegionID>,
42    time_slice_info: &TimeSliceInfo,
43) -> Result<DemandSliceMap> {
44    let file_path = model_dir.join(DEMAND_SLICING_FILE_NAME);
45    let demand_slices_csv = read_csv(&file_path)?;
46    read_demand_slices_from_iter(
47        demand_slices_csv,
48        svd_commodities,
49        region_ids,
50        time_slice_info,
51    )
52    .with_context(|| input_err_msg(file_path))
53}
54
55/// Read demand slices from an iterator
56fn read_demand_slices_from_iter<I>(
57    iter: I,
58    svd_commodities: &BorrowedCommodityMap,
59    region_ids: &IndexSet<RegionID>,
60    time_slice_info: &TimeSliceInfo,
61) -> Result<DemandSliceMap>
62where
63    I: Iterator<Item = DemandSlice>,
64{
65    let mut demand_slices = DemandSliceMap::new();
66
67    for slice in iter {
68        let commodity = svd_commodities
69            .get(slice.commodity_id.as_str())
70            .with_context(|| {
71                format!(
72                    "Can only provide demand slice data for SVD commodities. Found entry for '{}'",
73                    slice.commodity_id
74                )
75            })?;
76        let region_id = region_ids.get_id(&slice.region_id)?;
77
78        // We need to know how many time slices are covered by the current demand slice entry and
79        // how long they are relative to one another so that we can divide up the demand for this
80        // entry appropriately
81        let ts_selection = time_slice_info.get_selection(&slice.time_slice)?;
82
83        // Share demand between the time slice selections in proportion to duration
84        let iter = time_slice_info
85            .calculate_share(&ts_selection, commodity.time_slice_level, slice.fraction)
86            .with_context(|| {
87                format!(
88                    "Cannot provide demand at {:?} level when commodity time slice level is {:?}",
89                    ts_selection.level(),
90                    commodity.time_slice_level
91                )
92            })?;
93        for (ts_selection, demand_fraction) in iter {
94            let existing = demand_slices
95                .insert(
96                    (
97                        commodity.id.clone(),
98                        region_id.clone(),
99                        ts_selection.clone(),
100                    ),
101                    demand_fraction,
102                )
103                .is_some();
104            ensure!(
105                !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,
109                region_id,
110                ts_selection
111            );
112        }
113    }
114
115    validate_demand_slices(svd_commodities, region_ids, &demand_slices, time_slice_info)?;
116
117    Ok(demand_slices)
118}
119
120/// Check that the [`DemandSliceMap`] is well formed.
121///
122/// Specifically, check:
123///
124/// * It is non-empty
125/// * For every commodity + region pair, there must be entries covering every time slice
126/// * The demand fractions for all entries related to a commodity + region pair sum to one
127fn validate_demand_slices(
128    svd_commodities: &BorrowedCommodityMap,
129    region_ids: &IndexSet<RegionID>,
130    demand_slices: &DemandSliceMap,
131    time_slice_info: &TimeSliceInfo,
132) -> Result<()> {
133    for (commodity, region_id) in iproduct!(svd_commodities.values(), region_ids) {
134        time_slice_info
135            .iter_selections_at_level(commodity.time_slice_level)
136            .map(|ts_selection| {
137                demand_slices
138                    .get(&(
139                        commodity.id.clone(),
140                        region_id.clone(),
141                        ts_selection.clone(),
142                    ))
143                    .with_context(|| {
144                        format!(
145                            "Demand slice missing for time slice(s) '{}' (commodity: {}, region {})",
146                            ts_selection, commodity.id, region_id
147                        )
148                    })
149            })
150            .process_results(|iter| {
151                check_values_sum_to_one_approx(iter.copied()).context("Invalid demand fractions")
152            })??;
153    }
154
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::commodity::Commodity;
162    use crate::fixture::{assert_error, get_svd_map, svd_commodity, time_slice_info};
163    use crate::time_slice::TimeSliceID;
164    use crate::units::Year;
165    use rstest::{fixture, rstest};
166    use std::iter;
167
168    #[fixture]
169    pub fn region_ids() -> IndexSet<RegionID> {
170        IndexSet::from(["GBR".into()])
171    }
172
173    #[rstest]
174    fn test_read_demand_slices_from_iter_valid(
175        svd_commodity: Commodity,
176        region_ids: IndexSet<RegionID>,
177        time_slice_info: TimeSliceInfo,
178    ) {
179        // Valid
180        let svd_commodities = get_svd_map(&svd_commodity);
181        let demand_slice = DemandSlice {
182            commodity_id: "commodity1".into(),
183            region_id: "GBR".into(),
184            time_slice: "winter".into(),
185            fraction: Dimensionless(1.0),
186        };
187        let time_slice = time_slice_info
188            .get_time_slice_id_from_str("winter.day")
189            .unwrap();
190        let key = ("commodity1".into(), "GBR".into(), time_slice.into());
191        let expected = DemandSliceMap::from_iter(iter::once((key, Dimensionless(1.0))));
192        assert_eq!(
193            read_demand_slices_from_iter(
194                iter::once(demand_slice.clone()),
195                &svd_commodities,
196                &region_ids,
197                &time_slice_info,
198            )
199            .unwrap(),
200            expected
201        );
202    }
203
204    #[rstest]
205    fn test_read_demand_slices_from_iter_valid_multiple_time_slices(
206        svd_commodity: Commodity,
207        region_ids: IndexSet<RegionID>,
208    ) {
209        // Valid, multiple time slices
210        let svd_commodities = get_svd_map(&svd_commodity);
211        let time_slice_info = TimeSliceInfo {
212            seasons: [("winter".into(), Year(0.5)), ("summer".into(), Year(0.5))]
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                    Year(3.0 / 16.0),
223                ),
224                (
225                    TimeSliceID {
226                        season: "summer".into(),
227                        time_of_day: "night".into(),
228                    },
229                    Year(5.0 / 16.0),
230                ),
231                (
232                    TimeSliceID {
233                        season: "winter".into(),
234                        time_of_day: "day".into(),
235                    },
236                    Year(3.0 / 16.0),
237                ),
238                (
239                    TimeSliceID {
240                        season: "winter".into(),
241                        time_of_day: "night".into(),
242                    },
243                    Year(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: [("winter".into(), Year(0.5)), ("summer".into(), Year(0.5))]
404                .into_iter()
405                .collect(),
406            times_of_day: iter::once("day".into()).collect(),
407            time_slices: [
408                (
409                    TimeSliceID {
410                        season: "winter".into(),
411                        time_of_day: "day".into(),
412                    },
413                    Year(0.5),
414                ),
415                (
416                    TimeSliceID {
417                        season: "summer".into(),
418                        time_of_day: "day".into(),
419                    },
420                    Year(0.5),
421                ),
422            ]
423            .into_iter()
424            .collect(),
425        };
426        let demand_slice = DemandSlice {
427            commodity_id: "commodity1".into(),
428            region_id: "GBR".into(),
429            time_slice: "winter".into(),
430            fraction: Dimensionless(1.0),
431        };
432        assert_error!(
433            read_demand_slices_from_iter(
434                iter::once(demand_slice.clone()),
435                &svd_commodities,
436                &region_ids,
437                &time_slice_info,
438            ),
439            "Demand slice missing for time slice(s) 'summer.day' (commodity: commodity1, region GBR)"
440        );
441    }
442
443    #[rstest]
444    fn test_read_demand_slices_from_iter_invalid_duplicate_time_slice(
445        svd_commodity: Commodity,
446        region_ids: IndexSet<RegionID>,
447        time_slice_info: TimeSliceInfo,
448    ) {
449        // Same time slice twice
450        let svd_commodities = get_svd_map(&svd_commodity);
451        let demand_slice = DemandSlice {
452            commodity_id: "commodity1".into(),
453            region_id: "GBR".into(),
454            time_slice: "winter.day".into(),
455            fraction: Dimensionless(0.5),
456        };
457        assert_error!(
458            read_demand_slices_from_iter(
459                iter::repeat_n(demand_slice.clone(), 2),
460                &svd_commodities,
461                &region_ids,
462                &time_slice_info,
463            ),
464            "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
465                (commodity: commodity1, region: GBR, time slice(s): winter.day)"
466        );
467    }
468
469    #[rstest]
470    fn test_read_demand_slices_from_iter_invalid_season_time_slice_conflict(
471        svd_commodity: Commodity,
472        region_ids: IndexSet<RegionID>,
473        time_slice_info: TimeSliceInfo,
474    ) {
475        // Whole season and single time slice conflicting
476        let svd_commodities = get_svd_map(&svd_commodity);
477        let demand_slice = DemandSlice {
478            commodity_id: "commodity1".into(),
479            region_id: "GBR".into(),
480            time_slice: "winter.day".into(),
481            fraction: Dimensionless(0.5),
482        };
483        let demand_slice_season = DemandSlice {
484            commodity_id: "commodity1".into(),
485            region_id: "GBR".into(),
486            time_slice: "winter".into(),
487            fraction: Dimensionless(0.5),
488        };
489        assert_error!(
490            read_demand_slices_from_iter(
491                [demand_slice, demand_slice_season].into_iter(),
492                &svd_commodities,
493                &region_ids,
494                &time_slice_info,
495            ),
496            "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
497                (commodity: commodity1, region: GBR, time slice(s): winter.day)"
498        );
499    }
500
501    #[rstest]
502    fn test_read_demand_slices_from_iter_invalid_bad_fractions(
503        svd_commodity: Commodity,
504        region_ids: IndexSet<RegionID>,
505        time_slice_info: TimeSliceInfo,
506    ) {
507        // Fractions don't sum to one
508        let svd_commodities = get_svd_map(&svd_commodity);
509        let demand_slice = DemandSlice {
510            commodity_id: "commodity1".into(),
511            region_id: "GBR".into(),
512            time_slice: "winter".into(),
513            fraction: Dimensionless(0.5),
514        };
515        assert_error!(
516            read_demand_slices_from_iter(
517                iter::once(demand_slice),
518                &svd_commodities,
519                &region_ids,
520                &time_slice_info,
521            ),
522            "Invalid demand fractions"
523        );
524    }
525}