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_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")] 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 => PricingStrategy::Shadow,
142 }
143}
144
145fn validate_commodity(commodity: &Commodity) -> Result<()> {
146 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 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 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}