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///
501/// Constraints apply to a specific milestone year, and have been pre-scaled from annual limits
502/// defined in the input data to account for the number of years since the previous milestone year.
503#[derive(PartialEq, Debug, Clone)]
504pub struct ProcessInvestmentConstraint {
505    /// Addition constraint: Limit an agent can invest in the process, shared according to the
506    /// agent's proportion of the process's primary commodity demand
507    pub addition_limit: Option<Capacity>,
508}
509
510impl ProcessInvestmentConstraint {
511    /// Calculate the effective addition limit
512    ///
513    /// For now, this just returns `addition_limit`, but in the future when we add growth
514    /// limits and total capacity limits, this will have more complex logic which will depend on the
515    /// current total capacity.
516    pub fn get_addition_limit(&self) -> Option<Capacity> {
517        self.addition_limit
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::commodity::{CommodityLevyMap, CommodityType, DemandMap, PricingStrategy};
525    use crate::fixture::{assert_error, region_id, time_slice, time_slice_info2};
526    use crate::time_slice::TimeSliceLevel;
527    use crate::time_slice::TimeSliceSelection;
528    use float_cmp::assert_approx_eq;
529    use rstest::{fixture, rstest};
530    use std::collections::HashMap;
531    use std::rc::Rc;
532
533    #[fixture]
534    fn commodity_with_levy(region_id: RegionID, time_slice: TimeSliceID) -> Rc<Commodity> {
535        let mut levies_prod = CommodityLevyMap::new();
536        let mut levies_cons = CommodityLevyMap::new();
537
538        // Add levy for the default region and time slice
539        levies_prod.insert(
540            (region_id.clone(), 2020, time_slice.clone()),
541            MoneyPerFlow(10.0),
542        );
543        levies_cons.insert(
544            (region_id.clone(), 2020, time_slice.clone()),
545            MoneyPerFlow(-10.0),
546        );
547        // Add levy for a different region
548        levies_prod.insert(("USA".into(), 2020, time_slice.clone()), MoneyPerFlow(5.0));
549        levies_cons.insert(("USA".into(), 2020, time_slice.clone()), MoneyPerFlow(-5.0));
550        // Add levy for a different year
551        levies_prod.insert(
552            (region_id.clone(), 2030, time_slice.clone()),
553            MoneyPerFlow(7.0),
554        );
555        levies_cons.insert(
556            (region_id.clone(), 2030, time_slice.clone()),
557            MoneyPerFlow(-7.0),
558        );
559        // Add levy for a different time slice
560        levies_prod.insert(
561            (
562                region_id.clone(),
563                2020,
564                TimeSliceID {
565                    season: "summer".into(),
566                    time_of_day: "day".into(),
567                },
568            ),
569            MoneyPerFlow(3.0),
570        );
571        levies_cons.insert(
572            (
573                region_id.clone(),
574                2020,
575                TimeSliceID {
576                    season: "summer".into(),
577                    time_of_day: "day".into(),
578                },
579            ),
580            MoneyPerFlow(-3.0),
581        );
582
583        Rc::new(Commodity {
584            id: "test_commodity".into(),
585            description: "Test commodity".into(),
586            kind: CommodityType::ServiceDemand,
587            time_slice_level: TimeSliceLevel::Annual,
588            pricing_strategy: PricingStrategy::Shadow,
589            levies_prod,
590            levies_cons,
591            demand: DemandMap::new(),
592            units: "PJ".into(),
593        })
594    }
595
596    #[fixture]
597    fn commodity_with_consumption_levy(
598        region_id: RegionID,
599        time_slice: TimeSliceID,
600    ) -> Rc<Commodity> {
601        let mut levies = CommodityLevyMap::new();
602        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(10.0));
603
604        Rc::new(Commodity {
605            id: "test_commodity".into(),
606            description: "Test commodity".into(),
607            kind: CommodityType::ServiceDemand,
608            time_slice_level: TimeSliceLevel::Annual,
609            pricing_strategy: PricingStrategy::Shadow,
610            levies_prod: CommodityLevyMap::new(),
611            levies_cons: levies,
612            demand: DemandMap::new(),
613            units: "PJ".into(),
614        })
615    }
616
617    #[fixture]
618    fn commodity_with_production_levy(
619        region_id: RegionID,
620        time_slice: TimeSliceID,
621    ) -> Rc<Commodity> {
622        let mut levies = CommodityLevyMap::new();
623        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(10.0));
624
625        Rc::new(Commodity {
626            id: "test_commodity".into(),
627            description: "Test commodity".into(),
628            kind: CommodityType::ServiceDemand,
629            time_slice_level: TimeSliceLevel::Annual,
630            pricing_strategy: PricingStrategy::Shadow,
631            levies_prod: levies,
632            levies_cons: CommodityLevyMap::new(),
633            demand: DemandMap::new(),
634            units: "PJ".into(),
635        })
636    }
637
638    #[fixture]
639    fn commodity_with_incentive(region_id: RegionID, time_slice: TimeSliceID) -> Rc<Commodity> {
640        let mut levies_prod = CommodityLevyMap::new();
641        levies_prod.insert(
642            (region_id.clone(), 2020, time_slice.clone()),
643            MoneyPerFlow(-5.0),
644        );
645        let mut levies_cons = CommodityLevyMap::new();
646        levies_cons.insert((region_id, 2020, time_slice), MoneyPerFlow(5.0));
647
648        Rc::new(Commodity {
649            id: "test_commodity".into(),
650            description: "Test commodity".into(),
651            kind: CommodityType::ServiceDemand,
652            time_slice_level: TimeSliceLevel::Annual,
653            pricing_strategy: PricingStrategy::Shadow,
654            levies_prod,
655            levies_cons,
656            demand: DemandMap::new(),
657            units: "PJ".into(),
658        })
659    }
660
661    #[fixture]
662    fn commodity_no_levies() -> Rc<Commodity> {
663        Rc::new(Commodity {
664            id: "test_commodity".into(),
665            description: "Test commodity".into(),
666            kind: CommodityType::ServiceDemand,
667            time_slice_level: TimeSliceLevel::Annual,
668            pricing_strategy: PricingStrategy::Shadow,
669            levies_prod: CommodityLevyMap::new(),
670            levies_cons: CommodityLevyMap::new(),
671            demand: DemandMap::new(),
672            units: "PJ".into(),
673        })
674    }
675
676    #[fixture]
677    fn flow_with_cost() -> ProcessFlow {
678        ProcessFlow {
679            commodity: Rc::new(Commodity {
680                id: "test_commodity".into(),
681                description: "Test commodity".into(),
682                kind: CommodityType::ServiceDemand,
683                time_slice_level: TimeSliceLevel::Annual,
684                pricing_strategy: PricingStrategy::Shadow,
685                levies_prod: CommodityLevyMap::new(),
686                levies_cons: CommodityLevyMap::new(),
687                demand: DemandMap::new(),
688                units: "PJ".into(),
689            }),
690            coeff: FlowPerActivity(1.0),
691            kind: FlowType::Fixed,
692            cost: MoneyPerFlow(5.0),
693        }
694    }
695
696    #[fixture]
697    fn flow_with_cost_and_levy(region_id: RegionID, time_slice: TimeSliceID) -> ProcessFlow {
698        let mut levies = CommodityLevyMap::new();
699        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(10.0));
700
701        ProcessFlow {
702            commodity: Rc::new(Commodity {
703                id: "test_commodity".into(),
704                description: "Test commodity".into(),
705                kind: CommodityType::ServiceDemand,
706                time_slice_level: TimeSliceLevel::Annual,
707                pricing_strategy: PricingStrategy::Shadow,
708                levies_prod: levies,
709                levies_cons: CommodityLevyMap::new(),
710                demand: DemandMap::new(),
711                units: "PJ".into(),
712            }),
713            coeff: FlowPerActivity(1.0),
714            kind: FlowType::Fixed,
715            cost: MoneyPerFlow(5.0),
716        }
717    }
718
719    #[fixture]
720    fn flow_with_cost_and_incentive(region_id: RegionID, time_slice: TimeSliceID) -> ProcessFlow {
721        let mut levies = CommodityLevyMap::new();
722        levies.insert((region_id, 2020, time_slice), MoneyPerFlow(-3.0));
723
724        ProcessFlow {
725            commodity: Rc::new(Commodity {
726                id: "test_commodity".into(),
727                description: "Test commodity".into(),
728                kind: CommodityType::ServiceDemand,
729                time_slice_level: TimeSliceLevel::Annual,
730                pricing_strategy: PricingStrategy::Shadow,
731                levies_prod: levies,
732                levies_cons: CommodityLevyMap::new(),
733                demand: DemandMap::new(),
734                units: "PJ".into(),
735            }),
736            coeff: FlowPerActivity(1.0),
737            kind: FlowType::Fixed,
738            cost: MoneyPerFlow(5.0),
739        }
740    }
741
742    #[rstest]
743    fn get_levy_no_levies(
744        commodity_no_levies: Rc<Commodity>,
745        region_id: RegionID,
746        time_slice: TimeSliceID,
747    ) {
748        let flow = ProcessFlow {
749            commodity: commodity_no_levies,
750            coeff: FlowPerActivity(1.0),
751            kind: FlowType::Fixed,
752            cost: MoneyPerFlow(0.0),
753        };
754
755        assert_eq!(
756            flow.get_levy(&region_id, 2020, &time_slice),
757            MoneyPerFlow(0.0)
758        );
759    }
760
761    #[rstest]
762    fn get_levy_with_levy(
763        commodity_with_levy: Rc<Commodity>,
764        region_id: RegionID,
765        time_slice: TimeSliceID,
766    ) {
767        let flow = ProcessFlow {
768            commodity: commodity_with_levy,
769            coeff: FlowPerActivity(1.0),
770            kind: FlowType::Fixed,
771            cost: MoneyPerFlow(0.0),
772        };
773
774        assert_eq!(
775            flow.get_levy(&region_id, 2020, &time_slice),
776            MoneyPerFlow(10.0)
777        );
778    }
779
780    #[rstest]
781    fn get_levy_with_incentive(
782        commodity_with_incentive: Rc<Commodity>,
783        region_id: RegionID,
784        time_slice: TimeSliceID,
785    ) {
786        let flow = ProcessFlow {
787            commodity: commodity_with_incentive,
788            coeff: FlowPerActivity(1.0),
789            kind: FlowType::Fixed,
790            cost: MoneyPerFlow(0.0),
791        };
792
793        assert_eq!(
794            flow.get_levy(&region_id, 2020, &time_slice),
795            MoneyPerFlow(-5.0)
796        );
797    }
798
799    #[rstest]
800    fn get_levy_different_region(commodity_with_levy: Rc<Commodity>, time_slice: TimeSliceID) {
801        let flow = ProcessFlow {
802            commodity: commodity_with_levy,
803            coeff: FlowPerActivity(1.0),
804            kind: FlowType::Fixed,
805            cost: MoneyPerFlow(0.0),
806        };
807
808        assert_eq!(
809            flow.get_levy(&"USA".into(), 2020, &time_slice),
810            MoneyPerFlow(5.0)
811        );
812    }
813
814    #[rstest]
815    fn get_levy_different_year(
816        commodity_with_levy: Rc<Commodity>,
817        region_id: RegionID,
818        time_slice: TimeSliceID,
819    ) {
820        let flow = ProcessFlow {
821            commodity: commodity_with_levy,
822            coeff: FlowPerActivity(1.0),
823            kind: FlowType::Fixed,
824            cost: MoneyPerFlow(0.0),
825        };
826
827        assert_eq!(
828            flow.get_levy(&region_id, 2030, &time_slice),
829            MoneyPerFlow(7.0)
830        );
831    }
832
833    #[rstest]
834    fn get_levy_different_time_slice(commodity_with_levy: Rc<Commodity>, region_id: RegionID) {
835        let flow = ProcessFlow {
836            commodity: commodity_with_levy,
837            coeff: FlowPerActivity(1.0),
838            kind: FlowType::Fixed,
839            cost: MoneyPerFlow(0.0),
840        };
841
842        let different_time_slice = TimeSliceID {
843            season: "summer".into(),
844            time_of_day: "day".into(),
845        };
846
847        assert_eq!(
848            flow.get_levy(&region_id, 2020, &different_time_slice),
849            MoneyPerFlow(3.0)
850        );
851    }
852
853    #[rstest]
854    fn get_levy_consumption_positive_coeff(
855        commodity_with_consumption_levy: Rc<Commodity>,
856        region_id: RegionID,
857        time_slice: TimeSliceID,
858    ) {
859        let flow = ProcessFlow {
860            commodity: commodity_with_consumption_levy,
861            coeff: FlowPerActivity(1.0), // Positive coefficient means production
862            kind: FlowType::Fixed,
863            cost: MoneyPerFlow(0.0),
864        };
865
866        assert_eq!(
867            flow.get_levy(&region_id, 2020, &time_slice),
868            MoneyPerFlow(0.0)
869        );
870    }
871
872    #[rstest]
873    fn get_levy_consumption_negative_coeff(
874        commodity_with_consumption_levy: Rc<Commodity>,
875        region_id: RegionID,
876        time_slice: TimeSliceID,
877    ) {
878        let flow = ProcessFlow {
879            commodity: commodity_with_consumption_levy,
880            coeff: FlowPerActivity(-1.0), // Negative coefficient means consumption
881            kind: FlowType::Fixed,
882            cost: MoneyPerFlow(0.0),
883        };
884
885        assert_eq!(
886            flow.get_levy(&region_id, 2020, &time_slice),
887            MoneyPerFlow(10.0)
888        );
889    }
890
891    #[rstest]
892    fn get_levy_production_positive_coeff(
893        commodity_with_production_levy: Rc<Commodity>,
894        region_id: RegionID,
895        time_slice: TimeSliceID,
896    ) {
897        let flow = ProcessFlow {
898            commodity: commodity_with_production_levy,
899            coeff: FlowPerActivity(1.0), // Positive coefficient means production
900            kind: FlowType::Fixed,
901            cost: MoneyPerFlow(0.0),
902        };
903
904        assert_eq!(
905            flow.get_levy(&region_id, 2020, &time_slice),
906            MoneyPerFlow(10.0)
907        );
908    }
909
910    #[rstest]
911    fn get_levy_production_negative_coeff(
912        commodity_with_production_levy: Rc<Commodity>,
913        region_id: RegionID,
914        time_slice: TimeSliceID,
915    ) {
916        let flow = ProcessFlow {
917            commodity: commodity_with_production_levy,
918            coeff: FlowPerActivity(-1.0), // Negative coefficient means consumption
919            kind: FlowType::Fixed,
920            cost: MoneyPerFlow(0.0),
921        };
922
923        assert_eq!(
924            flow.get_levy(&region_id, 2020, &time_slice),
925            MoneyPerFlow(0.0)
926        );
927    }
928
929    #[rstest]
930    fn get_total_cost_base_cost(
931        flow_with_cost: ProcessFlow,
932        region_id: RegionID,
933        time_slice: TimeSliceID,
934    ) {
935        assert_eq!(
936            flow_with_cost.get_total_cost_per_activity(&region_id, 2020, &time_slice),
937            MoneyPerActivity(5.0)
938        );
939    }
940
941    #[rstest]
942    fn get_total_cost_with_levy(
943        flow_with_cost_and_levy: ProcessFlow,
944        region_id: RegionID,
945        time_slice: TimeSliceID,
946    ) {
947        assert_eq!(
948            flow_with_cost_and_levy.get_total_cost_per_activity(&region_id, 2020, &time_slice),
949            MoneyPerActivity(15.0)
950        );
951    }
952
953    #[rstest]
954    fn get_total_cost_with_incentive(
955        flow_with_cost_and_incentive: ProcessFlow,
956        region_id: RegionID,
957        time_slice: TimeSliceID,
958    ) {
959        assert_eq!(
960            flow_with_cost_and_incentive.get_total_cost_per_activity(&region_id, 2020, &time_slice),
961            MoneyPerActivity(2.0)
962        );
963    }
964
965    #[rstest]
966    fn get_total_cost_negative_coeff(
967        mut flow_with_cost: ProcessFlow,
968        region_id: RegionID,
969        time_slice: TimeSliceID,
970    ) {
971        flow_with_cost.coeff = FlowPerActivity(-2.0);
972        assert_eq!(
973            flow_with_cost.get_total_cost_per_activity(&region_id, 2020, &time_slice),
974            MoneyPerActivity(10.0)
975        );
976    }
977
978    #[rstest]
979    fn get_total_cost_zero_coeff(
980        mut flow_with_cost: ProcessFlow,
981        region_id: RegionID,
982        time_slice: TimeSliceID,
983    ) {
984        flow_with_cost.coeff = FlowPerActivity(0.0);
985        assert_eq!(
986            flow_with_cost.get_total_cost_per_activity(&region_id, 2020, &time_slice),
987            MoneyPerActivity(0.0)
988        );
989    }
990
991    #[test]
992    fn is_input_and_is_output() {
993        let commodity = Rc::new(Commodity {
994            id: "test_commodity".into(),
995            description: "Test commodity".into(),
996            kind: CommodityType::ServiceDemand,
997            time_slice_level: TimeSliceLevel::Annual,
998            pricing_strategy: PricingStrategy::Shadow,
999            levies_prod: CommodityLevyMap::new(),
1000            levies_cons: CommodityLevyMap::new(),
1001            demand: DemandMap::new(),
1002            units: "PJ".into(),
1003        });
1004
1005        let flow_in = ProcessFlow {
1006            commodity: Rc::clone(&commodity),
1007            coeff: FlowPerActivity(-1.0),
1008            kind: FlowType::Fixed,
1009            cost: MoneyPerFlow(0.0),
1010        };
1011        let flow_out = ProcessFlow {
1012            commodity: Rc::clone(&commodity),
1013            coeff: FlowPerActivity(1.0),
1014            kind: FlowType::Fixed,
1015            cost: MoneyPerFlow(0.0),
1016        };
1017        let flow_zero = ProcessFlow {
1018            commodity: Rc::clone(&commodity),
1019            coeff: FlowPerActivity(0.0),
1020            kind: FlowType::Fixed,
1021            cost: MoneyPerFlow(0.0),
1022        };
1023
1024        assert!(flow_in.direction() == FlowDirection::Input);
1025        assert!(flow_out.direction() == FlowDirection::Output);
1026        assert!(flow_zero.direction() == FlowDirection::Zero);
1027    }
1028
1029    #[rstest]
1030    fn new_with_full_availability(time_slice_info2: TimeSliceInfo) {
1031        let limits = ActivityLimits::new_with_full_availability(&time_slice_info2);
1032
1033        // Each timeslice from the info should be present in the limits
1034        for (ts_id, ts_len) in time_slice_info2.iter() {
1035            let l = limits.get_limit_for_time_slice(ts_id);
1036            // Lower bound should be zero and upper bound equal to timeslice length
1037            assert_eq!(*l.start(), Dimensionless(0.0));
1038            assert_eq!(*l.end(), Dimensionless(ts_len.value()));
1039        }
1040
1041        // Annual limit should be 0..1
1042        let annual_limit = limits.get_limit(&TimeSliceSelection::Annual);
1043        assert_approx_eq!(Dimensionless, *annual_limit.start(), Dimensionless(0.0));
1044        assert_approx_eq!(Dimensionless, *annual_limit.end(), Dimensionless(1.0));
1045    }
1046
1047    #[rstest]
1048    fn new_from_limits_with_seasonal_limit_applied(time_slice_info2: TimeSliceInfo) {
1049        let mut limits = HashMap::new();
1050
1051        // Set a seasonal upper limit that is stricter than the sum of timeslices
1052        limits.insert(
1053            TimeSliceSelection::Season("winter".into()),
1054            Dimensionless(0.0)..=Dimensionless(0.01),
1055        );
1056
1057        let result = ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap();
1058
1059        // Each timeslice upper bound should be capped by the seasonal upper bound (0.01)
1060        for (ts_id, _ts_len) in time_slice_info2.iter() {
1061            let ts_limit = result.get_limit_for_time_slice(ts_id);
1062            assert_eq!(*ts_limit.end(), Dimensionless(0.01));
1063        }
1064
1065        // The seasonal limit should reflect the given bound
1066        let season_limit = result.get_limit(&TimeSliceSelection::Season("winter".into()));
1067        assert_eq!(*season_limit.end(), Dimensionless(0.01));
1068    }
1069
1070    #[rstest]
1071    fn new_from_limits_with_annual_limit_applied(time_slice_info2: TimeSliceInfo) {
1072        let mut limits = HashMap::new();
1073
1074        // Set an annual upper limit that is stricter than the sum of timeslices
1075        limits.insert(
1076            TimeSliceSelection::Annual,
1077            Dimensionless(0.0)..=Dimensionless(0.01),
1078        );
1079
1080        let result = ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap();
1081
1082        // Each timeslice upper bound should be capped by the annual upper bound (0.01)
1083        for (ts_id, _ts_len) in time_slice_info2.iter() {
1084            let ts_limit = result.get_limit_for_time_slice(ts_id);
1085            assert_eq!(*ts_limit.end(), Dimensionless(0.01));
1086        }
1087
1088        // The seasonal limit should be capped by the annual upper bound (0.01)
1089        let season_limit = result.get_limit(&TimeSliceSelection::Season("winter".into()));
1090        assert_eq!(*season_limit.end(), Dimensionless(0.01));
1091
1092        // The annual limit should reflect the given bound
1093        let annual_limit = result.get_limit(&TimeSliceSelection::Annual);
1094        assert_eq!(*annual_limit.end(), Dimensionless(0.01));
1095    }
1096
1097    #[rstest]
1098    fn new_from_limits_missing_timeslices_error(time_slice_info2: TimeSliceInfo) {
1099        let mut limits = HashMap::new();
1100
1101        // Add a single timeslice limit but do not provide limits for all timeslices
1102        let first_ts = time_slice_info2.iter().next().unwrap().0.clone();
1103        limits.insert(
1104            TimeSliceSelection::Single(first_ts),
1105            Dimensionless(0.0)..=Dimensionless(0.1),
1106        );
1107
1108        assert_error!(
1109            ActivityLimits::new_from_limits(&limits, &time_slice_info2),
1110            "Missing availability limits for time slices: [winter.night]. Please provide"
1111        );
1112    }
1113
1114    #[rstest]
1115    fn new_from_limits_incompatible_limits(time_slice_info2: TimeSliceInfo) {
1116        let mut limits = HashMap::new();
1117
1118        // Time slice limits capping activity to 0.1 in each ts
1119        for (ts_id, _ts_len) in time_slice_info2.iter() {
1120            limits.insert(
1121                TimeSliceSelection::Single(ts_id.clone()),
1122                Dimensionless(0.0)..=Dimensionless(0.1),
1123            );
1124        }
1125
1126        // Seasonal limit that is incompatible (lower limit above the sum of time slice upper limits)
1127        limits.insert(
1128            TimeSliceSelection::Season("winter".into()),
1129            Dimensionless(0.99)..=Dimensionless(1.0),
1130        );
1131
1132        assert_error!(
1133            ActivityLimits::new_from_limits(&limits, &time_slice_info2),
1134            "Availability limit for season winter clashes with time slice limits"
1135        );
1136    }
1137}