muse2/input/commodity/
demand.rs

1//! Code for working with demand for a given commodity. Demand can vary by region, year and time
2//! slice.
3use super::super::*;
4use super::demand_slicing::{read_demand_slices, DemandSliceMap};
5use crate::commodity::{Commodity, CommodityID, CommodityType, DemandMap};
6use crate::id::IDCollection;
7use crate::region::RegionID;
8use crate::time_slice::TimeSliceInfo;
9use anyhow::{ensure, Result};
10use serde::Deserialize;
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13
14const DEMAND_FILE_NAME: &str = "demand.csv";
15
16/// Represents a single demand entry in the dataset.
17#[derive(Debug, Clone, Deserialize, PartialEq)]
18struct Demand {
19    /// The commodity this demand entry refers to
20    commodity_id: String,
21    /// The region of the demand entry
22    region_id: String,
23    /// The year of the demand entry
24    year: u32,
25    /// Annual demand quantity
26    demand: f64,
27}
28
29/// A map relating commodity, region and year to annual demand
30pub type AnnualDemandMap = HashMap<(CommodityID, RegionID, u32), f64>;
31
32/// Reads demand data from CSV files.
33///
34/// # Arguments
35///
36/// * `model_dir` - Folder containing model configuration files
37/// * `commodity_ids` - All possible IDs of commodities
38/// * `region_ids` - All possible IDs for regions
39/// * `time_slice_info` - Information about seasons and times of day
40/// * `milestone_years` - All milestone years
41///
42/// # Returns
43///
44/// This function returns [`DemandMap`]s grouped by commodity ID.
45pub fn read_demand(
46    model_dir: &Path,
47    commodities: &IndexMap<CommodityID, Commodity>,
48    region_ids: &HashSet<RegionID>,
49    time_slice_info: &TimeSliceInfo,
50    milestone_years: &[u32],
51) -> Result<HashMap<CommodityID, DemandMap>> {
52    // Get set of SVD commodity IDs
53    let svd_commodity_ids: HashSet<CommodityID> = commodities
54        .iter()
55        .filter(|(_, commodity)| commodity.kind == CommodityType::ServiceDemand)
56        .map(|(id, _)| id.clone())
57        .collect();
58
59    let demand = read_demand_file(model_dir, &svd_commodity_ids, region_ids, milestone_years)?;
60    let slices = read_demand_slices(model_dir, &svd_commodity_ids, region_ids, time_slice_info)?;
61
62    Ok(compute_demand_maps(&demand, &slices, time_slice_info))
63}
64
65/// Read the demand.csv file.
66///
67/// # Arguments
68///
69/// * `model_dir` - Folder containing model configuration files
70/// * `commodity_ids` - All possible IDs of commodities
71/// * `region_ids` - All possible IDs for regions
72/// * `milestone_years` - All milestone years
73///
74/// # Returns
75///
76/// Annual demand data, grouped by commodity, region and milestone year.
77fn read_demand_file(
78    model_dir: &Path,
79    svd_commodity_ids: &HashSet<CommodityID>,
80    region_ids: &HashSet<RegionID>,
81    milestone_years: &[u32],
82) -> Result<AnnualDemandMap> {
83    let file_path = model_dir.join(DEMAND_FILE_NAME);
84    let iter = read_csv(&file_path)?;
85    read_demand_from_iter(iter, svd_commodity_ids, region_ids, milestone_years)
86}
87
88/// Read the demand data from an iterator.
89///
90/// # Arguments
91///
92/// * `iter` - An iterator of [`Demand`]s
93/// * `commodity_ids` - All possible IDs of commodities
94/// * `region_ids` - All possible IDs for regions
95/// * `milestone_years` - All milestone years
96///
97/// # Returns
98///
99/// The demand for each combination of commodity, region and year along with a [`HashSet`] of all
100/// commodity + region pairs included in the file.
101fn read_demand_from_iter<I>(
102    iter: I,
103    svd_commodity_ids: &HashSet<CommodityID>,
104    region_ids: &HashSet<RegionID>,
105    milestone_years: &[u32],
106) -> Result<AnnualDemandMap>
107where
108    I: Iterator<Item = Demand>,
109{
110    let mut map = AnnualDemandMap::new();
111    for demand in iter {
112        let commodity_id = svd_commodity_ids
113            .get_id_by_str(&demand.commodity_id)
114            .with_context(|| {
115                format!(
116                    "Can only provide demand data for SVD commodities. Found entry for '{}'",
117                    demand.commodity_id
118                )
119            })?;
120        let region_id = region_ids.get_id_by_str(&demand.region_id)?;
121
122        ensure!(
123            milestone_years.binary_search(&demand.year).is_ok(),
124            "Year {} is not a milestone year. \
125            Input of non-milestone years is currently not supported.",
126            demand.year
127        );
128
129        ensure!(
130            demand.demand.is_normal() && demand.demand > 0.0,
131            "Demand must be a valid number greater than zero"
132        );
133
134        ensure!(
135            map.insert(
136                (commodity_id.clone(), region_id.clone(), demand.year),
137                demand.demand
138            )
139            .is_none(),
140            "Duplicate demand entries (commodity: {}, region: {}, year: {})",
141            commodity_id,
142            region_id,
143            demand.year
144        );
145    }
146
147    // Check that demand data is specified for all combinations of commodity, region and year
148    for commodity_id in svd_commodity_ids {
149        let mut missing_keys = Vec::new();
150        for region_id in region_ids {
151            for year in milestone_years {
152                if !map.contains_key(&(commodity_id.clone(), region_id.clone(), *year)) {
153                    missing_keys.push((region_id.clone(), *year));
154                }
155            }
156        }
157        ensure!(
158            missing_keys.is_empty(),
159            "Commodity {} is missing demand data for {:?}",
160            commodity_id,
161            missing_keys
162        );
163    }
164
165    Ok(map)
166}
167
168/// Calculate the demand for each combination of commodity, region, year and time slice.
169///
170/// # Arguments
171///
172/// * `demand` - Total annual demand for combinations of commodity, region and year
173/// * `slices` - How annual demand is shared between time slices
174/// * `time_slice_info` - Information about time slices
175///
176/// # Returns
177///
178/// [`DemandMap`]s for combinations of region, year and time slice, grouped by the commodity to
179/// which the demand applies.
180fn compute_demand_maps(
181    demand: &AnnualDemandMap,
182    slices: &DemandSliceMap,
183    time_slice_info: &TimeSliceInfo,
184) -> HashMap<CommodityID, DemandMap> {
185    let mut map = HashMap::new();
186    for ((commodity_id, region_id, year), annual_demand) in demand.iter() {
187        for time_slice in time_slice_info.iter_ids() {
188            let slice_key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
189
190            // NB: This has already been checked, so shouldn't fail
191            let demand_fraction = slices.get(&slice_key).unwrap();
192
193            // Get or create entry
194            let map = map
195                .entry(commodity_id.clone())
196                .or_insert_with(DemandMap::new);
197
198            // Add a new demand entry
199            map.insert(
200                (region_id.clone(), *year, time_slice.clone()),
201                annual_demand * demand_fraction,
202            );
203        }
204    }
205
206    map
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::fs::File;
213    use std::io::Write;
214    use std::iter;
215    use std::path::Path;
216    use tempfile::tempdir;
217
218    /// Create an example demand file in dir_path
219    fn create_demand_file(dir_path: &Path) {
220        let file_path = dir_path.join(DEMAND_FILE_NAME);
221        let mut file = File::create(file_path).unwrap();
222        writeln!(
223            file,
224            "commodity_id,region_id,year,demand
225COM1,North,2020,10
226COM1,South,2020,11
227COM1,East,2020,12
228COM1,West,2020,13"
229        )
230        .unwrap();
231    }
232
233    #[test]
234    fn test_read_demand_from_iter() {
235        let commodity_ids = ["COM1".into()].into_iter().collect();
236        let region_ids = ["North".into(), "South".into()].into_iter().collect();
237        let milestone_years = [2020];
238
239        // Valid
240        let demand = [
241            Demand {
242                year: 2020,
243                region_id: "North".to_string(),
244                commodity_id: "COM1".to_string(),
245                demand: 10.0,
246            },
247            Demand {
248                year: 2020,
249                region_id: "South".to_string(),
250                commodity_id: "COM1".to_string(),
251                demand: 11.0,
252            },
253        ];
254        assert!(read_demand_from_iter(
255            demand.into_iter(),
256            &commodity_ids,
257            &region_ids,
258            &milestone_years
259        )
260        .is_ok());
261
262        // Bad commodity ID
263        let demand = [
264            Demand {
265                year: 2020,
266                region_id: "North".to_string(),
267                commodity_id: "COM2".to_string(),
268                demand: 10.0,
269            },
270            Demand {
271                year: 2020,
272                region_id: "South".to_string(),
273                commodity_id: "COM1".to_string(),
274                demand: 11.0,
275            },
276        ];
277        assert!(read_demand_from_iter(
278            demand.into_iter(),
279            &commodity_ids,
280            &region_ids,
281            &milestone_years
282        )
283        .is_err());
284
285        // Bad region ID
286        let demand = [
287            Demand {
288                year: 2020,
289                region_id: "East".to_string(),
290                commodity_id: "COM1".to_string(),
291                demand: 10.0,
292            },
293            Demand {
294                year: 2020,
295                region_id: "South".to_string(),
296                commodity_id: "COM1".to_string(),
297                demand: 11.0,
298            },
299        ];
300        assert!(read_demand_from_iter(
301            demand.into_iter(),
302            &commodity_ids,
303            &region_ids,
304            &milestone_years
305        )
306        .is_err());
307
308        // Bad year
309        let demand = [
310            Demand {
311                year: 2010,
312                region_id: "North".to_string(),
313                commodity_id: "COM1".to_string(),
314                demand: 10.0,
315            },
316            Demand {
317                year: 2020,
318                region_id: "South".to_string(),
319                commodity_id: "COM1".to_string(),
320                demand: 11.0,
321            },
322        ];
323        assert!(read_demand_from_iter(
324            demand.into_iter(),
325            &commodity_ids,
326            &region_ids,
327            &milestone_years
328        )
329        .is_err());
330
331        // Bad demand quantity
332        macro_rules! test_quantity {
333            ($quantity: expr) => {
334                let demand = [Demand {
335                    year: 2020,
336                    region_id: "North".to_string(),
337                    commodity_id: "COM1".to_string(),
338                    demand: $quantity,
339                }];
340                assert!(read_demand_from_iter(
341                    demand.into_iter(),
342                    &commodity_ids,
343                    &region_ids,
344                    &milestone_years,
345                )
346                .is_err());
347            };
348        }
349        test_quantity!(-1.0);
350        test_quantity!(0.0);
351        test_quantity!(f64::NAN);
352        test_quantity!(f64::NEG_INFINITY);
353        test_quantity!(f64::INFINITY);
354
355        // Multiple entries for same commodity and region
356        let demand = [
357            Demand {
358                year: 2020,
359                region_id: "North".to_string(),
360                commodity_id: "COM1".to_string(),
361                demand: 10.0,
362            },
363            Demand {
364                year: 2020,
365                region_id: "North".to_string(),
366                commodity_id: "COM1".to_string(),
367                demand: 10.0,
368            },
369            Demand {
370                year: 2020,
371                region_id: "South".to_string(),
372                commodity_id: "COM1".to_string(),
373                demand: 11.0,
374            },
375        ];
376        assert!(read_demand_from_iter(
377            demand.into_iter(),
378            &commodity_ids,
379            &region_ids,
380            &milestone_years
381        )
382        .is_err());
383
384        // Missing entry for a milestone year
385        let demand = Demand {
386            year: 2020,
387            region_id: "North".to_string(),
388            commodity_id: "COM1".to_string(),
389            demand: 10.0,
390        };
391        assert!(read_demand_from_iter(
392            iter::once(demand),
393            &commodity_ids,
394            &region_ids,
395            &[2020, 2030]
396        )
397        .is_err());
398    }
399
400    #[test]
401    fn test_read_demand_file() {
402        let dir = tempdir().unwrap();
403        create_demand_file(dir.path());
404        let commodity_ids = HashSet::from_iter(iter::once("COM1".into()));
405        let region_ids =
406            HashSet::from_iter(["North".into(), "South".into(), "East".into(), "West".into()]);
407        let milestone_years = [2020];
408        let expected = AnnualDemandMap::from_iter([
409            (("COM1".into(), "North".into(), 2020), 10.0),
410            (("COM1".into(), "South".into(), 2020), 11.0),
411            (("COM1".into(), "East".into(), 2020), 12.0),
412            (("COM1".into(), "West".into(), 2020), 13.0),
413        ]);
414        let demand =
415            read_demand_file(dir.path(), &commodity_ids, &region_ids, &milestone_years).unwrap();
416        assert_eq!(demand, expected);
417    }
418}