1use 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")] pub kind: CommodityType,
31 pub time_slice_level: TimeSliceLevel,
32 pub pricing_strategy: Option<PricingStrategy>,
33 pub units: String,
34}
35
36pub 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 let commodities = read_commodities_file(model_dir)?;
56 let commodity_ids = commodities.keys().cloned().collect();
57
58 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 let mut demand = read_demand(
69 model_dir,
70 &commodities,
71 region_ids,
72 time_slice_info,
73 milestone_years,
74 )?;
75
76 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
137fn 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 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 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 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}