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::{Commodity, CommodityID};
4use crate::id::define_id_type;
5use crate::region::RegionID;
6use crate::time_slice::{Season, TimeSliceID, TimeSliceInfo, TimeSliceLevel, TimeSliceSelection};
7use crate::units::{
8    ActivityPerCapacity, Dimensionless, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity,
9    MoneyPerCapacityPerYear, MoneyPerFlow,
10};
11use anyhow::{Result, ensure};
12use indexmap::{IndexMap, IndexSet};
13use itertools::Itertools;
14use serde_string_enum::DeserializeLabeledStringEnum;
15use std::collections::HashMap;
16use std::ops::RangeInclusive;
17use std::rc::Rc;
18
19define_id_type! {ProcessID}
20
21/// A map of [`Process`]es, keyed by process ID
22pub type ProcessMap = IndexMap<ProcessID, Rc<Process>>;
23
24/// A map indicating activity limits for a [`Process`] throughout the year.
25pub type ProcessActivityLimitsMap = HashMap<(RegionID, u32), Rc<ActivityLimits>>;
26
27/// A map of [`ProcessParameter`]s, keyed by region and year
28pub type ProcessParameterMap = HashMap<(RegionID, u32), Rc<ProcessParameter>>;
29
30/// A map of process flows, keyed by region and year.
31///
32/// The value is actually a map itself, keyed by commodity ID.
33pub type ProcessFlowsMap = HashMap<(RegionID, u32), Rc<IndexMap<CommodityID, ProcessFlow>>>;
34
35/// Map of process investment constraints, keyed by region and year
36pub type ProcessInvestmentConstraintsMap =
37    HashMap<(RegionID, u32), Rc<ProcessInvestmentConstraint>>;
38
39/// Represents a process within the simulation
40#[derive(PartialEq, Debug)]
41pub struct Process {
42    /// A unique identifier for the process (e.g. GASDRV)
43    pub id: ProcessID,
44    /// A human-readable description for the process (e.g. dry gas extraction)
45    pub description: String,
46    /// The years in which this process is available for investment.
47    pub years: RangeInclusive<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    /// Factor for calculating the maximum consumption/production over a year.
59    ///
60    /// Used for converting one unit of capacity to maximum energy of asset per year. For example,
61    /// if capacity is measured in GW and energy is measured in PJ, the `capacity_to_activity` for the
62    /// process is 31.536 because 1 GW of capacity can produce 31.536 PJ energy output in a year.
63    pub capacity_to_activity: ActivityPerCapacity,
64    /// Investment constraints for this process
65    pub investment_constraints: ProcessInvestmentConstraintsMap,
66}
67
68impl Process {
69    /// Whether the process can be commissioned in a given year
70    pub fn active_for_year(&self, year: u32) -> bool {
71        self.years.contains(&year)
72    }
73}
74
75/// Defines the activity limits for a process in a given region and year
76///
77/// Activity limits represent the minimum and maximum fraction of the potential annual activity that
78/// can be undertaken in each time slice, season, or the year as a whole. The limits stored and
79/// returned by this struct are dimensionless; to convert to actual activity limits for an asset,
80/// multiply by the capacity to activity factor of the process and the installed capacity of the
81/// asset. In other words, the limits stored and returned by this struct are the absolute limits on
82/// activity per `1/capacity_to_activity` units of capacity.
83///
84/// All time slices must have an entry in `self.time_slice_limits`. If no specific availability limit
85/// is provided for a time slice, this will just represent the length of the time slice as a
86/// fraction of the year. Seasonal and annual limits may be stored if provided by the user, and only
87/// if they provide an extra level of constraint on top of the time slice limits.
88///
89/// Limits can be retrieved in three ways:
90/// - Retrieve the limit for a specific time slice using `get_limit_for_time_slice()`.
91/// - Retrieve a limit for a specific time slice selection (time slice, season, or annual)
92///   using `get_limit()`.
93/// - Retrieve all limits as an iterator using `iter_limits()`. Note: individual
94///   limits within this iterator cannot be relied upon, but the totality of these limits can be
95///   used to construct a set of constraints that ensures that all limits are respected.
96#[derive(PartialEq, Debug, Clone)]
97pub struct ActivityLimits {
98    /// Optional annual limit
99    annual_limit: Option<RangeInclusive<Dimensionless>>,
100    /// Optional limits for each season
101    seasonal_limits: IndexMap<Season, RangeInclusive<Dimensionless>>,
102    /// Limits for each time slice (mandatory for all time slices)
103    time_slice_limits: IndexMap<TimeSliceID, RangeInclusive<Dimensionless>>,
104}
105
106impl ActivityLimits {
107    /// Create a new `ActivityLimits` with full availability for all time slices
108    pub fn new_with_full_availability(time_slice_info: &TimeSliceInfo) -> Self {
109        // Initialize time slice limits to full availability
110        let mut ts_limits = IndexMap::new();
111        for (ts_id, ts_length) in time_slice_info.iter() {
112            ts_limits.insert(
113                ts_id.clone(),
114                Dimensionless(0.0)..=Dimensionless(ts_length.value()),
115            );
116        }
117
118        ActivityLimits {
119            annual_limit: None,
120            seasonal_limits: IndexMap::new(),
121            time_slice_limits: ts_limits,
122        }
123    }
124
125    /// Create a new `ActivityLimits` from a map of limits for time slice selections
126    ///
127    /// The limits provided here may be for individual time slices, seasons, or the entire year.
128    /// Provided limits must reflect the fraction of potential annual activity available in the
129    /// given time slice selection. In other words, these are the absolute limits on activity per
130    /// `1/capacity_to_activity` units of capacity.
131    ///
132    /// It is not mandatory to provide any limits; if no limits are provided, full availability
133    /// will be assumed for all time slices (limited only by time slice lengths). However, if
134    /// limits are provided for any individual time slices, they must be provided for ALL time
135    /// slices. Similarly, if limits are provided for any seasons, they must be provided for ALL
136    /// seasons.
137    ///
138    /// No calculations are done here to account for time slice lengths; this must be handled by the
139    /// user when providing the limits. For example, a limit of 0..=0.1 for a time slice indicates
140    /// that 10% of the potential annual activity can be undertaken in that time slice. If the time
141    /// slice is 10% of the year, then this provides no additional constraint.
142    ///
143    /// Checks are done to ensure that provided limits are compatible with each other. For example,
144    /// a limit of "..0.01" for "winter" would be incompatible with a limit of "0.2.." for
145    /// "winter.day" (i.e. if activity must be >0.2 in "winter.night", then if cannot possibly be
146    /// ≤0.01 in winter as a whole).
147    pub fn new_from_limits(
148        limits: &HashMap<TimeSliceSelection, RangeInclusive<Dimensionless>>,
149        time_slice_info: &TimeSliceInfo,
150    ) -> Result<Self> {
151        let mut result = ActivityLimits::new_with_full_availability(time_slice_info);
152
153        // Add time slice limits first
154        let mut time_slices_added = IndexSet::new();
155        for (ts_selection, limit) in limits {
156            if let TimeSliceSelection::Single(ts_id) = ts_selection {
157                result.add_time_slice_limit(ts_id.clone(), limit.clone());
158                time_slices_added.insert(ts_id.clone());
159            }
160        }
161
162        // Check that limits have been added for all or no time slices
163        if !time_slices_added.is_empty() {
164            let missing = time_slice_info
165                .iter_ids()
166                .filter(|ts_id| !time_slices_added.contains(*ts_id))
167                .collect::<Vec<_>>();
168            ensure!(
169                missing.is_empty(),
170                "Missing availability limits for time slices: [{}]. Please provide",
171                missing.iter().join(", ")
172            );
173        }
174
175        // Then add seasonal limits
176        // Error will be raised if seasonal limits are incompatible with time slice limits
177        let mut seasons_added = IndexSet::new();
178        for (ts_selection, limit) in limits {
179            if let TimeSliceSelection::Season(season) = ts_selection {
180                result.add_seasonal_limit(season.clone(), limit.clone())?;
181                seasons_added.insert(season.clone());
182            }
183        }
184
185        // Check that limits have been added for all or no seasons
186        if !seasons_added.is_empty() {
187            let missing = time_slice_info
188                .iter_seasons()
189                .filter(|season| !seasons_added.contains(*season))
190                .collect::<Vec<_>>();
191            ensure!(
192                missing.is_empty(),
193                "Missing availability limits for seasons: [{}]. Please provide",
194                missing.iter().join(", "),
195            );
196        }
197
198        // Then add annual limit
199        // Error will be raised if annual limit is incompatible with time slice/seasonal limits
200        if let Some(limit) = limits.get(&TimeSliceSelection::Annual) {
201            result.add_annual_limit(limit.clone())?;
202        }
203
204        Ok(result)
205    }
206
207    /// Add a limit for a specific time slice
208    pub fn add_time_slice_limit(
209        &mut self,
210        ts_id: TimeSliceID,
211        limit: RangeInclusive<Dimensionless>,
212    ) {
213        self.time_slice_limits.insert(ts_id, limit);
214    }
215
216    /// Add a limit for a specific season
217    fn add_seasonal_limit(
218        &mut self,
219        season: Season,
220        limit: RangeInclusive<Dimensionless>,
221    ) -> Result<()> {
222        // Get current limit for the season
223        let current_limit = self.get_limit_for_season(&season);
224
225        // Ensure that the new limit overlaps with the current limit
226        // If not, it's impossible to satisfy both limits, so we must exit with an error
227        ensure!(
228            *limit.start() <= *current_limit.end() && *limit.end() >= *current_limit.start(),
229            "Availability limit for season {season} clashes with time slice limits",
230        );
231
232        // Only insert the seasonal limit if it provides an extra level of constraint above the
233        // existing time slice limits. This is to minimize the number of seasonal limits stored and
234        // returned by `iter_limits()`, therefore preventing unnecessary constraints from being
235        // added to the optimization model.
236        if *limit.start() > *current_limit.start() || *limit.end() < *current_limit.end() {
237            self.seasonal_limits.insert(season, limit);
238        }
239
240        Ok(())
241    }
242
243    /// Add an annual limit
244    fn add_annual_limit(&mut self, limit: RangeInclusive<Dimensionless>) -> Result<()> {
245        // Get current limit for the year
246        let current_limit = self.get_limit_for_year(&TimeSliceInfo::default());
247
248        // Ensure that the new limit overlaps with the current limit
249        // If not, it's impossible to satisfy both limits, so we must exit with an error
250        ensure!(
251            *limit.start() <= *current_limit.end() && *limit.end() >= *current_limit.start(),
252            "Annual availability limit clashes with time slice/seasonal limits",
253        );
254
255        // Only insert the annual limit if it provides an extra level of constraint above the
256        // existing time slice/seasonal limits. This prevents unnecessary constraints from being
257        // stored and added to the optimization model.
258        if *limit.start() > *current_limit.start() || *limit.end() < *current_limit.end() {
259            self.annual_limit = Some(limit);
260        }
261
262        Ok(())
263    }
264
265    /// Get the limit for a given time slice selection
266    pub fn get_limit(
267        &self,
268        time_slice_selection: &TimeSliceSelection,
269        time_slice_info: &TimeSliceInfo,
270    ) -> RangeInclusive<Dimensionless> {
271        match time_slice_selection {
272            TimeSliceSelection::Single(ts_id) => self.get_limit_for_time_slice(ts_id),
273            TimeSliceSelection::Season(season) => self.get_limit_for_season(season),
274            TimeSliceSelection::Annual => self.get_limit_for_year(time_slice_info),
275        }
276    }
277
278    /// Get the limit for a given time slice
279    pub fn get_limit_for_time_slice(
280        &self,
281        time_slice: &TimeSliceID,
282    ) -> RangeInclusive<Dimensionless> {
283        // Get limit for this specific time slice
284        let ts_limit = self.time_slice_limits[time_slice].clone();
285        let lower = *ts_limit.start();
286        let mut upper = *ts_limit.end();
287
288        // If there's a seasonal/annual limit, we must cap the timeslice limit to ensure that it
289        // doesn't exceed the upper bound of the season/year
290        if let Some(seasonal_limit) = self.seasonal_limits.get(&time_slice.season) {
291            upper = upper.min(*seasonal_limit.end());
292        }
293        if let Some(annual_limit) = &self.annual_limit {
294            upper = upper.min(*annual_limit.end());
295        }
296
297        lower..=upper
298    }
299
300    /// Get the limit for a given season
301    fn get_limit_for_season(&self, season: &Season) -> RangeInclusive<Dimensionless> {
302        // Get sum of limits for all time slices in this season
303        let mut lower = Dimensionless(0.0);
304        let mut upper = Dimensionless(0.0);
305        for (ts, limit) in &self.time_slice_limits {
306            if &ts.season == season {
307                lower += *limit.start();
308                upper += *limit.end();
309            }
310        }
311
312        // Bound this by the seasonal limit, if specified
313        if let Some(seasonal_limit) = self.seasonal_limits.get(season) {
314            lower = lower.max(*seasonal_limit.start());
315            upper = upper.min(*seasonal_limit.end());
316        }
317
318        // If there's an annual limit, we must also cap the seasonal limit to ensure it doesn't
319        // exceed the upper bound of the year
320        if let Some(annual_limit) = &self.annual_limit {
321            upper = upper.min(*annual_limit.end());
322        }
323
324        lower..=upper
325    }
326
327    /// Get the limit for the entire year
328    fn get_limit_for_year(&self, time_slice_info: &TimeSliceInfo) -> RangeInclusive<Dimensionless> {
329        // Get the sum of limits for all seasons
330        let mut total_lower = Dimensionless(0.0);
331        let mut total_upper = Dimensionless(0.0);
332        for ts_selection in time_slice_info.iter_selections_at_level(TimeSliceLevel::Season) {
333            let TimeSliceSelection::Season(season) = ts_selection else {
334                panic!("Expected season selection")
335            };
336            let season_limit = self.get_limit_for_season(&season);
337            total_lower += *season_limit.start();
338            total_upper += *season_limit.end();
339        }
340
341        // Bound this by the annual limit, if specified
342        if let Some(annual_limit) = &self.annual_limit {
343            total_lower = total_lower.max(*annual_limit.start());
344            total_upper = total_upper.min(*annual_limit.end());
345        }
346
347        total_lower..=total_upper
348    }
349
350    /// Iterate over all limits
351    ///
352    /// This first iterates over all individual timeslice limits, followed by seasonal limits (if
353    /// any), and finally the annual limit (if any).
354    pub fn iter_limits(
355        &self,
356    ) -> impl Iterator<Item = (TimeSliceSelection, &RangeInclusive<Dimensionless>)> {
357        // Iterate over all time slice limits
358        let time_slice_limits = self
359            .time_slice_limits
360            .iter()
361            .map(|(ts_id, limit)| (TimeSliceSelection::Single(ts_id.clone()), limit));
362
363        // Then seasonal limits, if any
364        let seasonal_limits = self
365            .seasonal_limits
366            .iter()
367            .map(|(season, limit)| (TimeSliceSelection::Season(season.clone()), limit));
368
369        // Then annual limit, if any
370        let annual_limits = self
371            .annual_limit
372            .as_ref()
373            .map(|limit| (TimeSliceSelection::Annual, limit));
374
375        // Chain all limits together
376        time_slice_limits
377            .chain(seasonal_limits)
378            .chain(annual_limits)
379    }
380}
381
382/// Represents a maximum annual commodity coeff for a given process
383#[derive(PartialEq, Debug, Clone)]
384pub struct ProcessFlow {
385    /// The commodity produced or consumed by this flow
386    pub commodity: Rc<Commodity>,
387    /// Maximum annual commodity flow quantity relative to other commodity flows.
388    ///
389    /// Positive value indicates flow out and negative value indicates flow in.
390    pub coeff: FlowPerActivity,
391    /// Identifies if a flow is fixed or flexible.
392    pub kind: FlowType,
393    /// Cost per unit flow.
394    ///
395    /// For example, cost per unit of natural gas produced. The user can apply it to any specified
396    /// flow.
397    pub cost: MoneyPerFlow,
398}
399
400impl ProcessFlow {
401    /// Get the cost for this flow with the given parameters.
402    ///
403    /// This includes cost per unit flow and levies/incentives, if any.
404    pub fn get_total_cost(
405        &self,
406        region_id: &RegionID,
407        year: u32,
408        time_slice: &TimeSliceID,
409    ) -> MoneyPerActivity {
410        let cost_per_unit = self.cost + self.get_levy(region_id, year, time_slice);
411
412        self.coeff.abs() * cost_per_unit
413    }
414
415    /// Get the levy/incentive for this process flow with the given parameters, if any
416    fn get_levy(&self, region_id: &RegionID, year: u32, time_slice: &TimeSliceID) -> MoneyPerFlow {
417        match self.direction() {
418            FlowDirection::Input => *self
419                .commodity
420                .levies_cons
421                .get(&(region_id.clone(), year, time_slice.clone()))
422                .unwrap_or(&MoneyPerFlow(0.0)),
423            FlowDirection::Output => *self
424                .commodity
425                .levies_prod
426                .get(&(region_id.clone(), year, time_slice.clone()))
427                .unwrap_or(&MoneyPerFlow(0.0)),
428            FlowDirection::Zero => MoneyPerFlow(0.0),
429        }
430    }
431
432    /// Direction of the flow
433    pub fn direction(&self) -> FlowDirection {
434        match self.coeff {
435            x if x < FlowPerActivity(0.0) => FlowDirection::Input,
436            x if x > FlowPerActivity(0.0) => FlowDirection::Output,
437            _ => FlowDirection::Zero,
438        }
439    }
440}
441
442/// Type of commodity flow (see [`ProcessFlow`])
443#[derive(PartialEq, Default, Debug, Clone, DeserializeLabeledStringEnum)]
444pub enum FlowType {
445    /// The input to output flow ratio is fixed
446    #[default]
447    #[string = "fixed"]
448    Fixed,
449    /// The flow ratio can vary, subject to overall flow of a specified group of commodities whose
450    /// input/output ratio must be as per user input data
451    #[string = "flexible"]
452    Flexible,
453}
454
455/// Direction of the flow (see [`ProcessFlow`])
456#[derive(PartialEq, Debug)]
457pub enum FlowDirection {
458    /// The flow is an input (i.e., coeff < 0)
459    Input,
460    /// The flow is an output (i.e., coeff > 0)
461    Output,
462    /// The flow is zero, neither input nor output (i.e., coeff == 0)
463    Zero,
464}
465
466/// Additional parameters for a process
467#[derive(PartialEq, Clone, Debug)]
468pub struct ProcessParameter {
469    /// Overnight capital cost per unit capacity
470    pub capital_cost: MoneyPerCapacity,
471    /// Annual operating cost per unit capacity
472    pub fixed_operating_cost: MoneyPerCapacityPerYear,
473    /// Annual variable operating cost per unit activity
474    pub variable_operating_cost: MoneyPerActivity,
475    /// Lifetime in years of an asset created from this process
476    pub lifetime: u32,
477    /// Process-specific discount rate
478    pub discount_rate: Dimensionless,
479}
480
481/// A constraint imposed on investments in the process
482#[derive(PartialEq, Debug, Clone)]
483pub struct ProcessInvestmentConstraint {
484    /// Addition constraint: Yearly limit an agent can invest
485    /// in the process, shared according to the agent's
486    /// proportion of the processes primary commodity demand
487    pub addition_limit: Option<f64>,
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::commodity::{CommodityLevyMap, CommodityType, DemandMap};
494    use crate::fixture::{assert_error, region_id, time_slice, time_slice_info2};
495    use crate::time_slice::TimeSliceLevel;
496    use crate::time_slice::TimeSliceSelection;
497    use float_cmp::assert_approx_eq;
498    use rstest::{fixture, rstest};
499    use std::collections::HashMap;
500    use std::rc::Rc;
501
502    #[fixture]
503    fn commodity_with_levy(region_id: RegionID, time_slice: TimeSliceID) -> Rc<Commodity> {
504        let mut levies_prod = CommodityLevyMap::new();
505        let mut levies_cons = CommodityLevyMap::new();
506
507        // Add levy for the default region and time slice
508        levies_prod.insert(
509            (region_id.clone(), 2020, time_slice.clone()),
510            MoneyPerFlow(10.0),
511        );
512        levies_cons.insert(
513            (region_id.clone(), 2020, time_slice.clone()),
514            MoneyPerFlow(-10.0),
515        );
516        // Add levy for a different region
517        levies_prod.insert(("USA".into(), 2020, time_slice.clone()), MoneyPerFlow(5.0));
518        levies_cons.insert(("USA".into(), 2020, time_slice.clone()), MoneyPerFlow(-5.0));
519        // Add levy for a different year
520        levies_prod.insert(
521            (region_id.clone(), 2030, time_slice.clone()),
522            MoneyPerFlow(7.0),
523        );
524        levies_cons.insert(
525            (region_id.clone(), 2030, time_slice.clone()),
526            MoneyPerFlow(-7.0),
527        );
528        // Add levy for a different time slice
529        levies_prod.insert(
530            (
531                region_id.clone(),
532                2020,
533                TimeSliceID {
534                    season: "summer".into(),
535                    time_of_day: "day".into(),
536                },
537            ),
538            MoneyPerFlow(3.0),
539        );
540        levies_cons.insert(
541            (
542                region_id.clone(),
543                2020,
544                TimeSliceID {
545                    season: "summer".into(),
546                    time_of_day: "day".into(),
547                },
548            ),
549            MoneyPerFlow(-3.0),
550        );
551
552        Rc::new(Commodity {
553            id: "test_commodity".into(),
554            description: "Test commodity".into(),
555            kind: CommodityType::ServiceDemand,
556            time_slice_level: TimeSliceLevel::Annual,
557            levies_prod: levies_prod,
558            levies_cons: levies_cons,
559            demand: DemandMap::new(),
560        })
561    }
562
563    #[fixture]
564    fn commodity_with_consumption_levy(
565        region_id: RegionID,
566        time_slice: TimeSliceID,
567    ) -> Rc<Commodity> {
568        let mut levies = CommodityLevyMap::new();
569        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(10.0));
570
571        Rc::new(Commodity {
572            id: "test_commodity".into(),
573            description: "Test commodity".into(),
574            kind: CommodityType::ServiceDemand,
575            time_slice_level: TimeSliceLevel::Annual,
576            levies_prod: CommodityLevyMap::new(),
577            levies_cons: levies,
578            demand: DemandMap::new(),
579        })
580    }
581
582    #[fixture]
583    fn commodity_with_production_levy(
584        region_id: RegionID,
585        time_slice: TimeSliceID,
586    ) -> Rc<Commodity> {
587        let mut levies = CommodityLevyMap::new();
588        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(10.0));
589
590        Rc::new(Commodity {
591            id: "test_commodity".into(),
592            description: "Test commodity".into(),
593            kind: CommodityType::ServiceDemand,
594            time_slice_level: TimeSliceLevel::Annual,
595            levies_prod: levies,
596            levies_cons: CommodityLevyMap::new(),
597            demand: DemandMap::new(),
598        })
599    }
600
601    #[fixture]
602    fn commodity_with_incentive(region_id: RegionID, time_slice: TimeSliceID) -> Rc<Commodity> {
603        let mut levies_prod = CommodityLevyMap::new();
604        levies_prod.insert(
605            (region_id.clone(), 2020, time_slice.clone()),
606            MoneyPerFlow(-5.0),
607        );
608        let mut levies_cons = CommodityLevyMap::new();
609        levies_cons.insert((region_id, 2020, time_slice), MoneyPerFlow(5.0));
610
611        Rc::new(Commodity {
612            id: "test_commodity".into(),
613            description: "Test commodity".into(),
614            kind: CommodityType::ServiceDemand,
615            time_slice_level: TimeSliceLevel::Annual,
616            levies_prod: levies_prod,
617            levies_cons: levies_cons,
618            demand: DemandMap::new(),
619        })
620    }
621
622    #[fixture]
623    fn commodity_no_levies() -> Rc<Commodity> {
624        Rc::new(Commodity {
625            id: "test_commodity".into(),
626            description: "Test commodity".into(),
627            kind: CommodityType::ServiceDemand,
628            time_slice_level: TimeSliceLevel::Annual,
629            levies_prod: CommodityLevyMap::new(),
630            levies_cons: CommodityLevyMap::new(),
631            demand: DemandMap::new(),
632        })
633    }
634
635    #[fixture]
636    fn flow_with_cost() -> ProcessFlow {
637        ProcessFlow {
638            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_prod: CommodityLevyMap::new(),
644                levies_cons: CommodityLevyMap::new(),
645                demand: DemandMap::new(),
646            }),
647            coeff: FlowPerActivity(1.0),
648            kind: FlowType::Fixed,
649            cost: MoneyPerFlow(5.0),
650        }
651    }
652
653    #[fixture]
654    fn flow_with_cost_and_levy(region_id: RegionID, time_slice: TimeSliceID) -> ProcessFlow {
655        let mut levies = CommodityLevyMap::new();
656        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(10.0));
657
658        ProcessFlow {
659            commodity: Rc::new(Commodity {
660                id: "test_commodity".into(),
661                description: "Test commodity".into(),
662                kind: CommodityType::ServiceDemand,
663                time_slice_level: TimeSliceLevel::Annual,
664                levies_prod: levies,
665                levies_cons: CommodityLevyMap::new(),
666                demand: DemandMap::new(),
667            }),
668            coeff: FlowPerActivity(1.0),
669            kind: FlowType::Fixed,
670            cost: MoneyPerFlow(5.0),
671        }
672    }
673
674    #[fixture]
675    fn flow_with_cost_and_incentive(region_id: RegionID, time_slice: TimeSliceID) -> ProcessFlow {
676        let mut levies = CommodityLevyMap::new();
677        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(-3.0));
678
679        ProcessFlow {
680            commodity: Rc::new(Commodity {
681                id: "test_commodity".into(),
682                description: "Test commodity".into(),
683                kind: CommodityType::ServiceDemand,
684                time_slice_level: TimeSliceLevel::Annual,
685                levies_prod: levies,
686                levies_cons: CommodityLevyMap::new(),
687                demand: DemandMap::new(),
688            }),
689            coeff: FlowPerActivity(1.0),
690            kind: FlowType::Fixed,
691            cost: MoneyPerFlow(5.0),
692        }
693    }
694
695    #[rstest]
696    fn test_get_levy_no_levies(
697        commodity_no_levies: Rc<Commodity>,
698        region_id: RegionID,
699        time_slice: TimeSliceID,
700    ) {
701        let flow = ProcessFlow {
702            commodity: commodity_no_levies,
703            coeff: FlowPerActivity(1.0),
704            kind: FlowType::Fixed,
705            cost: MoneyPerFlow(0.0),
706        };
707
708        assert_eq!(
709            flow.get_levy(&region_id, 2020, &time_slice),
710            MoneyPerFlow(0.0)
711        );
712    }
713
714    #[rstest]
715    fn test_get_levy_with_levy(
716        commodity_with_levy: Rc<Commodity>,
717        region_id: RegionID,
718        time_slice: TimeSliceID,
719    ) {
720        let flow = ProcessFlow {
721            commodity: commodity_with_levy,
722            coeff: FlowPerActivity(1.0),
723            kind: FlowType::Fixed,
724            cost: MoneyPerFlow(0.0),
725        };
726
727        assert_eq!(
728            flow.get_levy(&region_id, 2020, &time_slice),
729            MoneyPerFlow(10.0)
730        );
731    }
732
733    #[rstest]
734    fn test_get_levy_with_incentive(
735        commodity_with_incentive: Rc<Commodity>,
736        region_id: RegionID,
737        time_slice: TimeSliceID,
738    ) {
739        let flow = ProcessFlow {
740            commodity: commodity_with_incentive,
741            coeff: FlowPerActivity(1.0),
742            kind: FlowType::Fixed,
743            cost: MoneyPerFlow(0.0),
744        };
745
746        assert_eq!(
747            flow.get_levy(&region_id, 2020, &time_slice),
748            MoneyPerFlow(-5.0)
749        );
750    }
751
752    #[rstest]
753    fn test_get_levy_different_region(commodity_with_levy: Rc<Commodity>, time_slice: TimeSliceID) {
754        let flow = ProcessFlow {
755            commodity: commodity_with_levy,
756            coeff: FlowPerActivity(1.0),
757            kind: FlowType::Fixed,
758            cost: MoneyPerFlow(0.0),
759        };
760
761        assert_eq!(
762            flow.get_levy(&"USA".into(), 2020, &time_slice),
763            MoneyPerFlow(5.0)
764        );
765    }
766
767    #[rstest]
768    fn test_get_levy_different_year(
769        commodity_with_levy: Rc<Commodity>,
770        region_id: RegionID,
771        time_slice: TimeSliceID,
772    ) {
773        let flow = ProcessFlow {
774            commodity: commodity_with_levy,
775            coeff: FlowPerActivity(1.0),
776            kind: FlowType::Fixed,
777            cost: MoneyPerFlow(0.0),
778        };
779
780        assert_eq!(
781            flow.get_levy(&region_id, 2030, &time_slice),
782            MoneyPerFlow(7.0)
783        );
784    }
785
786    #[rstest]
787    fn test_get_levy_different_time_slice(commodity_with_levy: Rc<Commodity>, region_id: RegionID) {
788        let flow = ProcessFlow {
789            commodity: commodity_with_levy,
790            coeff: FlowPerActivity(1.0),
791            kind: FlowType::Fixed,
792            cost: MoneyPerFlow(0.0),
793        };
794
795        let different_time_slice = TimeSliceID {
796            season: "summer".into(),
797            time_of_day: "day".into(),
798        };
799
800        assert_eq!(
801            flow.get_levy(&region_id, 2020, &different_time_slice),
802            MoneyPerFlow(3.0)
803        );
804    }
805
806    #[rstest]
807    fn test_get_levy_consumption_positive_coeff(
808        commodity_with_consumption_levy: Rc<Commodity>,
809        region_id: RegionID,
810        time_slice: TimeSliceID,
811    ) {
812        let flow = ProcessFlow {
813            commodity: commodity_with_consumption_levy,
814            coeff: FlowPerActivity(1.0), // Positive coefficient means production
815            kind: FlowType::Fixed,
816            cost: MoneyPerFlow(0.0),
817        };
818
819        assert_eq!(
820            flow.get_levy(&region_id, 2020, &time_slice),
821            MoneyPerFlow(0.0)
822        );
823    }
824
825    #[rstest]
826    fn test_get_levy_consumption_negative_coeff(
827        commodity_with_consumption_levy: Rc<Commodity>,
828        region_id: RegionID,
829        time_slice: TimeSliceID,
830    ) {
831        let flow = ProcessFlow {
832            commodity: commodity_with_consumption_levy,
833            coeff: FlowPerActivity(-1.0), // Negative coefficient means consumption
834            kind: FlowType::Fixed,
835            cost: MoneyPerFlow(0.0),
836        };
837
838        assert_eq!(
839            flow.get_levy(&region_id, 2020, &time_slice),
840            MoneyPerFlow(10.0)
841        );
842    }
843
844    #[rstest]
845    fn test_get_levy_production_positive_coeff(
846        commodity_with_production_levy: Rc<Commodity>,
847        region_id: RegionID,
848        time_slice: TimeSliceID,
849    ) {
850        let flow = ProcessFlow {
851            commodity: commodity_with_production_levy,
852            coeff: FlowPerActivity(1.0), // Positive coefficient means production
853            kind: FlowType::Fixed,
854            cost: MoneyPerFlow(0.0),
855        };
856
857        assert_eq!(
858            flow.get_levy(&region_id, 2020, &time_slice),
859            MoneyPerFlow(10.0)
860        );
861    }
862
863    #[rstest]
864    fn test_get_levy_production_negative_coeff(
865        commodity_with_production_levy: Rc<Commodity>,
866        region_id: RegionID,
867        time_slice: TimeSliceID,
868    ) {
869        let flow = ProcessFlow {
870            commodity: commodity_with_production_levy,
871            coeff: FlowPerActivity(-1.0), // Negative coefficient means consumption
872            kind: FlowType::Fixed,
873            cost: MoneyPerFlow(0.0),
874        };
875
876        assert_eq!(
877            flow.get_levy(&region_id, 2020, &time_slice),
878            MoneyPerFlow(0.0)
879        );
880    }
881
882    #[rstest]
883    fn test_get_total_cost_base_cost(
884        flow_with_cost: ProcessFlow,
885        region_id: RegionID,
886        time_slice: TimeSliceID,
887    ) {
888        assert_eq!(
889            flow_with_cost.get_total_cost(&region_id, 2020, &time_slice),
890            MoneyPerActivity(5.0)
891        );
892    }
893
894    #[rstest]
895    fn test_get_total_cost_with_levy(
896        flow_with_cost_and_levy: ProcessFlow,
897        region_id: RegionID,
898        time_slice: TimeSliceID,
899    ) {
900        assert_eq!(
901            flow_with_cost_and_levy.get_total_cost(&region_id, 2020, &time_slice),
902            MoneyPerActivity(15.0)
903        );
904    }
905
906    #[rstest]
907    fn test_get_total_cost_with_incentive(
908        flow_with_cost_and_incentive: ProcessFlow,
909        region_id: RegionID,
910        time_slice: TimeSliceID,
911    ) {
912        assert_eq!(
913            flow_with_cost_and_incentive.get_total_cost(&region_id, 2020, &time_slice),
914            MoneyPerActivity(2.0)
915        );
916    }
917
918    #[rstest]
919    fn test_get_total_cost_negative_coeff(
920        mut flow_with_cost: ProcessFlow,
921        region_id: RegionID,
922        time_slice: TimeSliceID,
923    ) {
924        flow_with_cost.coeff = FlowPerActivity(-2.0);
925        assert_eq!(
926            flow_with_cost.get_total_cost(&region_id, 2020, &time_slice),
927            MoneyPerActivity(10.0)
928        );
929    }
930
931    #[rstest]
932    fn test_get_total_cost_zero_coeff(
933        mut flow_with_cost: ProcessFlow,
934        region_id: RegionID,
935        time_slice: TimeSliceID,
936    ) {
937        flow_with_cost.coeff = FlowPerActivity(0.0);
938        assert_eq!(
939            flow_with_cost.get_total_cost(&region_id, 2020, &time_slice),
940            MoneyPerActivity(0.0)
941        );
942    }
943
944    #[test]
945    fn test_is_input_and_is_output() {
946        let commodity = Rc::new(Commodity {
947            id: "test_commodity".into(),
948            description: "Test commodity".into(),
949            kind: CommodityType::ServiceDemand,
950            time_slice_level: TimeSliceLevel::Annual,
951            levies_prod: CommodityLevyMap::new(),
952            levies_cons: CommodityLevyMap::new(),
953            demand: DemandMap::new(),
954        });
955
956        let flow_in = ProcessFlow {
957            commodity: Rc::clone(&commodity),
958            coeff: FlowPerActivity(-1.0),
959            kind: FlowType::Fixed,
960            cost: MoneyPerFlow(0.0),
961        };
962        let flow_out = ProcessFlow {
963            commodity: Rc::clone(&commodity),
964            coeff: FlowPerActivity(1.0),
965            kind: FlowType::Fixed,
966            cost: MoneyPerFlow(0.0),
967        };
968        let flow_zero = ProcessFlow {
969            commodity: Rc::clone(&commodity),
970            coeff: FlowPerActivity(0.0),
971            kind: FlowType::Fixed,
972            cost: MoneyPerFlow(0.0),
973        };
974
975        assert!(flow_in.direction() == FlowDirection::Input);
976        assert!(flow_out.direction() == FlowDirection::Output);
977        assert!(flow_zero.direction() == FlowDirection::Zero);
978    }
979
980    #[rstest]
981    fn test_new_with_full_availability(time_slice_info2: TimeSliceInfo) {
982        let limits = ActivityLimits::new_with_full_availability(&time_slice_info2);
983
984        // Each timeslice from the info should be present in the limits
985        for (ts_id, ts_len) in time_slice_info2.iter() {
986            let l = limits.get_limit_for_time_slice(&ts_id);
987            // Lower bound should be zero and upper bound equal to timeslice length
988            assert_eq!(*l.start(), Dimensionless(0.0));
989            assert_eq!(*l.end(), Dimensionless(ts_len.value()));
990        }
991
992        // Annual limit should be 0..1
993        let annual_limit = limits.get_limit(&TimeSliceSelection::Annual, &time_slice_info2);
994        assert_approx_eq!(Dimensionless, *annual_limit.start(), Dimensionless(0.0));
995        assert_approx_eq!(Dimensionless, *annual_limit.end(), Dimensionless(1.0));
996    }
997
998    #[rstest]
999    fn test_new_from_limits_with_seasonal_limit_applied(time_slice_info2: TimeSliceInfo) {
1000        let mut limits = HashMap::new();
1001
1002        // Set a seasonal upper limit that is stricter than the sum of timeslices
1003        limits.insert(
1004            TimeSliceSelection::Season("winter".into()),
1005            Dimensionless(0.0)..=Dimensionless(0.01),
1006        );
1007
1008        let result = ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap();
1009
1010        // Each timeslice upper bound should be capped by the seasonal upper bound (0.01)
1011        for (ts_id, _ts_len) in time_slice_info2.iter() {
1012            let ts_limit = result.get_limit_for_time_slice(&ts_id);
1013            assert_eq!(*ts_limit.end(), Dimensionless(0.01));
1014        }
1015
1016        // The seasonal limit should reflect the given bound
1017        let season_limit = result.get_limit(
1018            &TimeSliceSelection::Season("winter".into()),
1019            &time_slice_info2,
1020        );
1021        assert_eq!(*season_limit.end(), Dimensionless(0.01));
1022    }
1023
1024    #[rstest]
1025    fn test_new_from_limits_missing_timeslices_error(time_slice_info2: TimeSliceInfo) {
1026        let mut limits = HashMap::new();
1027
1028        // Add a single timeslice limit but do not provide limits for all timeslices
1029        let first_ts = time_slice_info2.iter().next().unwrap().0.clone();
1030        limits.insert(
1031            TimeSliceSelection::Single(first_ts),
1032            Dimensionless(0.0)..=Dimensionless(0.1),
1033        );
1034
1035        assert_error!(
1036            ActivityLimits::new_from_limits(&limits, &time_slice_info2),
1037            "Missing availability limits for time slices: [winter.night]. Please provide"
1038        );
1039    }
1040
1041    #[rstest]
1042    fn test_new_from_limits_incompatible_limits(time_slice_info2: TimeSliceInfo) {
1043        let mut limits = HashMap::new();
1044
1045        // Time slice limits capping activity to 0.1 in each ts
1046        for (ts_id, _ts_len) in time_slice_info2.iter() {
1047            limits.insert(
1048                TimeSliceSelection::Single(ts_id.clone()),
1049                Dimensionless(0.0)..=Dimensionless(0.1),
1050            );
1051        }
1052
1053        // Seasonal limit that is incompatible (lower limit above the sum of time slice upper limits)
1054        limits.insert(
1055            TimeSliceSelection::Season("winter".into()),
1056            Dimensionless(0.99)..=Dimensionless(1.0),
1057        );
1058
1059        assert_error!(
1060            ActivityLimits::new_from_limits(&limits, &time_slice_info2),
1061            "Availability limit for season winter clashes with time slice limits"
1062        );
1063    }
1064}