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::{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    #[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!(
106                !existing,
107                "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
108                (commodity: {}, region: {}, time slice(s): {})",
109                commodity.id,
110                region_id,
111                ts_selection
112            );
113        }
114    }
115
116    validate_demand_slices(svd_commodities, region_ids, &demand_slices, time_slice_info)?;
117
118    Ok(demand_slices)
119}
120
121/// Check that the [`DemandSliceMap`] is well formed.
122///
123/// Specifically, check:
124///
125/// * It is non-empty
126/// * For every commodity + region pair, there must be entries covering every time slice
127/// * The demand fractions for all entries related to a commodity + region pair sum to one
128fn validate_demand_slices(
129    svd_commodities: &BorrowedCommodityMap,
130    region_ids: &IndexSet<RegionID>,
131    demand_slices: &DemandSliceMap,
132    time_slice_info: &TimeSliceInfo,
133) -> Result<()> {
134    for (commodity, region_id) in iproduct!(svd_commodities.values(), region_ids) {
135        time_slice_info
136            .iter_selections_at_level(commodity.time_slice_level)
137            .map(|ts_selection| {
138                demand_slices
139                    .get(&(
140                        commodity.id.clone(),
141                        region_id.clone(),
142                        ts_selection.clone(),
143                    ))
144                    .with_context(|| {
145                        format!(
146                            "Demand slice missing for time slice(s) '{}' (commodity: {}, region {})",
147                            ts_selection, commodity.id, region_id
148                        )
149                    })
150            })
151            .process_results(|iter| {
152                check_values_sum_to_one_approx(iter.copied()).context("Invalid demand fractions")
153            })??;
154    }
155
156    Ok(())
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::commodity::Commodity;
163    use crate::fixture::{assert_error, get_svd_map, svd_commodity, time_slice_info};
164    use crate::time_slice::TimeSliceID;
165    use crate::units::Year;
166    use rstest::{fixture, rstest};
167    use std::iter;
168
169    #[fixture]
170    pub fn region_ids() -> IndexSet<RegionID> {
171        IndexSet::from(["GBR".into()])
172    }
173
174    #[rstest]
175    fn test_read_demand_slices_from_iter_valid(
176        svd_commodity: Commodity,
177        region_ids: IndexSet<RegionID>,
178        time_slice_info: TimeSliceInfo,
179    ) {
180        // Valid
181        let svd_commodities = get_svd_map(&svd_commodity);
182        let demand_slice = DemandSlice {
183            commodity_id: "commodity1".into(),
184            region_id: "GBR".into(),
185            time_slice: "winter".into(),
186            fraction: Dimensionless(1.0),
187        };
188        let time_slice = time_slice_info
189            .get_time_slice_id_from_str("winter.day")
190            .unwrap();
191        let key = ("commodity1".into(), "GBR".into(), time_slice.into());
192        let expected = DemandSliceMap::from_iter(iter::once((key, Dimensionless(1.0))));
193        assert_eq!(
194            read_demand_slices_from_iter(
195                iter::once(demand_slice.clone()),
196                &svd_commodities,
197                &region_ids,
198                &time_slice_info,
199            )
200            .unwrap(),
201            expected
202        );
203    }
204
205    #[rstest]
206    fn test_read_demand_slices_from_iter_valid_multiple_time_slices(
207        svd_commodity: Commodity,
208        region_ids: IndexSet<RegionID>,
209    ) {
210        // Valid, multiple time slices
211        let svd_commodities = get_svd_map(&svd_commodity);
212        let time_slice_info = TimeSliceInfo {
213            seasons: [("winter".into(), Year(0.5)), ("summer".into(), Year(0.5))]
214                .into_iter()
215                .collect(),
216            times_of_day: ["day".into(), "night".into()].into_iter().collect(),
217            time_slices: [
218                (
219                    TimeSliceID {
220                        season: "summer".into(),
221                        time_of_day: "day".into(),
222                    },
223                    Year(3.0 / 16.0),
224                ),
225                (
226                    TimeSliceID {
227                        season: "summer".into(),
228                        time_of_day: "night".into(),
229                    },
230                    Year(5.0 / 16.0),
231                ),
232                (
233                    TimeSliceID {
234                        season: "winter".into(),
235                        time_of_day: "day".into(),
236                    },
237                    Year(3.0 / 16.0),
238                ),
239                (
240                    TimeSliceID {
241                        season: "winter".into(),
242                        time_of_day: "night".into(),
243                    },
244                    Year(5.0 / 16.0),
245                ),
246            ]
247            .into_iter()
248            .collect(),
249        };
250        let demand_slices = [
251            DemandSlice {
252                commodity_id: "commodity1".into(),
253                region_id: "GBR".into(),
254                time_slice: "winter".into(),
255                fraction: Dimensionless(0.5),
256            },
257            DemandSlice {
258                commodity_id: "commodity1".into(),
259                region_id: "GBR".into(),
260                time_slice: "summer".into(),
261                fraction: Dimensionless(0.5),
262            },
263        ];
264
265        fn demand_slice_entry(
266            season: &str,
267            time_of_day: &str,
268            fraction: Dimensionless,
269        ) -> ((CommodityID, RegionID, TimeSliceSelection), Dimensionless) {
270            (
271                (
272                    "commodity1".into(),
273                    "GBR".into(),
274                    TimeSliceID {
275                        season: season.into(),
276                        time_of_day: time_of_day.into(),
277                    }
278                    .into(),
279                ),
280                fraction,
281            )
282        }
283        let expected = DemandSliceMap::from_iter([
284            demand_slice_entry("summer", "day", Dimensionless(3.0 / 16.0)),
285            demand_slice_entry("summer", "night", Dimensionless(5.0 / 16.0)),
286            demand_slice_entry("winter", "day", Dimensionless(3.0 / 16.0)),
287            demand_slice_entry("winter", "night", Dimensionless(5.0 / 16.0)),
288        ]);
289
290        assert_eq!(
291            read_demand_slices_from_iter(
292                demand_slices.into_iter(),
293                &svd_commodities,
294                &region_ids,
295                &time_slice_info,
296            )
297            .unwrap(),
298            expected
299        );
300    }
301
302    #[rstest]
303    fn test_read_demand_slices_from_iter_invalid_empty_file(
304        svd_commodity: Commodity,
305        region_ids: IndexSet<RegionID>,
306        time_slice_info: TimeSliceInfo,
307    ) {
308        // Empty CSV file
309        let svd_commodities = get_svd_map(&svd_commodity);
310        assert_error!(
311            read_demand_slices_from_iter(
312                iter::empty(),
313                &svd_commodities,
314                &region_ids,
315                &time_slice_info,
316            ),
317            "Demand slice missing for time slice(s) 'winter.day' (commodity: commodity1, region GBR)"
318        );
319    }
320
321    #[rstest]
322    fn test_read_demand_slices_from_iter_invalid_bad_commodity(
323        svd_commodity: Commodity,
324        region_ids: IndexSet<RegionID>,
325        time_slice_info: TimeSliceInfo,
326    ) {
327        // Bad commodity
328        let svd_commodities = get_svd_map(&svd_commodity);
329        let demand_slice = DemandSlice {
330            commodity_id: "commodity2".into(),
331            region_id: "GBR".into(),
332            time_slice: "winter.day".into(),
333            fraction: Dimensionless(1.0),
334        };
335        assert_error!(
336            read_demand_slices_from_iter(
337                iter::once(demand_slice.clone()),
338                &svd_commodities,
339                &region_ids,
340                &time_slice_info,
341            ),
342            "Can only provide demand slice data for SVD commodities. Found entry for 'commodity2'"
343        );
344    }
345
346    #[rstest]
347    fn test_read_demand_slices_from_iter_invalid_bad_region(
348        svd_commodity: Commodity,
349        region_ids: IndexSet<RegionID>,
350        time_slice_info: TimeSliceInfo,
351    ) {
352        // Bad region
353        let svd_commodities = get_svd_map(&svd_commodity);
354        let demand_slice = DemandSlice {
355            commodity_id: "commodity1".into(),
356            region_id: "FRA".into(),
357            time_slice: "winter.day".into(),
358            fraction: Dimensionless(1.0),
359        };
360        assert_error!(
361            read_demand_slices_from_iter(
362                iter::once(demand_slice.clone()),
363                &svd_commodities,
364                &region_ids,
365                &time_slice_info,
366            ),
367            "Unknown ID FRA found"
368        );
369    }
370
371    #[rstest]
372    fn test_read_demand_slices_from_iter_invalid_bad_time_slice(
373        svd_commodity: Commodity,
374        region_ids: IndexSet<RegionID>,
375        time_slice_info: TimeSliceInfo,
376    ) {
377        // Bad time slice selection
378        let svd_commodities = get_svd_map(&svd_commodity);
379        let demand_slice = DemandSlice {
380            commodity_id: "commodity1".into(),
381            region_id: "GBR".into(),
382            time_slice: "summer".into(),
383            fraction: Dimensionless(1.0),
384        };
385        assert_error!(
386            read_demand_slices_from_iter(
387                iter::once(demand_slice.clone()),
388                &svd_commodities,
389                &region_ids,
390                &time_slice_info,
391            ),
392            "'summer' is not a valid season"
393        );
394    }
395
396    #[rstest]
397    fn test_read_demand_slices_from_iter_invalid_missing_time_slices(
398        svd_commodity: Commodity,
399        region_ids: IndexSet<RegionID>,
400    ) {
401        // Some time slices uncovered
402        let svd_commodities = get_svd_map(&svd_commodity);
403        let time_slice_info = TimeSliceInfo {
404            seasons: [("winter".into(), Year(0.5)), ("summer".into(), Year(0.5))]
405                .into_iter()
406                .collect(),
407            times_of_day: iter::once("day".into()).collect(),
408            time_slices: [
409                (
410                    TimeSliceID {
411                        season: "winter".into(),
412                        time_of_day: "day".into(),
413                    },
414                    Year(0.5),
415                ),
416                (
417                    TimeSliceID {
418                        season: "summer".into(),
419                        time_of_day: "day".into(),
420                    },
421                    Year(0.5),
422                ),
423            ]
424            .into_iter()
425            .collect(),
426        };
427        let demand_slice = DemandSlice {
428            commodity_id: "commodity1".into(),
429            region_id: "GBR".into(),
430            time_slice: "winter".into(),
431            fraction: Dimensionless(1.0),
432        };
433        assert_error!(
434            read_demand_slices_from_iter(
435                iter::once(demand_slice.clone()),
436                &svd_commodities,
437                &region_ids,
438                &time_slice_info,
439            ),
440            "Demand slice missing for time slice(s) 'summer.day' (commodity: commodity1, region GBR)"
441        );
442    }
443
444    #[rstest]
445    fn test_read_demand_slices_from_iter_invalid_duplicate_time_slice(
446        svd_commodity: Commodity,
447        region_ids: IndexSet<RegionID>,
448        time_slice_info: TimeSliceInfo,
449    ) {
450        // Same time slice twice
451        let svd_commodities = get_svd_map(&svd_commodity);
452        let demand_slice = DemandSlice {
453            commodity_id: "commodity1".into(),
454            region_id: "GBR".into(),
455            time_slice: "winter.day".into(),
456            fraction: Dimensionless(0.5),
457        };
458        assert_error!(
459            read_demand_slices_from_iter(
460                iter::repeat_n(demand_slice.clone(), 2),
461                &svd_commodities,
462                &region_ids,
463                &time_slice_info,
464            ),
465            "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
466                (commodity: commodity1, region: GBR, time slice(s): winter.day)"
467        );
468    }
469
470    #[rstest]
471    fn test_read_demand_slices_from_iter_invalid_season_time_slice_conflict(
472        svd_commodity: Commodity,
473        region_ids: IndexSet<RegionID>,
474        time_slice_info: TimeSliceInfo,
475    ) {
476        // Whole season and single time slice conflicting
477        let svd_commodities = get_svd_map(&svd_commodity);
478        let demand_slice = DemandSlice {
479            commodity_id: "commodity1".into(),
480            region_id: "GBR".into(),
481            time_slice: "winter.day".into(),
482            fraction: Dimensionless(0.5),
483        };
484        let demand_slice_season = DemandSlice {
485            commodity_id: "commodity1".into(),
486            region_id: "GBR".into(),
487            time_slice: "winter".into(),
488            fraction: Dimensionless(0.5),
489        };
490        assert_error!(
491            read_demand_slices_from_iter(
492                [demand_slice, demand_slice_season].into_iter(),
493                &svd_commodities,
494                &region_ids,
495                &time_slice_info,
496            ),
497            "Duplicate demand slicing entry (or same time slice covered by more than one entry) \
498                (commodity: commodity1, region: GBR, time slice(s): winter.day)"
499        );
500    }
501
502    #[rstest]
503    fn test_read_demand_slices_from_iter_invalid_bad_fractions(
504        svd_commodity: Commodity,
505        region_ids: IndexSet<RegionID>,
506        time_slice_info: TimeSliceInfo,
507    ) {
508        // Fractions don't sum to one
509        let svd_commodities = get_svd_map(&svd_commodity);
510        let demand_slice = DemandSlice {
511            commodity_id: "commodity1".into(),
512            region_id: "GBR".into(),
513            time_slice: "winter".into(),
514            fraction: Dimensionless(0.5),
515        };
516        assert_error!(
517            read_demand_slices_from_iter(
518                iter::once(demand_slice),
519                &svd_commodities,
520                &region_ids,
521                &time_slice_info,
522            ),
523            "Invalid demand fractions"
524        );
525    }
526}