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