muse2/
process.rs

1//! Processes are used for converting between different commodities. The data structures in this
2//! module are used to represent these conversions along with the associated costs.
3use crate::commodity::{BalanceType, Commodity, CommodityID};
4use crate::id::define_id_type;
5use crate::region::RegionID;
6use crate::time_slice::TimeSliceID;
7use crate::units::{
8    ActivityPerCapacity, Dimensionless, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity,
9    MoneyPerCapacityPerYear, MoneyPerFlow,
10};
11use indexmap::{IndexMap, IndexSet};
12use serde_string_enum::DeserializeLabeledStringEnum;
13use std::collections::HashMap;
14use std::ops::RangeInclusive;
15use std::rc::Rc;
16
17define_id_type! {ProcessID}
18
19/// A map of [`Process`]es, keyed by process ID
20pub type ProcessMap = IndexMap<ProcessID, Rc<Process>>;
21
22/// A map indicating activity limits for a [`Process`] throughout the year.
23///
24/// The value is calculated as availability multiplied by time slice length. The limits are given as
25/// ranges, depending on the user-specified limit type and value for availability.
26pub type ProcessActivityLimitsMap =
27    HashMap<(RegionID, u32, TimeSliceID), RangeInclusive<Dimensionless>>;
28
29/// A map of [`ProcessParameter`]s, keyed by region and year
30pub type ProcessParameterMap = HashMap<(RegionID, u32), Rc<ProcessParameter>>;
31
32/// A map of process flows, keyed by region and year.
33///
34/// The value is actually a map itself, keyed by commodity ID.
35pub type ProcessFlowsMap = HashMap<(RegionID, u32), IndexMap<CommodityID, ProcessFlow>>;
36
37/// Represents a process within the simulation
38#[derive(PartialEq, Debug)]
39pub struct Process {
40    /// A unique identifier for the process (e.g. GASDRV)
41    pub id: ProcessID,
42    /// A human-readable description for the process (e.g. dry gas extraction)
43    pub description: String,
44    /// The years in which this process is available for investment
45    pub years: Vec<u32>,
46    /// Limits on activity for each time slice (as a fraction of maximum)
47    pub activity_limits: ProcessActivityLimitsMap,
48    /// Maximum annual commodity flows for this process
49    pub flows: ProcessFlowsMap,
50    /// Additional parameters for this process
51    pub parameters: ProcessParameterMap,
52    /// The regions in which this process can operate
53    pub regions: IndexSet<RegionID>,
54}
55
56/// Represents a maximum annual commodity coeff for a given process
57#[derive(PartialEq, Debug, Clone)]
58pub struct ProcessFlow {
59    /// The commodity produced or consumed by this flow
60    pub commodity: Rc<Commodity>,
61    /// Maximum annual commodity flow quantity relative to other commodity flows.
62    ///
63    /// Positive value indicates flow out and negative value indicates flow in.
64    pub coeff: FlowPerActivity,
65    /// Identifies if a flow is fixed or flexible.
66    pub kind: FlowType,
67    /// Cost per unit flow.
68    ///
69    /// For example, cost per unit of natural gas produced. The user can apply it to any specified
70    /// flow.
71    pub cost: MoneyPerFlow,
72    /// Whether this flow is the primary output for the process
73    pub is_primary_output: bool,
74}
75
76impl ProcessFlow {
77    /// Get the cost for this flow with the given parameters.
78    ///
79    /// This includes cost per unit flow and levies/incentives, if any.
80    pub fn get_total_cost(
81        &self,
82        region_id: &RegionID,
83        year: u32,
84        time_slice: &TimeSliceID,
85    ) -> MoneyPerActivity {
86        let cost_per_unit = self.cost + self.get_levy(region_id, year, time_slice);
87
88        self.coeff.abs() * cost_per_unit
89    }
90
91    /// Get the levy/incentive for this process flow with the given parameters, if any
92    fn get_levy(&self, region_id: &RegionID, year: u32, time_slice: &TimeSliceID) -> MoneyPerFlow {
93        if self.commodity.levies.is_empty() {
94            return MoneyPerFlow(0.0);
95        }
96
97        let levy = self
98            .commodity
99            .levies
100            .get(&(region_id.clone(), year, time_slice.clone()))
101            .unwrap();
102        let apply_levy = match levy.balance_type {
103            BalanceType::Net => true,
104            BalanceType::Consumption => self.is_input(),
105            BalanceType::Production => self.is_output(),
106        };
107
108        if apply_levy {
109            levy.value
110        } else {
111            MoneyPerFlow(0.0)
112        }
113    }
114
115    /// Returns true if this flow is an input (i.e., coeff < 0)
116    pub fn is_input(&self) -> bool {
117        self.coeff < FlowPerActivity(0.0)
118    }
119
120    /// Returns true if this flow is an output (i.e., coeff > 0)
121    pub fn is_output(&self) -> bool {
122        self.coeff > FlowPerActivity(0.0)
123    }
124}
125
126/// Type of commodity flow (see [`ProcessFlow`])
127#[derive(PartialEq, Default, Debug, Clone, DeserializeLabeledStringEnum)]
128pub enum FlowType {
129    /// The input to output flow ratio is fixed
130    #[default]
131    #[string = "fixed"]
132    Fixed,
133    /// The flow ratio can vary, subject to overall flow of a specified group of commodities whose
134    /// input/output ratio must be as per user input data
135    #[string = "flexible"]
136    Flexible,
137}
138
139/// Additional parameters for a process
140#[derive(PartialEq, Clone, Debug)]
141pub struct ProcessParameter {
142    /// Overnight capital cost per unit capacity
143    pub capital_cost: MoneyPerCapacity,
144    /// Annual operating cost per unit capacity
145    pub fixed_operating_cost: MoneyPerCapacityPerYear,
146    /// Annual variable operating cost per unit activity
147    pub variable_operating_cost: MoneyPerActivity,
148    /// Lifetime in years of an asset created from this process
149    pub lifetime: u32,
150    /// Process-specific discount rate
151    pub discount_rate: Dimensionless,
152    /// Factor for calculating the maximum consumption/production over a year.
153    ///
154    /// Used for converting one unit of capacity to maximum energy of asset per year. For example,
155    /// if capacity is measured in GW and energy is measured in PJ, the capacity_to_activity for the
156    /// process is 31.536 because 1 GW of capacity can produce 31.536 PJ energy output in a year.
157    pub capacity_to_activity: ActivityPerCapacity,
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::commodity::{
164        BalanceType, CommodityLevy, CommodityLevyMap, CommodityType, DemandMap,
165    };
166    use crate::fixture::{region_id, time_slice};
167    use crate::time_slice::TimeSliceLevel;
168    use rstest::{fixture, rstest};
169    use std::rc::Rc;
170
171    #[fixture]
172    fn commodity_with_levy(region_id: RegionID, time_slice: TimeSliceID) -> Rc<Commodity> {
173        let mut levies = CommodityLevyMap::new();
174        // Add levy for the default region and time slice
175        levies.insert(
176            (region_id.clone(), 2020, time_slice.clone()),
177            CommodityLevy {
178                balance_type: BalanceType::Net,
179                value: MoneyPerFlow(10.0),
180            },
181        );
182        // Add levy for a different region
183        levies.insert(
184            ("USA".into(), 2020, time_slice.clone()),
185            CommodityLevy {
186                balance_type: BalanceType::Net,
187                value: MoneyPerFlow(5.0),
188            },
189        );
190        // Add levy for a different year
191        levies.insert(
192            (region_id.clone(), 2030, time_slice.clone()),
193            CommodityLevy {
194                balance_type: BalanceType::Net,
195                value: MoneyPerFlow(7.0),
196            },
197        );
198        // Add levy for a different time slice
199        levies.insert(
200            (
201                region_id.clone(),
202                2020,
203                TimeSliceID {
204                    season: "summer".into(),
205                    time_of_day: "day".into(),
206                },
207            ),
208            CommodityLevy {
209                balance_type: BalanceType::Net,
210                value: MoneyPerFlow(3.0),
211            },
212        );
213
214        Rc::new(Commodity {
215            id: "test_commodity".into(),
216            description: "Test commodity".into(),
217            kind: CommodityType::ServiceDemand,
218            time_slice_level: TimeSliceLevel::Annual,
219            levies,
220            demand: DemandMap::new(),
221        })
222    }
223
224    #[fixture]
225    fn commodity_with_consumption_levy(
226        region_id: RegionID,
227        time_slice: TimeSliceID,
228    ) -> Rc<Commodity> {
229        let mut levies = CommodityLevyMap::new();
230        levies.insert(
231            (region_id, 2020, time_slice),
232            CommodityLevy {
233                balance_type: BalanceType::Consumption,
234                value: MoneyPerFlow(10.0),
235            },
236        );
237
238        Rc::new(Commodity {
239            id: "test_commodity".into(),
240            description: "Test commodity".into(),
241            kind: CommodityType::ServiceDemand,
242            time_slice_level: TimeSliceLevel::Annual,
243            levies,
244            demand: DemandMap::new(),
245        })
246    }
247
248    #[fixture]
249    fn commodity_with_production_levy(
250        region_id: RegionID,
251        time_slice: TimeSliceID,
252    ) -> Rc<Commodity> {
253        let mut levies = CommodityLevyMap::new();
254        levies.insert(
255            (region_id, 2020, time_slice),
256            CommodityLevy {
257                balance_type: BalanceType::Production,
258                value: MoneyPerFlow(10.0),
259            },
260        );
261
262        Rc::new(Commodity {
263            id: "test_commodity".into(),
264            description: "Test commodity".into(),
265            kind: CommodityType::ServiceDemand,
266            time_slice_level: TimeSliceLevel::Annual,
267            levies,
268            demand: DemandMap::new(),
269        })
270    }
271
272    #[fixture]
273    fn commodity_with_incentive(region_id: RegionID, time_slice: TimeSliceID) -> Rc<Commodity> {
274        let mut levies = CommodityLevyMap::new();
275        levies.insert(
276            (region_id, 2020, time_slice),
277            CommodityLevy {
278                balance_type: BalanceType::Net,
279                value: MoneyPerFlow(-5.0),
280            },
281        );
282
283        Rc::new(Commodity {
284            id: "test_commodity".into(),
285            description: "Test commodity".into(),
286            kind: CommodityType::ServiceDemand,
287            time_slice_level: TimeSliceLevel::Annual,
288            levies,
289            demand: DemandMap::new(),
290        })
291    }
292
293    #[fixture]
294    fn commodity_no_levies() -> Rc<Commodity> {
295        Rc::new(Commodity {
296            id: "test_commodity".into(),
297            description: "Test commodity".into(),
298            kind: CommodityType::ServiceDemand,
299            time_slice_level: TimeSliceLevel::Annual,
300            levies: CommodityLevyMap::new(),
301            demand: DemandMap::new(),
302        })
303    }
304
305    #[fixture]
306    fn flow_with_cost() -> ProcessFlow {
307        ProcessFlow {
308            commodity: Rc::new(Commodity {
309                id: "test_commodity".into(),
310                description: "Test commodity".into(),
311                kind: CommodityType::ServiceDemand,
312                time_slice_level: TimeSliceLevel::Annual,
313                levies: CommodityLevyMap::new(),
314                demand: DemandMap::new(),
315            }),
316            coeff: FlowPerActivity(1.0),
317            kind: FlowType::Fixed,
318            cost: MoneyPerFlow(5.0),
319            is_primary_output: false,
320        }
321    }
322
323    #[fixture]
324    fn flow_with_cost_and_levy(region_id: RegionID, time_slice: TimeSliceID) -> ProcessFlow {
325        let mut levies = CommodityLevyMap::new();
326        levies.insert(
327            (region_id, 2020, time_slice),
328            CommodityLevy {
329                balance_type: BalanceType::Net,
330                value: MoneyPerFlow(10.0),
331            },
332        );
333
334        ProcessFlow {
335            commodity: Rc::new(Commodity {
336                id: "test_commodity".into(),
337                description: "Test commodity".into(),
338                kind: CommodityType::ServiceDemand,
339                time_slice_level: TimeSliceLevel::Annual,
340                levies,
341                demand: DemandMap::new(),
342            }),
343            coeff: FlowPerActivity(1.0),
344            kind: FlowType::Fixed,
345            cost: MoneyPerFlow(5.0),
346            is_primary_output: false,
347        }
348    }
349
350    #[fixture]
351    fn flow_with_cost_and_incentive(region_id: RegionID, time_slice: TimeSliceID) -> ProcessFlow {
352        let mut levies = CommodityLevyMap::new();
353        levies.insert(
354            (region_id, 2020, time_slice),
355            CommodityLevy {
356                balance_type: BalanceType::Net,
357                value: MoneyPerFlow(-3.0),
358            },
359        );
360
361        ProcessFlow {
362            commodity: Rc::new(Commodity {
363                id: "test_commodity".into(),
364                description: "Test commodity".into(),
365                kind: CommodityType::ServiceDemand,
366                time_slice_level: TimeSliceLevel::Annual,
367                levies,
368                demand: DemandMap::new(),
369            }),
370            coeff: FlowPerActivity(1.0),
371            kind: FlowType::Fixed,
372            cost: MoneyPerFlow(5.0),
373            is_primary_output: false,
374        }
375    }
376
377    #[rstest]
378    fn test_get_levy_no_levies(
379        commodity_no_levies: Rc<Commodity>,
380        region_id: RegionID,
381        time_slice: TimeSliceID,
382    ) {
383        let flow = ProcessFlow {
384            commodity: commodity_no_levies,
385            coeff: FlowPerActivity(1.0),
386            kind: FlowType::Fixed,
387            cost: MoneyPerFlow(0.0),
388            is_primary_output: false,
389        };
390
391        assert_eq!(
392            flow.get_levy(&region_id, 2020, &time_slice),
393            MoneyPerFlow(0.0)
394        );
395    }
396
397    #[rstest]
398    fn test_get_levy_with_levy(
399        commodity_with_levy: Rc<Commodity>,
400        region_id: RegionID,
401        time_slice: TimeSliceID,
402    ) {
403        let flow = ProcessFlow {
404            commodity: commodity_with_levy,
405            coeff: FlowPerActivity(1.0),
406            kind: FlowType::Fixed,
407            cost: MoneyPerFlow(0.0),
408            is_primary_output: false,
409        };
410
411        assert_eq!(
412            flow.get_levy(&region_id, 2020, &time_slice),
413            MoneyPerFlow(10.0)
414        );
415    }
416
417    #[rstest]
418    fn test_get_levy_with_incentive(
419        commodity_with_incentive: Rc<Commodity>,
420        region_id: RegionID,
421        time_slice: TimeSliceID,
422    ) {
423        let flow = ProcessFlow {
424            commodity: commodity_with_incentive,
425            coeff: FlowPerActivity(1.0),
426            kind: FlowType::Fixed,
427            cost: MoneyPerFlow(0.0),
428            is_primary_output: false,
429        };
430
431        assert_eq!(
432            flow.get_levy(&region_id, 2020, &time_slice),
433            MoneyPerFlow(-5.0)
434        );
435    }
436
437    #[rstest]
438    fn test_get_levy_different_region(commodity_with_levy: Rc<Commodity>, time_slice: TimeSliceID) {
439        let flow = ProcessFlow {
440            commodity: commodity_with_levy,
441            coeff: FlowPerActivity(1.0),
442            kind: FlowType::Fixed,
443            cost: MoneyPerFlow(0.0),
444            is_primary_output: false,
445        };
446
447        assert_eq!(
448            flow.get_levy(&"USA".into(), 2020, &time_slice),
449            MoneyPerFlow(5.0)
450        );
451    }
452
453    #[rstest]
454    fn test_get_levy_different_year(
455        commodity_with_levy: Rc<Commodity>,
456        region_id: RegionID,
457        time_slice: TimeSliceID,
458    ) {
459        let flow = ProcessFlow {
460            commodity: commodity_with_levy,
461            coeff: FlowPerActivity(1.0),
462            kind: FlowType::Fixed,
463            cost: MoneyPerFlow(0.0),
464            is_primary_output: false,
465        };
466
467        assert_eq!(
468            flow.get_levy(&region_id, 2030, &time_slice),
469            MoneyPerFlow(7.0)
470        );
471    }
472
473    #[rstest]
474    fn test_get_levy_different_time_slice(commodity_with_levy: Rc<Commodity>, region_id: RegionID) {
475        let flow = ProcessFlow {
476            commodity: commodity_with_levy,
477            coeff: FlowPerActivity(1.0),
478            kind: FlowType::Fixed,
479            cost: MoneyPerFlow(0.0),
480            is_primary_output: false,
481        };
482
483        let different_time_slice = TimeSliceID {
484            season: "summer".into(),
485            time_of_day: "day".into(),
486        };
487
488        assert_eq!(
489            flow.get_levy(&region_id, 2020, &different_time_slice),
490            MoneyPerFlow(3.0)
491        );
492    }
493
494    #[rstest]
495    fn test_get_levy_consumption_positive_coeff(
496        commodity_with_consumption_levy: Rc<Commodity>,
497        region_id: RegionID,
498        time_slice: TimeSliceID,
499    ) {
500        let flow = ProcessFlow {
501            commodity: commodity_with_consumption_levy,
502            coeff: FlowPerActivity(1.0), // Positive coefficient means production
503            kind: FlowType::Fixed,
504            cost: MoneyPerFlow(0.0),
505            is_primary_output: false,
506        };
507
508        assert_eq!(
509            flow.get_levy(&region_id, 2020, &time_slice),
510            MoneyPerFlow(0.0)
511        );
512    }
513
514    #[rstest]
515    fn test_get_levy_consumption_negative_coeff(
516        commodity_with_consumption_levy: Rc<Commodity>,
517        region_id: RegionID,
518        time_slice: TimeSliceID,
519    ) {
520        let flow = ProcessFlow {
521            commodity: commodity_with_consumption_levy,
522            coeff: FlowPerActivity(-1.0), // Negative coefficient means consumption
523            kind: FlowType::Fixed,
524            cost: MoneyPerFlow(0.0),
525            is_primary_output: false,
526        };
527
528        assert_eq!(
529            flow.get_levy(&region_id, 2020, &time_slice),
530            MoneyPerFlow(10.0)
531        );
532    }
533
534    #[rstest]
535    fn test_get_levy_production_positive_coeff(
536        commodity_with_production_levy: Rc<Commodity>,
537        region_id: RegionID,
538        time_slice: TimeSliceID,
539    ) {
540        let flow = ProcessFlow {
541            commodity: commodity_with_production_levy,
542            coeff: FlowPerActivity(1.0), // Positive coefficient means production
543            kind: FlowType::Fixed,
544            cost: MoneyPerFlow(0.0),
545            is_primary_output: false,
546        };
547
548        assert_eq!(
549            flow.get_levy(&region_id, 2020, &time_slice),
550            MoneyPerFlow(10.0)
551        );
552    }
553
554    #[rstest]
555    fn test_get_levy_production_negative_coeff(
556        commodity_with_production_levy: Rc<Commodity>,
557        region_id: RegionID,
558        time_slice: TimeSliceID,
559    ) {
560        let flow = ProcessFlow {
561            commodity: commodity_with_production_levy,
562            coeff: FlowPerActivity(-1.0), // Negative coefficient means consumption
563            kind: FlowType::Fixed,
564            cost: MoneyPerFlow(0.0),
565            is_primary_output: false,
566        };
567
568        assert_eq!(
569            flow.get_levy(&region_id, 2020, &time_slice),
570            MoneyPerFlow(0.0)
571        );
572    }
573
574    #[rstest]
575    fn test_get_total_cost_base_cost(
576        flow_with_cost: ProcessFlow,
577        region_id: RegionID,
578        time_slice: TimeSliceID,
579    ) {
580        assert_eq!(
581            flow_with_cost.get_total_cost(&region_id, 2020, &time_slice),
582            MoneyPerActivity(5.0)
583        );
584    }
585
586    #[rstest]
587    fn test_get_total_cost_with_levy(
588        flow_with_cost_and_levy: ProcessFlow,
589        region_id: RegionID,
590        time_slice: TimeSliceID,
591    ) {
592        assert_eq!(
593            flow_with_cost_and_levy.get_total_cost(&region_id, 2020, &time_slice),
594            MoneyPerActivity(15.0)
595        );
596    }
597
598    #[rstest]
599    fn test_get_total_cost_with_incentive(
600        flow_with_cost_and_incentive: ProcessFlow,
601        region_id: RegionID,
602        time_slice: TimeSliceID,
603    ) {
604        assert_eq!(
605            flow_with_cost_and_incentive.get_total_cost(&region_id, 2020, &time_slice),
606            MoneyPerActivity(2.0)
607        );
608    }
609
610    #[rstest]
611    fn test_get_total_cost_negative_coeff(
612        mut flow_with_cost: ProcessFlow,
613        region_id: RegionID,
614        time_slice: TimeSliceID,
615    ) {
616        flow_with_cost.coeff = FlowPerActivity(-2.0);
617        assert_eq!(
618            flow_with_cost.get_total_cost(&region_id, 2020, &time_slice),
619            MoneyPerActivity(10.0)
620        );
621    }
622
623    #[rstest]
624    fn test_get_total_cost_zero_coeff(
625        mut flow_with_cost: ProcessFlow,
626        region_id: RegionID,
627        time_slice: TimeSliceID,
628    ) {
629        flow_with_cost.coeff = FlowPerActivity(0.0);
630        assert_eq!(
631            flow_with_cost.get_total_cost(&region_id, 2020, &time_slice),
632            MoneyPerActivity(0.0)
633        );
634    }
635
636    #[test]
637    fn test_is_input_and_is_output() {
638        let commodity = Rc::new(Commodity {
639            id: "test_commodity".into(),
640            description: "Test commodity".into(),
641            kind: CommodityType::ServiceDemand,
642            time_slice_level: TimeSliceLevel::Annual,
643            levies: CommodityLevyMap::new(),
644            demand: DemandMap::new(),
645        });
646
647        let flow_in = ProcessFlow {
648            commodity: Rc::clone(&commodity),
649            coeff: FlowPerActivity(-1.0),
650            kind: FlowType::Fixed,
651            cost: MoneyPerFlow(0.0),
652            is_primary_output: false,
653        };
654        let flow_out = ProcessFlow {
655            commodity: Rc::clone(&commodity),
656            coeff: FlowPerActivity(1.0),
657            kind: FlowType::Fixed,
658            cost: MoneyPerFlow(0.0),
659            is_primary_output: false,
660        };
661        let flow_zero = ProcessFlow {
662            commodity: Rc::clone(&commodity),
663            coeff: FlowPerActivity(0.0),
664            kind: FlowType::Fixed,
665            cost: MoneyPerFlow(0.0),
666            is_primary_output: false,
667        };
668
669        assert!(flow_in.is_input());
670        assert!(!flow_in.is_output());
671        assert!(flow_out.is_output());
672        assert!(!flow_out.is_input());
673        assert!(!flow_zero.is_input());
674        assert!(!flow_zero.is_output());
675    }
676}