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