muse2/input/
process.rs

1//! Code for reading process-related information from CSV files.
2use 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
38/// Read process information from the specified CSV files.
39///
40/// # Arguments
41///
42/// * `model_dir` - Folder containing model configuration files
43/// * `commodities` - Commodities for the model
44/// * `region_ids` - All possible region IDs
45/// * `time_slice_info` - Information about seasons and times of day
46/// * `year_range` - The possible range of milestone years
47///
48/// # Returns
49///
50/// This function returns a map of processes, with the IDs as keys.
51pub 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 after the flows have been read
64    validate_commodities(
65        commodities,
66        &flows,
67        &activity_limits,
68        region_ids,
69        milestone_years,
70        time_slice_info,
71    )?;
72
73    // Add data to Process objects
74    for (id, process) in processes.iter_mut() {
75        // This will always succeed as we know there will only be one reference to the process here
76        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        // Check year range is valid
118        ensure!(
119            start_year <= end_year,
120            "Error in parameter for process {}: start_year > end_year",
121            process_raw.id
122        );
123
124        // Select process years
125        let years = milestone_years
126            .iter()
127            .copied()
128            .filter(|year| (start_year..=end_year).contains(year))
129            .collect();
130
131        // Parse region ID
132        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
153/// Perform consistency checks for commodity flows.
154fn 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
196/// Check that commodities of type other are either produced or consumed but not both
197fn 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
225/// Check that an SED commodity has a consumer and producer process
226fn 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    // Check if the commodity has a demand in the given time slice, region and year.
265    // We only need to check for producers if there is positive demand.
266    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    // We must check for producers in the given year, region and time slices.
275    // This includes checking if flow > 0 and if availability > 0.
276    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            // We're only interested in processes which produce this commodity
280            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        // If the process has availability >0 in any time slice for this selection, we accept it
291        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    // If we reach this point it means there is no producer, so we return an error.
303    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        // Valid scenario
371        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        // Invalid scenario: no producer
384        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        // Invalid scenario: no consumer
394        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        // Valid scenario
448        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        // Invalid scenario: no availability
468        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        // Valid scenario: commodity is only produced
536        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        // Valid scenario: commodity is only consumed
546        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        // Invalid scenario: commodity is both produced and consumed
557        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        // Invalid scenario: commodity is neither produced nor consumed
571        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                &region_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}