muse2/input/commodity/
levy.rs

1//! Code for reading in the commodity levies CSV file.
2use super::super::*;
3use crate::commodity::{BalanceType, CommodityID, CommodityLevy, CommodityLevyMap};
4use crate::id::IDCollection;
5use crate::region::{RegionID, parse_region_str};
6use crate::time_slice::TimeSliceInfo;
7use crate::units::MoneyPerFlow;
8use crate::year::parse_year_str;
9use anyhow::{Context, Result, ensure};
10use indexmap::IndexSet;
11use log::warn;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::Path;
15
16const COMMODITY_LEVIES_FILE_NAME: &str = "commodity_levies.csv";
17
18/// Cost parameters for each commodity
19#[derive(PartialEq, Debug, Deserialize, Clone)]
20struct CommodityLevyRaw {
21    /// Unique identifier for the commodity (e.g. "ELC")
22    commodity_id: String,
23    /// The region(s) to which the levy applies.
24    regions: String,
25    /// Type of balance for application of cost.
26    balance_type: BalanceType,
27    /// The year(s) to which the cost applies.
28    years: String,
29    /// The time slice to which the cost applies.
30    time_slice: String,
31    /// Cost per unit commodity
32    value: MoneyPerFlow,
33}
34
35/// Read costs associated with each commodity from levies CSV file.
36///
37/// # Arguments
38///
39/// * `model_dir` - Folder containing model configuration files
40/// * `commodity_ids` - All possible commodity IDs
41/// * `region_ids` - All possible region IDs
42/// * `time_slice_info` - Information about time slices
43/// * `milestone_years` - All milestone years
44///
45/// # Returns
46///
47/// A map containing levies, grouped by commodity ID.
48pub fn read_commodity_levies(
49    model_dir: &Path,
50    commodity_ids: &IndexSet<CommodityID>,
51    region_ids: &IndexSet<RegionID>,
52    time_slice_info: &TimeSliceInfo,
53    milestone_years: &[u32],
54) -> Result<HashMap<CommodityID, CommodityLevyMap>> {
55    let file_path = model_dir.join(COMMODITY_LEVIES_FILE_NAME);
56    let commodity_levies_csv = read_csv::<CommodityLevyRaw>(&file_path)?;
57    read_commodity_levies_iter(
58        commodity_levies_csv,
59        commodity_ids,
60        region_ids,
61        time_slice_info,
62        milestone_years,
63    )
64    .with_context(|| input_err_msg(&file_path))
65}
66
67/// Read costs associated with each commodity from an iterator over raw cost entries.
68///
69/// # Arguments
70///
71/// * `iter` - An iterator over raw commodity levy entries
72/// * `commodity_ids` - All possible commodity IDs
73/// * `region_ids` - All possible region IDs
74/// * `time_slice_info` - Information about time slices
75/// * `milestone_years` - All milestone years
76///
77/// # Returns
78///
79/// A map containing levies, grouped by commodity ID.
80fn read_commodity_levies_iter<I>(
81    iter: I,
82    commodity_ids: &IndexSet<CommodityID>,
83    region_ids: &IndexSet<RegionID>,
84    time_slice_info: &TimeSliceInfo,
85    milestone_years: &[u32],
86) -> Result<HashMap<CommodityID, CommodityLevyMap>>
87where
88    I: Iterator<Item = CommodityLevyRaw>,
89{
90    let mut map = HashMap::new();
91
92    // Keep track of commodity/region combinations specified. We will check that all years and
93    // time slices are covered for each commodity/region combination.
94    let mut commodity_regions: HashMap<CommodityID, IndexSet<RegionID>> = HashMap::new();
95
96    for cost in iter {
97        let commodity_id = commodity_ids.get_id(&cost.commodity_id)?;
98        let regions = parse_region_str(&cost.regions, region_ids)?;
99        let years = parse_year_str(&cost.years, milestone_years)?;
100        let ts_selection = time_slice_info.get_selection(&cost.time_slice)?;
101
102        // Get or create CommodityLevyMap for this commodity
103        let map = map
104            .entry(commodity_id.clone())
105            .or_insert_with(CommodityLevyMap::new);
106
107        // Create CommodityLevy
108        let cost = CommodityLevy {
109            balance_type: cost.balance_type,
110            value: cost.value,
111        };
112
113        // Insert cost into map for each region/year/time slice
114        for region in regions.iter() {
115            commodity_regions
116                .entry(commodity_id.clone())
117                .or_default()
118                .insert(region.clone());
119            for year in years.iter() {
120                for (time_slice, _) in ts_selection.iter(time_slice_info) {
121                    try_insert(
122                        map,
123                        (region.clone(), *year, time_slice.clone()),
124                        cost.clone(),
125                    )?;
126                }
127            }
128        }
129    }
130
131    // Validate map and complete with missing regions/years/time slices
132    for (commodity_id, regions) in commodity_regions.iter() {
133        let map = map.get_mut(commodity_id).unwrap();
134        validate_commodity_levy_map(map, regions, milestone_years, time_slice_info)
135            .with_context(|| format!("Missing costs for commodity {commodity_id}"))?;
136
137        for region_id in region_ids.difference(regions) {
138            add_missing_region_to_commodity_levy_map(
139                map,
140                region_id,
141                milestone_years,
142                time_slice_info,
143            );
144            warn!(
145                "No levy specified for commodity {commodity_id} in region {region_id}. Assuming zero levy."
146            );
147        }
148    }
149
150    Ok(map)
151}
152
153/// Add missing region to commodity levy map with zero cost for all years and time slices.
154///
155/// # Arguments
156///
157/// * `map` - The commodity levy map to update
158/// * `region_id` - The region ID to add
159/// * `milestone_years` - All milestone years
160/// * `time_slice_info` - Information about time slices
161fn add_missing_region_to_commodity_levy_map(
162    map: &mut CommodityLevyMap,
163    region_id: &RegionID,
164    milestone_years: &[u32],
165    time_slice_info: &TimeSliceInfo,
166) {
167    for year in milestone_years.iter() {
168        for time_slice in time_slice_info.iter_ids() {
169            map.insert(
170                (region_id.clone(), *year, time_slice.clone()),
171                CommodityLevy {
172                    balance_type: BalanceType::Net,
173                    value: MoneyPerFlow(0.0),
174                },
175            );
176        }
177    }
178}
179
180/// Validate that the commodity levy map contains entries for all regions, years and time slices.
181///
182/// # Arguments
183///
184/// * `map` - The commodity levy map to validate
185/// * `regions` - The set of regions that should be covered
186/// * `milestone_years` - All milestone years
187/// * `time_slice_info` - Information about time slices
188///
189/// # Returns
190///
191/// Nothing if the map is valid. An error if the map is missing any entries.
192fn validate_commodity_levy_map(
193    map: &CommodityLevyMap,
194    regions: &IndexSet<RegionID>,
195    milestone_years: &[u32],
196    time_slice_info: &TimeSliceInfo,
197) -> Result<()> {
198    // Check that all regions, years and time slices are covered
199    for region_id in regions.iter() {
200        for year in milestone_years.iter() {
201            for time_slice in time_slice_info.iter_ids() {
202                ensure!(
203                    map.contains_key(&(region_id.clone(), *year, time_slice.clone())),
204                    "Missing cost for region {}, year {}, time slice {}",
205                    region_id,
206                    year,
207                    time_slice
208                );
209            }
210        }
211    }
212    Ok(())
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::fixture::{assert_error, region_id, time_slice, time_slice_info};
219    use crate::time_slice::TimeSliceID;
220    use crate::units::Year;
221    use rstest::{fixture, rstest};
222
223    #[fixture]
224    fn region_ids(region_id: RegionID) -> IndexSet<RegionID> {
225        IndexSet::from([region_id])
226    }
227
228    #[fixture]
229    fn cost_map(time_slice: TimeSliceID) -> CommodityLevyMap {
230        let cost = CommodityLevy {
231            balance_type: BalanceType::Net,
232            value: MoneyPerFlow(1.0),
233        };
234
235        let mut map = CommodityLevyMap::new();
236        map.insert(("GBR".into(), 2020, time_slice.clone()), cost.clone());
237        map
238    }
239
240    #[rstest]
241    fn test_validate_commodity_levies_map_valid(
242        cost_map: CommodityLevyMap,
243        time_slice_info: TimeSliceInfo,
244        region_ids: IndexSet<RegionID>,
245    ) {
246        // Valid map
247        assert!(
248            validate_commodity_levy_map(&cost_map, &region_ids, &[2020], &time_slice_info).is_ok()
249        );
250    }
251
252    #[rstest]
253    fn test_validate_commodity_levies_map_invalid_missing_region(
254        cost_map: CommodityLevyMap,
255        time_slice_info: TimeSliceInfo,
256    ) {
257        // Missing region
258        let region_ids = IndexSet::from(["GBR".into(), "FRA".into()]);
259        assert_error!(
260            validate_commodity_levy_map(&cost_map, &region_ids, &[2020], &time_slice_info),
261            "Missing cost for region FRA, year 2020, time slice winter.day"
262        );
263    }
264
265    #[rstest]
266    fn test_validate_commodity_levies_map_invalid_missing_year(
267        cost_map: CommodityLevyMap,
268        time_slice_info: TimeSliceInfo,
269        region_ids: IndexSet<RegionID>,
270    ) {
271        // Missing year
272        assert_error!(
273            validate_commodity_levy_map(&cost_map, &region_ids, &[2020, 2030], &time_slice_info),
274            "Missing cost for region GBR, year 2030, time slice winter.day"
275        );
276    }
277
278    #[rstest]
279    fn test_validate_commodity_levies_map_invalid(
280        cost_map: CommodityLevyMap,
281        region_ids: IndexSet<RegionID>,
282    ) {
283        // Missing time slice
284        let time_slice = TimeSliceID {
285            season: "winter".into(),
286            time_of_day: "night".into(),
287        };
288        let time_slice_info = TimeSliceInfo {
289            seasons: [("winter".into(), Year(1.0))].into(),
290            times_of_day: ["day".into(), "night".into()].into(),
291            time_slices: [
292                (time_slice.clone(), Year(0.5)),
293                (time_slice.clone(), Year(0.5)),
294            ]
295            .into(),
296        };
297        assert_error!(
298            validate_commodity_levy_map(&cost_map, &region_ids, &[2020], &time_slice_info),
299            "Missing cost for region GBR, year 2020, time slice winter.night"
300        );
301    }
302
303    #[rstest]
304    fn test_add_missing_region_to_commodity_levy_map(
305        cost_map: CommodityLevyMap,
306        time_slice_info: TimeSliceInfo,
307        region_id: RegionID,
308    ) {
309        let mut cost_map = cost_map;
310        add_missing_region_to_commodity_levy_map(
311            &mut cost_map,
312            &region_id,
313            &[2020],
314            &time_slice_info,
315        );
316
317        // Check that costs have been added for the new region
318        for time_slice in time_slice_info.iter_ids() {
319            assert_eq!(
320                cost_map.get(&(region_id.clone(), 2020, time_slice.clone())),
321                Some(&CommodityLevy {
322                    balance_type: BalanceType::Net,
323                    value: MoneyPerFlow(0.0)
324                })
325            );
326        }
327    }
328}