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