Skip to main content

muse2/input/
commodity.rs

1//! Code for reading commodity-related data from CSV files.
2use super::{input_err_msg, read_csv};
3use crate::ISSUES_URL;
4use crate::commodity::{
5    BalanceType, Commodity, CommodityID, CommodityLevyMap, CommodityMap, CommodityType, DemandMap,
6    PricingStrategy,
7};
8use crate::model::{ALLOW_DANGEROUS_OPTION_NAME, dangerous_model_options_enabled};
9use crate::region::RegionID;
10use crate::time_slice::{TimeSliceInfo, TimeSliceLevel};
11use anyhow::{Context, Ok, Result, ensure};
12use indexmap::{IndexMap, IndexSet};
13use log::warn;
14use serde::Deserialize;
15use std::path::Path;
16
17mod levy;
18use levy::read_commodity_levies;
19mod demand;
20use demand::read_demand;
21mod demand_slicing;
22
23const COMMODITY_FILE_NAME: &str = "commodities.csv";
24
25#[derive(PartialEq, Debug, Deserialize)]
26struct CommodityRaw {
27    pub id: CommodityID,
28    pub description: String,
29    #[serde(rename = "type")] // NB: we can't name a field type as it's a reserved keyword
30    pub kind: CommodityType,
31    pub time_slice_level: TimeSliceLevel,
32    pub pricing_strategy: Option<PricingStrategy>,
33    pub units: String,
34}
35
36/// Read commodity data from the specified model directory.
37///
38/// # Arguments
39///
40/// * `model_dir` - Folder containing model configuration files
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/// An `IndexMap` mapping `CommodityID` to `Commodity`, or an error.
48pub fn read_commodities(
49    model_dir: &Path,
50    region_ids: &IndexSet<RegionID>,
51    time_slice_info: &TimeSliceInfo,
52    milestone_years: &[u32],
53) -> Result<CommodityMap> {
54    // Read commodities table
55    let commodities = read_commodities_file(model_dir)?;
56    let commodity_ids = commodities.keys().cloned().collect();
57
58    // Read costs table
59    let mut costs = read_commodity_levies(
60        model_dir,
61        &commodity_ids,
62        region_ids,
63        time_slice_info,
64        milestone_years,
65    )?;
66
67    // Read demand table
68    let mut demand = read_demand(
69        model_dir,
70        &commodities,
71        region_ids,
72        time_slice_info,
73        milestone_years,
74    )?;
75
76    // Populate maps for each Commodity
77    Ok(commodities
78        .into_iter()
79        .map(|(id, mut commodity)| {
80            if let Some(mut costs) = costs.remove(&id) {
81                if let Some(levies) = costs.remove(&BalanceType::Consumption) {
82                    commodity.levies_cons = levies;
83                }
84                if let Some(levies) = costs.remove(&BalanceType::Production) {
85                    commodity.levies_prod = levies;
86                }
87            }
88            if let Some(demand) = demand.remove(&id) {
89                commodity.demand = demand;
90            }
91
92            (id, commodity.into())
93        })
94        .collect())
95}
96
97fn read_commodities_file(model_dir: &Path) -> Result<IndexMap<CommodityID, Commodity>> {
98    let file_path = model_dir.join(COMMODITY_FILE_NAME);
99    let commodities_csv = read_csv(&file_path)?;
100    read_commodities_file_from_iter(commodities_csv).with_context(|| input_err_msg(&file_path))
101}
102
103fn read_commodities_file_from_iter<I>(iter: I) -> Result<IndexMap<CommodityID, Commodity>>
104where
105    I: Iterator<Item = CommodityRaw>,
106{
107    let mut commodities = IndexMap::new();
108    for commodity_raw in iter {
109        let pricing_strategy = match commodity_raw.pricing_strategy {
110            Some(strategy) => strategy,
111            None => default_pricing_strategy(&commodity_raw.kind),
112        };
113
114        let commodity = Commodity {
115            id: commodity_raw.id.clone(),
116            description: commodity_raw.description,
117            kind: commodity_raw.kind,
118            time_slice_level: commodity_raw.time_slice_level,
119            pricing_strategy,
120            levies_prod: CommodityLevyMap::default(),
121            levies_cons: CommodityLevyMap::default(),
122            demand: DemandMap::default(),
123            units: commodity_raw.units,
124        };
125
126        validate_commodity(&commodity)?;
127
128        ensure!(
129            commodities.insert(commodity_raw.id, commodity).is_none(),
130            "Duplicate commodity ID"
131        );
132    }
133
134    Ok(commodities)
135}
136
137/// Get the default pricing strategy for a given commodity kind.
138fn default_pricing_strategy(commodity_kind: &CommodityType) -> PricingStrategy {
139    match commodity_kind {
140        CommodityType::Other => PricingStrategy::Unpriced,
141        CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand => {
142            PricingStrategy::FullCostAverage
143        }
144    }
145}
146
147fn validate_commodity(commodity: &Commodity) -> Result<()> {
148    // Check that the pricing strategy is appropriate for the commodity type
149    match commodity.kind {
150        CommodityType::Other => {
151            ensure!(
152                commodity.pricing_strategy == PricingStrategy::Unpriced,
153                "Commodity {} of type Other must be unpriced. \
154                    Update its pricing strategy to 'unpriced' or 'default'.",
155                commodity.id
156            );
157        }
158        CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand => {
159            ensure!(
160                commodity.pricing_strategy != PricingStrategy::Unpriced,
161                "Commodity {} of type {:?} cannot be unpriced. \
162                    Update its pricing strategy to a valid option.",
163                commodity.id,
164                commodity.kind
165            );
166        }
167    }
168
169    // Gatekeep scarcity-adjusted pricing option
170    if commodity.pricing_strategy == PricingStrategy::ScarcityAdjusted {
171        ensure!(
172            dangerous_model_options_enabled(),
173            "The 'scarcity' pricing strategy is currently experimental. \
174            To run anyway, set the {ALLOW_DANGEROUS_OPTION_NAME} option to true."
175        );
176        warn!(
177            "The pricing strategy for {} is set to 'scarcity'. Commodity prices may be \
178            incorrect if assets have more than one output commodity. See: {ISSUES_URL}/677",
179            commodity.id
180        );
181    }
182
183    // check that units are provided
184    ensure!(
185        !commodity.units.trim().is_empty(),
186        "Commodity {} requires units to be specified.",
187        commodity.id
188    );
189
190    Ok(())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::fixture::assert_error;
197    use crate::time_slice::TimeSliceLevel;
198
199    fn make_commodity(kind: CommodityType, pricing_strategy: PricingStrategy) -> Commodity {
200        Commodity {
201            id: "ELC".into(),
202            description: "test".into(),
203            kind,
204            time_slice_level: TimeSliceLevel::Annual,
205            pricing_strategy,
206            levies_prod: CommodityLevyMap::default(),
207            levies_cons: CommodityLevyMap::default(),
208            demand: DemandMap::default(),
209            units: "PJ".into(),
210        }
211    }
212
213    #[test]
214    fn validate_commodity_works() {
215        let commodity = make_commodity(CommodityType::SupplyEqualsDemand, PricingStrategy::Shadow);
216        validate_commodity(&commodity).unwrap();
217    }
218
219    #[test]
220    fn validate_commodity_other_priced() {
221        let commodity = make_commodity(CommodityType::Other, PricingStrategy::MarginalCost);
222        assert_error!(
223            validate_commodity(&commodity),
224            "Commodity ELC of type Other must be unpriced. Update its pricing strategy to 'unpriced' or 'default'."
225        );
226    }
227
228    #[test]
229    fn validate_commodity_sed_unpriced() {
230        let commodity =
231            make_commodity(CommodityType::SupplyEqualsDemand, PricingStrategy::Unpriced);
232        assert_error!(
233            validate_commodity(&commodity),
234            "Commodity ELC of type SupplyEqualsDemand cannot be unpriced. Update its pricing strategy to a valid option."
235        );
236    }
237
238    #[test]
239    fn validate_commodity_remove_units() {
240        let mut commodity = make_commodity(CommodityType::Other, PricingStrategy::Unpriced);
241        commodity.units = "   ".into();
242        assert_error!(
243            validate_commodity(&commodity),
244            "Commodity ELC requires units to be specified."
245        );
246    }
247}