1use 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
21pub type ProcessMap = IndexMap<ProcessID, Arc<Process>>;
23
24pub type ProcessActivityLimitsMap = HashMap<(RegionID, u32), Arc<ActivityLimits>>;
26
27pub type ProcessParameterMap = HashMap<(RegionID, u32), Arc<ProcessParameter>>;
29
30pub type ProcessFlowsMap = HashMap<(RegionID, u32), Arc<IndexMap<CommodityID, ProcessFlow>>>;
34
35pub type ProcessInvestmentConstraintsMap =
37 HashMap<(RegionID, u32), Arc<ProcessInvestmentConstraint>>;
38
39#[derive(PartialEq, Debug, Clone)]
41pub struct Process {
42 pub id: ProcessID,
44 pub description: String,
46 pub years: RangeInclusive<u32>,
48 pub activity_limits: ProcessActivityLimitsMap,
50 pub flows: ProcessFlowsMap,
52 pub parameters: ProcessParameterMap,
54 pub regions: IndexSet<RegionID>,
56 pub primary_output: Option<CommodityID>,
58 pub capacity_to_activity: ActivityPerCapacity,
64 pub investment_constraints: ProcessInvestmentConstraintsMap,
66 pub tranche_size: Option<Capacity>,
73}
74
75impl Process {
76 pub fn active_for_year(&self, year: u32) -> bool {
78 self.years.contains(&year)
79 }
80
81 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 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 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#[derive(PartialEq, Debug, Clone)]
155pub struct ActivityLimits {
156 annual_limit: Option<RangeInclusive<Dimensionless>>,
158 seasonal_limits: IndexMap<Season, RangeInclusive<Dimensionless>>,
160 time_slice_limits: IndexMap<TimeSliceID, RangeInclusive<Dimensionless>>,
162}
163
164impl ActivityLimits {
165 pub fn new_with_full_availability(time_slice_info: &TimeSliceInfo) -> Self {
167 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 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 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 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 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 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 if let Some(limit) = limits.get(&TimeSliceSelection::Annual) {
259 result.add_annual_limit(limit.clone())?;
260 }
261
262 Ok(result)
263 }
264
265 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 fn add_seasonal_limit(
276 &mut self,
277 season: Season,
278 limit: RangeInclusive<Dimensionless>,
279 ) -> Result<()> {
280 let current_limit = self.get_limit_for_season(&season);
282
283 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 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 fn add_annual_limit(&mut self, limit: RangeInclusive<Dimensionless>) -> Result<()> {
303 let current_limit = self.get_limit_for_year();
305
306 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 if *limit.start() > *current_limit.start() || *limit.end() < *current_limit.end() {
317 self.annual_limit = Some(limit);
318 }
319
320 Ok(())
321 }
322
323 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 pub fn get_limit_for_time_slice(
337 &self,
338 time_slice: &TimeSliceID,
339 ) -> RangeInclusive<Dimensionless> {
340 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 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 fn get_limit_for_season(&self, season: &Season) -> RangeInclusive<Dimensionless> {
359 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 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 let Some(annual_limit) = &self.annual_limit {
378 upper = upper.min(*annual_limit.end());
379 }
380
381 lower..=upper
382 }
383
384 fn get_limit_for_year(&self) -> RangeInclusive<Dimensionless> {
386 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 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 pub fn iter_limits(
414 &self,
415 ) -> impl Iterator<Item = (TimeSliceSelection, &RangeInclusive<Dimensionless>)> {
416 let time_slice_limits = self
418 .time_slice_limits
419 .iter()
420 .map(|(ts_id, limit)| (TimeSliceSelection::Single(ts_id.clone()), limit));
421
422 let seasonal_limits = self
424 .seasonal_limits
425 .iter()
426 .map(|(season, limit)| (TimeSliceSelection::Season(season.clone()), limit));
427
428 let annual_limits = self
430 .annual_limit
431 .as_ref()
432 .map(|limit| (TimeSliceSelection::Annual, limit));
433
434 time_slice_limits
436 .chain(seasonal_limits)
437 .chain(annual_limits)
438 }
439}
440
441#[derive(PartialEq, Debug, Clone)]
443pub struct ProcessFlow {
444 pub commodity: Arc<Commodity>,
446 pub coeff: FlowPerActivity,
450 pub kind: FlowType,
452 pub cost: MoneyPerFlow,
457}
458
459impl ProcessFlow {
460 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 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 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 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#[derive(PartialEq, Default, Debug, Clone, Deserialize)]
514pub enum FlowType {
515 #[default]
517 #[serde(rename = "fixed")]
518 Fixed,
519 #[serde(rename = "flexible")]
522 Flexible,
523}
524
525#[derive(PartialEq, Debug)]
527pub enum FlowDirection {
528 Input,
530 Output,
532 Zero,
534}
535
536#[derive(PartialEq, Clone, Debug)]
538pub struct ProcessParameter {
539 pub capital_cost: MoneyPerCapacity,
541 pub fixed_operating_cost: MoneyPerCapacityPerYear,
543 pub variable_operating_cost: MoneyPerActivity,
545 pub lifetime: u32,
547 pub discount_rate: Dimensionless,
549}
550
551#[derive(PartialEq, Debug, Clone)]
556pub struct ProcessInvestmentConstraint {
557 pub addition_limit: Option<Capacity>,
560 pub total_capacity_limit: Option<Capacity>,
562}
563
564impl ProcessInvestmentConstraint {
565 pub fn get_addition_limit(&self) -> Option<Capacity> {
567 self.addition_limit
568 }
569
570 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 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 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 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 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(®ion_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(®ion_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(®ion_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(®ion_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(®ion_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), kind: FlowType::Fixed,
928 cost: MoneyPerFlow(0.0),
929 };
930
931 assert_eq!(
932 flow.get_levy(®ion_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), kind: FlowType::Fixed,
947 cost: MoneyPerFlow(0.0),
948 };
949
950 assert_eq!(
951 flow.get_levy(®ion_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), kind: FlowType::Fixed,
966 cost: MoneyPerFlow(0.0),
967 };
968
969 assert_eq!(
970 flow.get_levy(®ion_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), kind: FlowType::Fixed,
985 cost: MoneyPerFlow(0.0),
986 };
987
988 assert_eq!(
989 flow.get_levy(®ion_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(®ion_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(®ion_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(®ion_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(®ion_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(®ion_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 for (ts_id, ts_len) in time_slice_info2.iter() {
1101 let l = limits.get_limit_for_time_slice(ts_id);
1102 assert_eq!(*l.start(), Dimensionless(0.0));
1104 assert_eq!(*l.end(), Dimensionless(ts_len.value()));
1105 }
1106
1107 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 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 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 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 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 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 let season_limit = result.get_limit(&TimeSliceSelection::Season("winter".into()));
1156 assert_eq!(*season_limit.end(), Dimensionless(0.01));
1157
1158 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 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 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 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 ®ion_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(®ion_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(®ion_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(®ion_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(®ion_id, 2015, Dimensionless(0.5))
1267 .unwrap();
1268 assert_eq!(result, Capacity(50.0));
1269 }
1270}