1use 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")] pub kind: CommodityType,
33 pub time_slice_level: TimeSliceLevel,
34 pub pricing_strategy: Option<PricingStrategy>,
35 pub units: String,
36}
37
38pub 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 let commodities = read_commodities_file(model_dir)?;
58 let commodity_ids = commodities.keys().cloned().collect();
59
60 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 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 let mut demand = read_demand(
80 model_dir,
81 &commodities,
82 region_ids,
83 time_slice_info,
84 milestone_years,
85 )?;
86
87 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
152fn 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 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 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 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}