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}
34
35/// Read commodity data from the specified model directory.
36///
37/// # Arguments
38///
39/// * `model_dir` - Folder containing model configuration files
40/// * `region_ids` - All possible region IDs
41/// * `time_slice_info` - Information about time slices
42/// * `milestone_years` - All milestone years
43///
44/// # Returns
45///
46/// An `IndexMap` mapping `CommodityID` to `Commodity`, or an error.
47pub fn read_commodities(
48    model_dir: &Path,
49    region_ids: &IndexSet<RegionID>,
50    time_slice_info: &TimeSliceInfo,
51    milestone_years: &[u32],
52) -> Result<CommodityMap> {
53    // Read commodities table
54    let commodities = read_commodities_file(model_dir)?;
55    let commodity_ids = commodities.keys().cloned().collect();
56
57    // Read costs table
58    let mut costs = read_commodity_levies(
59        model_dir,
60        &commodity_ids,
61        region_ids,
62        time_slice_info,
63        milestone_years,
64    )?;
65
66    // Read demand table
67    let mut demand = read_demand(
68        model_dir,
69        &commodities,
70        region_ids,
71        time_slice_info,
72        milestone_years,
73    )?;
74
75    // Populate maps for each Commodity
76    Ok(commodities
77        .into_iter()
78        .map(|(id, mut commodity)| {
79            if let Some(mut costs) = costs.remove(&id) {
80                if let Some(levies) = costs.remove(&BalanceType::Consumption) {
81                    commodity.levies_cons = levies;
82                }
83                if let Some(levies) = costs.remove(&BalanceType::Production) {
84                    commodity.levies_prod = levies;
85                }
86            }
87            if let Some(demand) = demand.remove(&id) {
88                commodity.demand = demand;
89            }
90
91            (id, commodity.into())
92        })
93        .collect())
94}
95
96fn read_commodities_file(model_dir: &Path) -> Result<IndexMap<CommodityID, Commodity>> {
97    let file_path = model_dir.join(COMMODITY_FILE_NAME);
98    let commodities_csv = read_csv(&file_path)?;
99    read_commodities_file_from_iter(commodities_csv).with_context(|| input_err_msg(&file_path))
100}
101
102fn read_commodities_file_from_iter<I>(iter: I) -> Result<IndexMap<CommodityID, Commodity>>
103where
104    I: Iterator<Item = CommodityRaw>,
105{
106    let mut commodities = IndexMap::new();
107    for commodity_raw in iter {
108        let pricing_strategy = match commodity_raw.pricing_strategy {
109            Some(strategy) => strategy,
110            None => default_pricing_strategy(&commodity_raw.kind),
111        };
112
113        let commodity = Commodity {
114            id: commodity_raw.id.clone(),
115            description: commodity_raw.description,
116            kind: commodity_raw.kind,
117            time_slice_level: commodity_raw.time_slice_level,
118            pricing_strategy,
119            levies_prod: CommodityLevyMap::default(),
120            levies_cons: CommodityLevyMap::default(),
121            demand: DemandMap::default(),
122        };
123
124        validate_commodity(&commodity)?;
125
126        ensure!(
127            commodities.insert(commodity_raw.id, commodity).is_none(),
128            "Duplicate commodity ID"
129        );
130    }
131
132    Ok(commodities)
133}
134
135/// Get the default pricing strategy for a given commodity kind.
136fn default_pricing_strategy(commodity_kind: &CommodityType) -> PricingStrategy {
137    match commodity_kind {
138        CommodityType::Other => PricingStrategy::Unpriced,
139        CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand => PricingStrategy::Shadow,
140    }
141}
142
143fn validate_commodity(commodity: &Commodity) -> Result<()> {
144    // Check that the pricing strategy is appropriate for the commodity type
145    match commodity.kind {
146        CommodityType::Other => {
147            ensure!(
148                commodity.pricing_strategy == PricingStrategy::Unpriced,
149                "Commodity {} of type Other must be unpriced. \
150                    Update its pricing strategy to 'unpriced' or 'default'.",
151                commodity.id
152            );
153        }
154        CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand => {
155            ensure!(
156                commodity.pricing_strategy != PricingStrategy::Unpriced,
157                "Commodity {} of type {:?} cannot be unpriced. \
158                    Update its pricing strategy to a valid option.",
159                commodity.id,
160                commodity.kind
161            );
162        }
163    }
164
165    // Gatekeep alternative pricing options
166    if !matches!(
167        commodity.pricing_strategy,
168        PricingStrategy::Shadow | PricingStrategy::Unpriced
169    ) {
170        ensure!(
171            broken_model_options_allowed(),
172            "Price strategies other than 'shadow' and 'unpriced' are currently experimental. \
173            To run anyway, set the {ALLOW_BROKEN_OPTION_NAME} option to true."
174        );
175    }
176    if commodity.pricing_strategy == PricingStrategy::ScarcityAdjusted {
177        warn!(
178            "The pricing strategy for {} is set to 'scarcity'. Commodity prices may be \
179            incorrect if assets have more than one output commodity. See: {ISSUES_URL}/677",
180            commodity.id
181        );
182    }
183
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::fixture::assert_error;
191    use crate::time_slice::TimeSliceLevel;
192
193    fn make_commodity(kind: CommodityType, pricing_strategy: PricingStrategy) -> Commodity {
194        Commodity {
195            id: "ELC".into(),
196            description: "test".into(),
197            kind,
198            time_slice_level: TimeSliceLevel::Annual,
199            pricing_strategy,
200            levies_prod: CommodityLevyMap::default(),
201            levies_cons: CommodityLevyMap::default(),
202            demand: DemandMap::default(),
203        }
204    }
205
206    #[test]
207    fn validate_commodity_works() {
208        let commodity = make_commodity(CommodityType::SupplyEqualsDemand, PricingStrategy::Shadow);
209        validate_commodity(&commodity).unwrap();
210    }
211
212    #[test]
213    fn validate_commodity_other_priced() {
214        let commodity = make_commodity(CommodityType::Other, PricingStrategy::MarginalCost);
215        assert_error!(
216            validate_commodity(&commodity),
217            "Commodity ELC of type Other must be unpriced. Update its pricing strategy to 'unpriced' or 'default'."
218        );
219    }
220
221    #[test]
222    fn validate_commodity_sed_unpriced() {
223        let commodity =
224            make_commodity(CommodityType::SupplyEqualsDemand, PricingStrategy::Unpriced);
225        assert_error!(
226            validate_commodity(&commodity),
227            "Commodity ELC of type SupplyEqualsDemand cannot be unpriced. Update its pricing strategy to a valid option."
228        );
229    }
230}