1use super::*;
3use crate::commodity::{Commodity, CommodityID, CommodityMap, CommodityType};
4use crate::process::{
5 Process, ProcessActivityLimitsMap, ProcessFlowsMap, ProcessID, ProcessMap, ProcessParameterMap,
6};
7use crate::region::{parse_region_str, RegionID};
8use crate::time_slice::{TimeSliceInfo, TimeSliceSelection};
9use crate::units::Flow;
10use anyhow::{ensure, Context, Ok, Result};
11use indexmap::IndexSet;
12use itertools::iproduct;
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::path::Path;
16use std::rc::Rc;
17
18mod availability;
19use availability::read_process_availabilities;
20mod flow;
21use flow::read_process_flows;
22mod parameter;
23use crate::id::define_id_getter;
24use parameter::read_process_parameters;
25
26const PROCESSES_FILE_NAME: &str = "processes.csv";
27
28#[derive(PartialEq, Debug, Deserialize)]
29struct ProcessRaw {
30 id: ProcessID,
31 description: String,
32 regions: String,
33 start_year: Option<u32>,
34 end_year: Option<u32>,
35}
36define_id_getter! {ProcessRaw, ProcessID}
37
38pub fn read_processes(
52 model_dir: &Path,
53 commodities: &CommodityMap,
54 region_ids: &IndexSet<RegionID>,
55 time_slice_info: &TimeSliceInfo,
56 milestone_years: &[u32],
57) -> Result<ProcessMap> {
58 let mut processes = read_processes_file(model_dir, milestone_years, region_ids)?;
59 let mut activity_limits = read_process_availabilities(model_dir, &processes, time_slice_info)?;
60 let mut flows = read_process_flows(model_dir, &processes, commodities)?;
61 let mut parameters = read_process_parameters(model_dir, &processes)?;
62
63 validate_commodities(
65 commodities,
66 &flows,
67 &activity_limits,
68 region_ids,
69 milestone_years,
70 time_slice_info,
71 )?;
72
73 for (id, process) in processes.iter_mut() {
75 let process = Rc::get_mut(process).unwrap();
77 process.activity_limits = activity_limits
78 .remove(id)
79 .with_context(|| format!("Missing availabilities for process {id}"))?;
80 process.flows = flows
81 .remove(id)
82 .with_context(|| format!("Missing flows for process {id}"))?;
83 process.parameters = parameters
84 .remove(id)
85 .with_context(|| format!("Missing parameters for process {id}"))?;
86 }
87
88 Ok(processes)
89}
90
91fn read_processes_file(
92 model_dir: &Path,
93 milestone_years: &[u32],
94 region_ids: &IndexSet<RegionID>,
95) -> Result<ProcessMap> {
96 let file_path = model_dir.join(PROCESSES_FILE_NAME);
97 let processes_csv = read_csv(&file_path)?;
98 read_processes_file_from_iter(processes_csv, milestone_years, region_ids)
99 .with_context(|| input_err_msg(&file_path))
100}
101
102fn read_processes_file_from_iter<I>(
103 iter: I,
104 milestone_years: &[u32],
105 region_ids: &IndexSet<RegionID>,
106) -> Result<ProcessMap>
107where
108 I: Iterator<Item = ProcessRaw>,
109{
110 let mut processes = ProcessMap::new();
111 for process_raw in iter {
112 let start_year = process_raw.start_year.unwrap_or(milestone_years[0]);
113 let end_year = process_raw
114 .end_year
115 .unwrap_or(*milestone_years.last().unwrap());
116
117 ensure!(
119 start_year <= end_year,
120 "Error in parameter for process {}: start_year > end_year",
121 process_raw.id
122 );
123
124 let years = milestone_years
126 .iter()
127 .copied()
128 .filter(|year| (start_year..=end_year).contains(year))
129 .collect();
130
131 let regions = parse_region_str(&process_raw.regions, region_ids)?;
133
134 let process = Process {
135 id: process_raw.id.clone(),
136 description: process_raw.description,
137 years,
138 activity_limits: ProcessActivityLimitsMap::new(),
139 flows: ProcessFlowsMap::new(),
140 parameters: ProcessParameterMap::new(),
141 regions,
142 };
143
144 ensure!(
145 processes.insert(process_raw.id, process.into()).is_none(),
146 "Duplicate process ID"
147 );
148 }
149
150 Ok(processes)
151}
152
153fn validate_commodities(
155 commodities: &CommodityMap,
156 flows: &HashMap<ProcessID, ProcessFlowsMap>,
157 availabilities: &HashMap<ProcessID, ProcessActivityLimitsMap>,
158 region_ids: &IndexSet<RegionID>,
159 milestone_years: &[u32],
160 time_slice_info: &TimeSliceInfo,
161) -> Result<()> {
162 for commodity in commodities.values() {
163 if commodity.kind == CommodityType::Other {
164 validate_other_commodity(&commodity.id, flows)?;
165 continue;
166 }
167
168 for (region_id, year) in iproduct!(region_ids.iter(), milestone_years.iter().copied()) {
169 match commodity.kind {
170 CommodityType::SupplyEqualsDemand => {
171 validate_sed_commodity(&commodity.id, flows, region_id, year)?;
172 }
173 CommodityType::ServiceDemand => {
174 for ts_selection in
175 time_slice_info.iter_selections_at_level(commodity.time_slice_level)
176 {
177 validate_svd_commodity(
178 time_slice_info,
179 commodity,
180 flows,
181 availabilities,
182 region_id,
183 year,
184 &ts_selection,
185 )?;
186 }
187 }
188 _ => unreachable!(),
189 }
190 }
191 }
192
193 Ok(())
194}
195
196fn validate_other_commodity(
198 commodity_id: &CommodityID,
199 flows: &HashMap<ProcessID, ProcessFlowsMap>,
200) -> Result<()> {
201 let mut is_producer = None;
202 for flows in flows.values().flat_map(|flows| flows.values()) {
203 if let Some(flow) = flows.get(commodity_id) {
204 let cur_is_producer = flow.is_output();
205 if let Some(is_producer) = is_producer {
206 ensure!(
207 is_producer == cur_is_producer,
208 "{commodity_id} is both a producer and consumer. \
209 Commodities of type 'other' must only be consumed or produced."
210 );
211 } else {
212 is_producer = Some(cur_is_producer);
213 }
214 }
215 }
216
217 ensure!(
218 is_producer.is_some(),
219 "Commodity {commodity_id} is neither produced or consumed."
220 );
221
222 Ok(())
223}
224
225fn validate_sed_commodity(
227 commodity_id: &CommodityID,
228 flows: &HashMap<ProcessID, ProcessFlowsMap>,
229 region_id: &RegionID,
230 year: u32,
231) -> Result<()> {
232 let mut has_producer = false;
233 let mut has_consumer = false;
234 for flows in flows.values() {
235 let flows = flows.get(&(region_id.clone(), year)).unwrap();
236 if let Some(flow) = flows.get(&commodity_id.clone()) {
237 if flow.is_output() {
238 has_producer = true;
239 } else if flow.is_input() {
240 has_consumer = true;
241 }
242 }
243 }
244
245 ensure!(has_consumer && has_producer,
246 "Commodity {} of 'SED' type must have both producer and consumer processes for region {} in year {}",
247 commodity_id,
248 region_id,
249 year,
250 );
251
252 Ok(())
253}
254
255fn validate_svd_commodity(
256 time_slice_info: &TimeSliceInfo,
257 commodity: &Commodity,
258 flows: &HashMap<ProcessID, ProcessFlowsMap>,
259 availabilities: &HashMap<ProcessID, ProcessActivityLimitsMap>,
260 region_id: &RegionID,
261 year: u32,
262 ts_selection: &TimeSliceSelection,
263) -> Result<()> {
264 let demand = *commodity
267 .demand
268 .get(&(region_id.clone(), year, ts_selection.clone()))
269 .unwrap();
270 if demand <= Flow(0.0) {
271 return Ok(());
272 }
273
274 for (process_id, flows) in flows.iter() {
277 let flows = flows.get(&(region_id.clone(), year)).unwrap();
278 let Some(flow) = flows.get(&commodity.id) else {
279 continue;
281 };
282 ensure!(
283 flow.is_output(),
284 "SVD commodity {} is consumed by process {}. \
285 SVD commodities can only be produced, not consumed.",
286 commodity.id,
287 process_id
288 );
289
290 let availabilities = availabilities.get(process_id).unwrap();
292 for (ts, _) in ts_selection.iter(time_slice_info) {
293 let availability = availabilities
294 .get(&(region_id.clone(), year, ts.clone()))
295 .unwrap();
296 if *availability.end() > Dimensionless(0.0) {
297 return Ok(());
298 }
299 }
300 }
301
302 bail!(
304 "Commodity {} of 'SVD' type must have a producer process for region {} in year {} and time slice(s) {}",
305 commodity.id,
306 region_id,
307 year,
308 ts_selection,
309 )
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::commodity::{CommodityLevyMap, DemandMap};
316 use crate::fixture::{assert_error, time_slice, time_slice_info};
317 use crate::process::{FlowType, ProcessFlow};
318 use crate::time_slice::{TimeSliceID, TimeSliceLevel};
319 use crate::units::{Dimensionless, FlowPerActivity, MoneyPerFlow};
320 use indexmap::indexmap;
321 use rstest::{fixture, rstest};
322 use std::iter;
323
324 #[fixture]
325 fn commodity_sed() -> Commodity {
326 Commodity {
327 id: "commodity_sed".into(),
328 description: "SED commodity".into(),
329 kind: CommodityType::SupplyEqualsDemand,
330 time_slice_level: TimeSliceLevel::Annual,
331 levies: CommodityLevyMap::new(),
332 demand: DemandMap::new(),
333 }
334 }
335
336 #[fixture]
337 fn input_flows_sed(commodity_sed: Commodity) -> ProcessFlowsMap {
338 ProcessFlowsMap::from_iter([(
339 ("GBR".into(), 2010),
340 indexmap! { commodity_sed.id.clone() => ProcessFlow {
341 commodity: commodity_sed.into(),
342 coeff: FlowPerActivity(-10.0),
343 kind: FlowType::Fixed,
344 cost: MoneyPerFlow(1.0),
345 is_primary_output: false,
346 }},
347 )])
348 }
349
350 #[fixture]
351 fn output_flows_sed(commodity_sed: Commodity) -> ProcessFlowsMap {
352 ProcessFlowsMap::from_iter([(
353 ("GBR".into(), 2010),
354 indexmap! {commodity_sed.id.clone()=>ProcessFlow {
355 commodity: commodity_sed.into(),
356 coeff: FlowPerActivity(10.0),
357 kind: FlowType::Fixed,
358 cost: MoneyPerFlow(1.0),
359 is_primary_output: false,
360 }},
361 )])
362 }
363
364 #[rstest]
365 fn test_validate_sed_commodity_valid(
366 commodity_sed: Commodity,
367 input_flows_sed: ProcessFlowsMap,
368 output_flows_sed: ProcessFlowsMap,
369 ) {
370 let flows = HashMap::from_iter([
372 ("process1".into(), input_flows_sed.clone()),
373 ("process2".into(), output_flows_sed.clone()),
374 ]);
375 assert!(validate_sed_commodity(&commodity_sed.id, &flows, &"GBR".into(), 2010).is_ok());
376 }
377
378 #[rstest]
379 fn test_validate_sed_commodity_invalid_no_producer(
380 commodity_sed: Commodity,
381 input_flows_sed: ProcessFlowsMap,
382 ) {
383 let flows = HashMap::from_iter([("process1".into(), input_flows_sed.clone())]);
385 assert_error!(
386 validate_sed_commodity(&commodity_sed.id, &flows, &"GBR".into(), 2010),
387 "Commodity commodity_sed of 'SED' type must have both producer and consumer processes for region GBR in year 2010"
388 );
389 }
390
391 #[rstest]
392 fn test_validate_sed_commodity(commodity_sed: Commodity, output_flows_sed: ProcessFlowsMap) {
393 let flows = HashMap::from_iter([("process2".into(), output_flows_sed.clone())]);
395 assert_error!(
396 validate_sed_commodity(&commodity_sed.id, &flows, &"GBR".into(), 2010),
397 "Commodity commodity_sed of 'SED' type must have both producer and consumer processes for region GBR in year 2010"
398 );
399 }
400
401 #[fixture]
402 fn commodity_svd(time_slice: TimeSliceID) -> Commodity {
403 let demand = DemandMap::from_iter([(("GBR".into(), 2010, time_slice.into()), Flow(10.0))]);
404
405 Commodity {
406 id: "commodity_svd".into(),
407 description: "SVD commodity".into(),
408 kind: CommodityType::ServiceDemand,
409 time_slice_level: TimeSliceLevel::Annual,
410 levies: CommodityLevyMap::new(),
411 demand,
412 }
413 }
414
415 #[fixture]
416 fn flows_svd(commodity_svd: Commodity) -> HashMap<ProcessID, ProcessFlowsMap> {
417 HashMap::from_iter([(
418 "process1".into(),
419 ProcessFlowsMap::from_iter([(
420 ("GBR".into(), 2010),
421 indexmap! { commodity_svd.id.clone() => ProcessFlow {
422 commodity: commodity_svd.into(),
423 coeff: FlowPerActivity(10.0),
424 kind: FlowType::Fixed,
425 cost: MoneyPerFlow(1.0),
426 is_primary_output: false,
427 }},
428 )]),
429 )])
430 }
431
432 #[rstest]
433 fn test_validate_svd_commodity_valid(
434 commodity_svd: Commodity,
435 flows_svd: HashMap<ProcessID, ProcessFlowsMap>,
436 time_slice_info: TimeSliceInfo,
437 time_slice: TimeSliceID,
438 ) {
439 let availabilities = HashMap::from_iter([(
440 "process1".into(),
441 ProcessActivityLimitsMap::from_iter([(
442 ("GBR".into(), 2010, time_slice.clone()),
443 Dimensionless(0.1)..=Dimensionless(0.9),
444 )]),
445 )]);
446
447 assert!(validate_svd_commodity(
449 &time_slice_info,
450 &commodity_svd,
451 &flows_svd,
452 &availabilities,
453 &"GBR".into(),
454 2010,
455 &time_slice.into()
456 )
457 .is_ok());
458 }
459
460 #[rstest]
461 fn test_validate_svd_commodity_invalid_no_availability(
462 time_slice_info: TimeSliceInfo,
463 commodity_svd: Commodity,
464 flows_svd: HashMap<ProcessID, ProcessFlowsMap>,
465 time_slice: TimeSliceID,
466 ) {
467 let availabilities = HashMap::from_iter([(
469 "process1".into(),
470 ProcessActivityLimitsMap::from_iter([(
471 ("GBR".into(), 2010, time_slice.clone()),
472 Dimensionless(0.0)..=Dimensionless(0.0),
473 )]),
474 )]);
475 assert_error!(
476 validate_svd_commodity(
477 &time_slice_info,
478 &commodity_svd,
479 &flows_svd,
480 &availabilities,
481 &"GBR".into(),
482 2010,
483 &time_slice.into()
484 ),
485 "Commodity commodity_svd of 'SVD' type must have a producer process \
486 for region GBR in year 2010 and time slice(s) winter.day"
487 );
488 }
489
490 #[fixture]
491 fn commodity_other() -> Commodity {
492 Commodity {
493 id: "commodity_other".into(),
494 description: "Other commodity".into(),
495 kind: CommodityType::Other,
496 time_slice_level: TimeSliceLevel::Annual,
497 levies: CommodityLevyMap::new(),
498 demand: DemandMap::new(),
499 }
500 }
501
502 #[fixture]
503 fn producer_flows(commodity_other: Commodity) -> ProcessFlowsMap {
504 ProcessFlowsMap::from_iter([(
505 ("GBR".into(), 2010),
506 indexmap! { commodity_other.id.clone() => ProcessFlow {
507 commodity: commodity_other.into(),
508 coeff: FlowPerActivity(10.0),
509 kind: FlowType::Fixed,
510 cost: MoneyPerFlow(1.0),
511 is_primary_output: false,
512 }},
513 )])
514 }
515
516 #[fixture]
517 fn consumer_flows(commodity_other: Commodity) -> ProcessFlowsMap {
518 ProcessFlowsMap::from_iter([(
519 ("GBR".into(), 2010),
520 indexmap! { commodity_other.id.clone() => ProcessFlow {
521 commodity: commodity_other.into(),
522 coeff: FlowPerActivity(-10.0),
523 kind: FlowType::Fixed,
524 cost: MoneyPerFlow(1.0),
525 is_primary_output: false,
526 }},
527 )])
528 }
529
530 #[rstest]
531 fn test_validate_other_commodity_valid_producer(
532 commodity_other: Commodity,
533 producer_flows: ProcessFlowsMap,
534 ) {
535 let flows = HashMap::from_iter([("process1".into(), producer_flows)]);
537 assert!(validate_other_commodity(&commodity_other.id, &flows).is_ok());
538 }
539
540 #[rstest]
541 fn test_validate_other_commodity_valid_consumer(
542 commodity_other: Commodity,
543 consumer_flows: ProcessFlowsMap,
544 ) {
545 let flows = HashMap::from_iter([("process1".into(), consumer_flows)]);
547 assert!(validate_other_commodity(&commodity_other.id, &flows).is_ok());
548 }
549
550 #[rstest]
551 fn test_validate_other_commodity_invalid_both(
552 commodity_other: Commodity,
553 producer_flows: ProcessFlowsMap,
554 consumer_flows: ProcessFlowsMap,
555 ) {
556 let flows = HashMap::from_iter([
558 ("process1".into(), producer_flows),
559 ("process2".into(), consumer_flows),
560 ]);
561 assert_error!(
562 validate_other_commodity(&commodity_other.id, &flows),
563 "commodity_other is both a producer and consumer. \
564 Commodities of type 'other' must only be consumed or produced."
565 );
566 }
567
568 #[rstest]
569 fn test_validate_other_commodity_invalid_neither(commodity_other: Commodity) {
570 let flows = HashMap::new();
572 assert_error!(
573 validate_other_commodity(&commodity_other.id, &flows),
574 "Commodity commodity_other is neither produced or consumed."
575 );
576 }
577
578 #[rstest]
579 fn test_validate_svd_commodity_invalid_consumed(
580 commodity_svd: Commodity,
581 time_slice_info: TimeSliceInfo,
582 time_slice: TimeSliceID,
583 ) {
584 let commodity_svd = Rc::new(commodity_svd);
585 let region_id = RegionID("GBR".into());
586 let availabilities = HashMap::from_iter([(
587 "process1".into(),
588 ProcessActivityLimitsMap::from_iter([(
589 (region_id.clone(), 2010, time_slice.clone()),
590 Dimensionless(0.1)..=Dimensionless(0.9),
591 )]),
592 )]);
593 let flows = HashMap::from_iter(iter::once((
594 "process1".into(),
595 ProcessFlowsMap::from_iter([(
596 (region_id.clone(), 2010),
597 indexmap! { commodity_svd.id.clone() => ProcessFlow {
598 commodity: Rc::clone(&commodity_svd),
599 coeff: FlowPerActivity(-10.0),
600 kind: FlowType::Fixed,
601 cost: MoneyPerFlow(1.0),
602 is_primary_output: false,
603 }},
604 )]),
605 )));
606 assert_error!(
607 validate_svd_commodity(
608 &time_slice_info,
609 &commodity_svd,
610 &flows,
611 &availabilities,
612 ®ion_id,
613 2010,
614 &time_slice.into()
615 ),
616 "SVD commodity commodity_svd is consumed by process process1. \
617 SVD commodities can only be produced, not consumed."
618 );
619 }
620}