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_BROKEN_OPTION_NAME, broken_model_options_allowed};
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 => PricingStrategy::Shadow,
142    }
143}
144
145fn validate_commodity(commodity: &Commodity) -> Result<()> {
146    // Check that the pricing strategy is appropriate for the commodity type
147    match commodity.kind {
148        CommodityType::Other => {
149            ensure!(
150                commodity.pricing_strategy == PricingStrategy::Unpriced,
151                "Commodity {} of type Other must be unpriced. \
152                    Update its pricing strategy to 'unpriced' or 'default'.",
153                commodity.id
154            );
155        }
156        CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand => {
157            ensure!(
158                commodity.pricing_strategy != PricingStrategy::Unpriced,
159                "Commodity {} of type {:?} cannot be unpriced. \
160                    Update its pricing strategy to a valid option.",
161                commodity.id,
162                commodity.kind
163            );
164        }
165    }
166
167    // Gatekeep alternative pricing options
168    if !matches!(
169        commodity.pricing_strategy,
170        PricingStrategy::Shadow | PricingStrategy::Unpriced
171    ) {
172        ensure!(
173            broken_model_options_allowed(),
174            "Price strategies other than 'shadow' and 'unpriced' are currently experimental. \
175            To run anyway, set the {ALLOW_BROKEN_OPTION_NAME} option to true."
176        );
177    }
178    if commodity.pricing_strategy == PricingStrategy::ScarcityAdjusted {
179        warn!(
180            "The pricing strategy for {} is set to 'scarcity'. Commodity prices may be \
181            incorrect if assets have more than one output commodity. See: {ISSUES_URL}/677",
182            commodity.id
183        );
184    }
185
186    // check that units are provided
187    ensure!(
188        !commodity.units.trim().is_empty(),
189        "Commodity {} requires units to be specified.",
190        commodity.id
191    );
192
193    Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::fixture::assert_error;
200    use crate::time_slice::TimeSliceLevel;
201
202    fn make_commodity(kind: CommodityType, pricing_strategy: PricingStrategy) -> Commodity {
203        Commodity {
204            id: "ELC".into(),
205            description: "test".into(),
206            kind,
207            time_slice_level: TimeSliceLevel::Annual,
208            pricing_strategy,
209            levies_prod: CommodityLevyMap::default(),
210            levies_cons: CommodityLevyMap::default(),
211            demand: DemandMap::default(),
212            units: "PJ".into(),
213        }
214    }
215
216    #[test]
217    fn validate_commodity_works() {
218        let commodity = make_commodity(CommodityType::SupplyEqualsDemand, PricingStrategy::Shadow);
219        validate_commodity(&commodity).unwrap();
220    }
221
222    #[test]
223    fn validate_commodity_other_priced() {
224        let commodity = make_commodity(CommodityType::Other, PricingStrategy::MarginalCost);
225        assert_error!(
226            validate_commodity(&commodity),
227            "Commodity ELC of type Other must be unpriced. Update its pricing strategy to 'unpriced' or 'default'."
228        );
229    }
230
231    #[test]
232    fn validate_commodity_sed_unpriced() {
233        let commodity =
234            make_commodity(CommodityType::SupplyEqualsDemand, PricingStrategy::Unpriced);
235        assert_error!(
236            validate_commodity(&commodity),
237            "Commodity ELC of type SupplyEqualsDemand cannot be unpriced. Update its pricing strategy to a valid option."
238        );
239    }
240
241    #[test]
242    fn validate_commodity_remove_units() {
243        let mut commodity = make_commodity(CommodityType::Other, PricingStrategy::Unpriced);
244        commodity.units = "   ".into();
245        assert_error!(
246            validate_commodity(&commodity),
247            "Commodity ELC requires units to be specified."
248        );
249    }
250}