muse2/input/commodity/
levy.rs

1//! Code for reading commodity levies from a CSV file.
2use super::super::{input_err_msg, read_csv_optional, try_insert};
3use crate::commodity::{BalanceType, CommodityID, 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 `commodity_levies.csv`.
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 `HashMap<CommodityID, HashMap<BalanceType, CommodityLevyMap>>` mapping each commodity to
48/// its per-balance-type levy maps.
49pub fn read_commodity_levies(
50    model_dir: &Path,
51    commodity_ids: &IndexSet<CommodityID>,
52    region_ids: &IndexSet<RegionID>,
53    time_slice_info: &TimeSliceInfo,
54    milestone_years: &[u32],
55) -> Result<HashMap<CommodityID, HashMap<BalanceType, CommodityLevyMap>>> {
56    let file_path = model_dir.join(COMMODITY_LEVIES_FILE_NAME);
57    let commodity_levies_csv = read_csv_optional(&file_path)?;
58    read_commodity_levies_iter(
59        commodity_levies_csv,
60        commodity_ids,
61        region_ids,
62        time_slice_info,
63        milestone_years,
64    )
65    .with_context(|| input_err_msg(&file_path))
66}
67
68/// Read commodity levies from an iterator of raw entries.
69///
70/// # Arguments
71///
72/// * `iter` - An iterator over raw commodity levy entries
73/// * `commodity_ids` - All possible commodity IDs
74/// * `region_ids` - All possible region IDs
75/// * `time_slice_info` - Information about time slices
76/// * `milestone_years` - All milestone years
77///
78/// # Returns
79///
80/// A `HashMap<CommodityID, HashMap<BalanceType, CommodityLevyMap>>` grouping levy maps by
81/// commodity and balance type.
82fn read_commodity_levies_iter<I>(
83    iter: I,
84    commodity_ids: &IndexSet<CommodityID>,
85    region_ids: &IndexSet<RegionID>,
86    time_slice_info: &TimeSliceInfo,
87    milestone_years: &[u32],
88) -> Result<HashMap<CommodityID, HashMap<BalanceType, CommodityLevyMap>>>
89where
90    I: Iterator<Item = CommodityLevyRaw>,
91{
92    let mut map = HashMap::new();
93
94    // Keep track of commodity/region combinations specified. We will check that all years and
95    // time slices are covered for each commodity/region combination.
96    let mut commodity_regions: HashMap<CommodityID, IndexSet<RegionID>> = HashMap::new();
97
98    for cost in iter {
99        let commodity_id = commodity_ids.get_id(&cost.commodity_id)?;
100        let regions = parse_region_str(&cost.regions, region_ids)?;
101        let years = parse_year_str(&cost.years, milestone_years)?;
102        let ts_selection = time_slice_info.get_selection(&cost.time_slice)?;
103
104        // Get or create CommodityLevyMap for this commodity
105        let map = map.entry(commodity_id.clone()).or_insert_with(HashMap::new);
106
107        // Insert cost into map for each region/year/time slice
108        for region in &regions {
109            commodity_regions
110                .entry(commodity_id.clone())
111                .or_default()
112                .insert(region.clone());
113            for year in &years {
114                for (time_slice, _) in ts_selection.iter(time_slice_info) {
115                    match cost.balance_type {
116                        // If production or consumption, we just add the levy to the relevant map
117                        BalanceType::Consumption | BalanceType::Production => {
118                            let map = map
119                                .entry(cost.balance_type.clone())
120                                .or_insert_with(CommodityLevyMap::new);
121                            try_insert(
122                                map,
123                                &(region.clone(), *year, time_slice.clone()),
124                                cost.value,
125                            )?;
126                        }
127                        // If net, we add it to both, reversing the sign for consumption
128                        BalanceType::Net => {
129                            let map_p = map
130                                .entry(BalanceType::Production)
131                                .or_insert_with(CommodityLevyMap::new);
132                            try_insert(
133                                map_p,
134                                &(region.clone(), *year, time_slice.clone()),
135                                cost.value,
136                            )?;
137                            let map_c = map
138                                .entry(BalanceType::Consumption)
139                                .or_insert_with(CommodityLevyMap::new);
140                            try_insert(
141                                map_c,
142                                &(region.clone(), *year, time_slice.clone()),
143                                -cost.value,
144                            )?;
145                        }
146                    }
147                }
148            }
149        }
150    }
151
152    // Validate map and complete with missing regions/years/time slices
153    for (commodity_id, regions) in &commodity_regions {
154        let map = map.get_mut(commodity_id).unwrap();
155
156        for map_inner in map.values_mut() {
157            validate_commodity_levy_map(map_inner, regions, milestone_years, time_slice_info)
158                .with_context(|| format!("Missing costs for commodity {commodity_id}"))?;
159
160            for region_id in region_ids.difference(regions) {
161                add_missing_region_to_commodity_levy_map(
162                    map_inner,
163                    region_id,
164                    milestone_years,
165                    time_slice_info,
166                );
167                warn!(
168                    "No levy specified for commodity {commodity_id} in region {region_id}. Assuming zero levy."
169                );
170            }
171        }
172    }
173
174    Ok(map)
175}
176
177/// Add a missing region to a commodity levy map with zero cost for all years and time slices.
178///
179/// # Arguments
180///
181/// * `map` - The commodity levy map to update
182/// * `region_id` - The region ID to add
183/// * `milestone_years` - Milestone years used by the model
184/// * `time_slice_info` - Time slice configuration
185fn add_missing_region_to_commodity_levy_map(
186    map: &mut CommodityLevyMap,
187    region_id: &RegionID,
188    milestone_years: &[u32],
189    time_slice_info: &TimeSliceInfo,
190) {
191    for year in milestone_years {
192        for time_slice in time_slice_info.iter_ids() {
193            map.insert(
194                (region_id.clone(), *year, time_slice.clone()),
195                MoneyPerFlow(0.0),
196            );
197        }
198    }
199}
200
201/// Validate that the commodity levy map contains entries for all regions, years and time slices.
202///
203/// # Arguments
204///
205/// * `map` - The commodity levy map to validate
206/// * `regions` - The set of regions that should be covered
207/// * `milestone_years` - Milestone years used by the model
208/// * `time_slice_info` - Time slice configuration
209///
210/// # Returns
211///
212/// `Ok(())` if the map is valid; an error if any entries are missing.
213fn validate_commodity_levy_map(
214    map: &CommodityLevyMap,
215    regions: &IndexSet<RegionID>,
216    milestone_years: &[u32],
217    time_slice_info: &TimeSliceInfo,
218) -> Result<()> {
219    // Check that all regions, years and time slices are covered
220    for region_id in regions {
221        for year in milestone_years {
222            for time_slice in time_slice_info.iter_ids() {
223                ensure!(
224                    map.contains_key(&(region_id.clone(), *year, time_slice.clone())),
225                    "Missing cost for region {region_id}, year {year}, time slice {time_slice}"
226                );
227            }
228        }
229    }
230    Ok(())
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::fixture::{assert_error, region_id, time_slice, time_slice_info};
237    use crate::time_slice::TimeSliceID;
238    use crate::units::Year;
239    use rstest::{fixture, rstest};
240
241    #[fixture]
242    fn region_ids(region_id: RegionID) -> IndexSet<RegionID> {
243        IndexSet::from([region_id])
244    }
245
246    #[fixture]
247    fn cost_map(time_slice: TimeSliceID) -> CommodityLevyMap {
248        let cost = MoneyPerFlow(1.0);
249
250        let mut map = CommodityLevyMap::new();
251        map.insert(("GBR".into(), 2020, time_slice.clone()), cost);
252        map
253    }
254
255    #[rstest]
256    fn validate_commodity_levies_map_valid(
257        cost_map: CommodityLevyMap,
258        time_slice_info: TimeSliceInfo,
259        region_ids: IndexSet<RegionID>,
260    ) {
261        // Valid map
262        validate_commodity_levy_map(&cost_map, &region_ids, &[2020], &time_slice_info).unwrap();
263    }
264
265    #[rstest]
266    fn validate_commodity_levies_map_invalid_missing_region(
267        cost_map: CommodityLevyMap,
268        time_slice_info: TimeSliceInfo,
269    ) {
270        // Missing region
271        let region_ids = IndexSet::from(["GBR".into(), "FRA".into()]);
272        assert_error!(
273            validate_commodity_levy_map(&cost_map, &region_ids, &[2020], &time_slice_info),
274            "Missing cost for region FRA, year 2020, time slice winter.day"
275        );
276    }
277
278    #[rstest]
279    fn validate_commodity_levies_map_invalid_missing_year(
280        cost_map: CommodityLevyMap,
281        time_slice_info: TimeSliceInfo,
282        region_ids: IndexSet<RegionID>,
283    ) {
284        // Missing year
285        assert_error!(
286            validate_commodity_levy_map(&cost_map, &region_ids, &[2020, 2030], &time_slice_info),
287            "Missing cost for region GBR, year 2030, time slice winter.day"
288        );
289    }
290
291    #[rstest]
292    fn validate_commodity_levies_map_invalid(
293        cost_map: CommodityLevyMap,
294        region_ids: IndexSet<RegionID>,
295    ) {
296        // Missing time slice
297        let time_slice = TimeSliceID {
298            season: "winter".into(),
299            time_of_day: "night".into(),
300        };
301        let time_slice_info = TimeSliceInfo {
302            seasons: [("winter".into(), Year(1.0))].into(),
303            times_of_day: ["day".into(), "night".into()].into(),
304            time_slices: [
305                (time_slice.clone(), Year(0.5)),
306                (time_slice.clone(), Year(0.5)),
307            ]
308            .into(),
309        };
310        assert_error!(
311            validate_commodity_levy_map(&cost_map, &region_ids, &[2020], &time_slice_info),
312            "Missing cost for region GBR, year 2020, time slice winter.night"
313        );
314    }
315
316    #[rstest]
317    fn add_missing_region_to_commodity_levy_map_works(
318        cost_map: CommodityLevyMap,
319        time_slice_info: TimeSliceInfo,
320        region_id: RegionID,
321    ) {
322        let mut cost_map = cost_map;
323        add_missing_region_to_commodity_levy_map(
324            &mut cost_map,
325            &region_id,
326            &[2020],
327            &time_slice_info,
328        );
329
330        // Check that costs have been added for the new region
331        for time_slice in time_slice_info.iter_ids() {
332            assert_eq!(
333                cost_map.get(&(region_id.clone(), 2020, time_slice.clone())),
334                Some(&MoneyPerFlow(0.0))
335            );
336        }
337    }
338}