Skip to main content

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