muse2/input/commodity/
demand_slicing.rs

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