1use crate::agent::AgentID;
3use crate::commodity::{CommodityID, CommodityType};
4use crate::finance::annual_capital_cost;
5use crate::process::{
6 ActivityLimits, FlowDirection, Process, ProcessFlow, ProcessID, ProcessParameter,
7};
8use crate::region::RegionID;
9use crate::simulation::PriceMap;
10use crate::time_slice::{TimeSliceID, TimeSliceSelection};
11use crate::units::{
12 Activity, ActivityPerCapacity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity,
13 MoneyPerCapacity, MoneyPerFlow, Year,
14};
15use anyhow::{Context, Result, ensure};
16use indexmap::IndexMap;
17use log::debug;
18use map_macro::vec_deque;
19use serde::{Deserialize, Serialize};
20use std::cmp::Ordering;
21use std::collections::VecDeque;
22use std::hash::{Hash, Hasher};
23use std::ops::RangeInclusive;
24use std::rc::Rc;
25
26mod capacity;
27pub use capacity::AssetCapacity;
28mod pool;
29pub use pool::AssetPool;
30
31#[derive(
33 Clone,
34 Copy,
35 Debug,
36 derive_more::Display,
37 Eq,
38 Hash,
39 Ord,
40 PartialEq,
41 PartialOrd,
42 Deserialize,
43 Serialize,
44)]
45pub struct AssetID(u32);
46
47#[derive(PartialEq, Debug, Clone)]
49pub struct MothballEvent {
50 year: u32,
51 num_units: u32,
52}
53
54#[derive(Clone, Debug, PartialEq, strum::Display)]
64pub enum AssetState {
65 Commissioned {
67 id: AssetID,
69 agent_id: AgentID,
71 mothball_events: VecDeque<MothballEvent>,
76 },
77 Ready {
79 agent_id: AgentID,
81 commission_reason: &'static str,
83 },
84 Candidate,
86}
87
88#[derive(Clone)]
90pub struct Asset {
91 state: AssetState,
93 process: Rc<Process>,
95 activity_limits: Rc<ActivityLimits>,
97 flows: Rc<IndexMap<CommodityID, ProcessFlow>>,
99 process_parameter: Rc<ProcessParameter>,
101 region_id: RegionID,
103 capacity: AssetCapacity,
105 commission_year: u32,
107 max_decommission_year: u32,
109}
110
111impl Asset {
112 pub fn new_candidate(
114 process: Rc<Process>,
115 region_id: RegionID,
116 capacity: Capacity,
117 commission_year: u32,
118 ) -> Result<Self> {
119 let unit_size = process.unit_size;
120 Self::new_with_state(
121 AssetState::Candidate,
122 process,
123 region_id,
124 AssetCapacity::from_capacity(capacity, unit_size),
125 commission_year,
126 None,
127 )
128 }
129
130 pub fn new_candidate_for_dispatch(
136 process: Rc<Process>,
137 region_id: RegionID,
138 capacity: Capacity,
139 commission_year: u32,
140 ) -> Result<Self> {
141 Self::new_with_state(
142 AssetState::Candidate,
143 process,
144 region_id,
145 AssetCapacity::Continuous(capacity),
146 commission_year,
147 None,
148 )
149 }
150
151 pub fn new_candidate_from_commissioned(asset: &Asset) -> Self {
153 assert!(asset.is_commissioned(), "Asset must be commissioned");
154
155 Self {
156 state: AssetState::Candidate,
157 ..asset.clone()
158 }
159 }
160
161 #[cfg(test)]
166 pub fn new_ready(
167 agent_id: AgentID,
168 process: Rc<Process>,
169 region_id: RegionID,
170 capacity: Capacity,
171 commission_year: u32,
172 ) -> Result<Self> {
173 let unit_size = process.unit_size;
174 Self::new_with_state(
175 AssetState::Ready {
176 agent_id,
177 commission_reason: "selected",
178 },
179 process,
180 region_id,
181 AssetCapacity::from_capacity(capacity, unit_size),
182 commission_year,
183 None,
184 )
185 }
186
187 #[cfg(test)]
192 pub fn new_commissioned(
193 agent_id: AgentID,
194 process: Rc<Process>,
195 region_id: RegionID,
196 capacity: Capacity,
197 commission_year: u32,
198 ) -> Result<Self> {
199 let unit_size = process.unit_size;
200 Self::new_with_state(
201 AssetState::Commissioned {
202 id: AssetID(0),
203 agent_id,
204 mothball_events: vec_deque![],
205 },
206 process,
207 region_id,
208 AssetCapacity::from_capacity(capacity, unit_size),
209 commission_year,
210 None,
211 )
212 }
213
214 fn new_with_state(
216 state: AssetState,
217 process: Rc<Process>,
218 region_id: RegionID,
219 capacity: AssetCapacity,
220 commission_year: u32,
221 max_decommission_year: Option<u32>,
222 ) -> Result<Self> {
223 check_region_year_valid_for_process(&process, ®ion_id, commission_year)?;
224 ensure!(
225 capacity.total_capacity() >= Capacity(0.0),
226 "Capacity must be non-negative"
227 );
228
229 let key = (region_id.clone(), commission_year);
235 let activity_limits = process
236 .activity_limits
237 .get(&key)
238 .with_context(|| {
239 format!(
240 "No process availabilities supplied for process {} in region {} in year {}. \
241 You should update process_availabilities.csv.",
242 process.id, region_id, commission_year
243 )
244 })?
245 .clone();
246 let flows = process
247 .flows
248 .get(&key)
249 .with_context(|| {
250 format!(
251 "No commodity flows supplied for process {} in region {} in year {}. \
252 You should update process_flows.csv.",
253 process.id, region_id, commission_year
254 )
255 })?
256 .clone();
257 let process_parameter = process
258 .parameters
259 .get(&key)
260 .with_context(|| {
261 format!(
262 "No process parameters supplied for process {} in region {} in year {}. \
263 You should update process_parameters.csv.",
264 process.id, region_id, commission_year
265 )
266 })?
267 .clone();
268
269 let max_decommission_year =
270 max_decommission_year.unwrap_or(commission_year + process_parameter.lifetime);
271 ensure!(
272 max_decommission_year > commission_year,
273 "Max decommission year must be greater than commission year"
274 );
275
276 Ok(Self {
277 state,
278 process,
279 activity_limits,
280 flows,
281 process_parameter,
282 region_id,
283 capacity,
284 commission_year,
285 max_decommission_year,
286 })
287 }
288
289 pub fn state(&self) -> &AssetState {
291 &self.state
292 }
293
294 pub fn process_parameter(&self) -> &ProcessParameter {
296 &self.process_parameter
297 }
298
299 pub fn max_decommission_year(&self) -> u32 {
301 self.max_decommission_year
302 }
303
304 pub fn get_activity_per_capacity_limits(
306 &self,
307 time_slice: &TimeSliceID,
308 ) -> RangeInclusive<ActivityPerCapacity> {
309 let limits = &self.activity_limits.get_limit_for_time_slice(time_slice);
310 let cap2act = self.process.capacity_to_activity;
311 (cap2act * *limits.start())..=(cap2act * *limits.end())
312 }
313
314 pub fn get_activity_limits_for_selection(
316 &self,
317 time_slice_selection: &TimeSliceSelection,
318 ) -> RangeInclusive<Activity> {
319 let activity_per_capacity_limits = self.activity_limits.get_limit(time_slice_selection);
320 let cap2act = self.process.capacity_to_activity;
321 let max_activity = self.total_capacity() * cap2act;
322 let lb = max_activity * *activity_per_capacity_limits.start();
323 let ub = max_activity * *activity_per_capacity_limits.end();
324 lb..=ub
325 }
326
327 pub fn get_activity_per_capacity_limits_for_selection(
329 &self,
330 time_slice_selection: &TimeSliceSelection,
331 ) -> RangeInclusive<ActivityPerCapacity> {
332 let limits = self.activity_limits.get_limit(time_slice_selection);
333 let cap2act = self.process.capacity_to_activity;
334 (cap2act * *limits.start())..=(cap2act * *limits.end())
335 }
336
337 pub fn iter_activity_limits(
339 &self,
340 ) -> impl Iterator<Item = (TimeSliceSelection, RangeInclusive<Activity>)> + '_ {
341 let max_act = self.max_activity();
342 self.activity_limits
343 .iter_limits()
344 .map(move |(ts_sel, limit)| {
345 (
346 ts_sel,
347 (max_act * *limit.start())..=(max_act * *limit.end()),
348 )
349 })
350 }
351
352 pub fn iter_activity_per_capacity_limits(
354 &self,
355 ) -> impl Iterator<Item = (TimeSliceSelection, RangeInclusive<ActivityPerCapacity>)> + '_ {
356 let cap2act = self.process.capacity_to_activity;
357 self.activity_limits
358 .iter_limits()
359 .map(move |(ts_sel, limit)| {
360 (
361 ts_sel,
362 (cap2act * *limit.start())..=(cap2act * *limit.end()),
363 )
364 })
365 }
366
367 pub fn get_total_output_per_activity(&self) -> FlowPerActivity {
374 self.iter_output_flows().map(|flow| flow.coeff).sum()
375 }
376
377 pub fn get_operating_cost(&self, year: u32, time_slice: &TimeSliceID) -> MoneyPerActivity {
379 let flows_cost = self
381 .iter_flows()
382 .map(|flow| flow.get_total_cost_per_activity(&self.region_id, year, time_slice))
383 .sum();
384
385 self.process_parameter.variable_operating_cost + flows_cost
386 }
387
388 pub fn get_revenue_from_flows(
392 &self,
393 prices: &PriceMap,
394 time_slice: &TimeSliceID,
395 ) -> MoneyPerActivity {
396 self.get_revenue_from_flows_with_filter(prices, time_slice, |_| true)
397 }
398
399 pub fn get_revenue_from_flows_excluding_primary(
403 &self,
404 prices: &PriceMap,
405 time_slice: &TimeSliceID,
406 ) -> MoneyPerActivity {
407 let excluded_commodity = self.primary_output().map(|flow| &flow.commodity.id);
408
409 self.get_revenue_from_flows_with_filter(prices, time_slice, |flow| {
410 excluded_commodity.is_none_or(|commodity_id| commodity_id != &flow.commodity.id)
411 })
412 }
413
414 pub fn get_input_cost_from_prices(
418 &self,
419 prices: &PriceMap,
420 time_slice: &TimeSliceID,
421 ) -> MoneyPerActivity {
422 -self.get_revenue_from_flows_with_filter(prices, time_slice, |x| {
424 x.direction() == FlowDirection::Input
425 })
426 }
427
428 fn get_revenue_from_flows_with_filter<F>(
433 &self,
434 prices: &PriceMap,
435 time_slice: &TimeSliceID,
436 mut filter_for_flows: F,
437 ) -> MoneyPerActivity
438 where
439 F: FnMut(&ProcessFlow) -> bool,
440 {
441 self.iter_flows()
442 .filter(|flow| filter_for_flows(flow))
443 .map(|flow| {
444 flow.coeff
445 * prices
446 .get(&flow.commodity.id, &self.region_id, time_slice)
447 .unwrap_or(MoneyPerFlow(0.0))
448 })
449 .sum()
450 }
451
452 fn get_generic_activity_cost(
457 &self,
458 prices: &PriceMap,
459 year: u32,
460 time_slice: &TimeSliceID,
461 ) -> MoneyPerActivity {
462 let cost_of_inputs = self.get_input_cost_from_prices(prices, time_slice);
464
465 let excludes_sed_svd_output = |flow: &&ProcessFlow| {
467 !(flow.direction() == FlowDirection::Output
468 && matches!(
469 flow.commodity.kind,
470 CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
471 ))
472 };
473 let flow_costs = self
474 .iter_flows()
475 .filter(excludes_sed_svd_output)
476 .map(|flow| flow.get_total_cost_per_activity(&self.region_id, year, time_slice))
477 .sum();
478
479 cost_of_inputs + flow_costs + self.process_parameter.variable_operating_cost
480 }
481
482 pub fn iter_marginal_costs_with_filter<'a>(
490 &'a self,
491 prices: &'a PriceMap,
492 year: u32,
493 time_slice: &'a TimeSliceID,
494 filter: impl Fn(&CommodityID) -> bool + 'a,
495 ) -> Box<dyn Iterator<Item = (CommodityID, MoneyPerFlow)> + 'a> {
496 let mut output_flows_iter = self
498 .iter_output_flows()
499 .filter(move |flow| filter(&flow.commodity.id))
500 .peekable();
501
502 if output_flows_iter.peek().is_none() {
504 return Box::new(std::iter::empty::<(CommodityID, MoneyPerFlow)>());
505 }
506
507 let generic_activity_cost = self.get_generic_activity_cost(prices, year, time_slice);
512
513 let total_output_per_activity = self.get_total_output_per_activity();
518 assert!(total_output_per_activity > FlowPerActivity::EPSILON); let generic_cost_per_flow = generic_activity_cost / total_output_per_activity;
520
521 Box::new(output_flows_iter.map(move |flow| {
523 let commodity_specific_costs_per_flow =
525 flow.get_total_cost_per_flow(&self.region_id, year, time_slice);
526
527 let marginal_cost = generic_cost_per_flow + commodity_specific_costs_per_flow;
529 (flow.commodity.id.clone(), marginal_cost)
530 }))
531 }
532
533 pub fn iter_marginal_costs<'a>(
537 &'a self,
538 prices: &'a PriceMap,
539 year: u32,
540 time_slice: &'a TimeSliceID,
541 ) -> Box<dyn Iterator<Item = (CommodityID, MoneyPerFlow)> + 'a> {
542 self.iter_marginal_costs_with_filter(prices, year, time_slice, move |_| true)
543 }
544
545 pub fn get_annual_capital_cost_per_capacity(&self) -> MoneyPerCapacity {
547 let capital_cost = self.process_parameter.capital_cost;
548 let lifetime = self.process_parameter.lifetime;
549 let discount_rate = self.process_parameter.discount_rate;
550 annual_capital_cost(capital_cost, lifetime, discount_rate)
551 }
552
553 pub fn get_annual_fixed_costs_per_activity(
558 &self,
559 annual_activity: Activity,
560 ) -> MoneyPerActivity {
561 let annual_capital_cost_per_capacity = self.get_annual_capital_cost_per_capacity();
562 let annual_fixed_opex = self.process_parameter.fixed_operating_cost * Year(1.0);
563 let total_annual_fixed_costs =
564 (annual_capital_cost_per_capacity + annual_fixed_opex) * self.total_capacity();
565 assert!(
566 annual_activity > Activity::EPSILON,
567 "Cannot calculate annual fixed costs per activity for an asset with zero annual activity"
568 );
569 total_annual_fixed_costs / annual_activity
570 }
571
572 pub fn get_annual_fixed_costs_per_flow(&self, annual_activity: Activity) -> MoneyPerFlow {
577 let annual_fixed_costs_per_activity =
578 self.get_annual_fixed_costs_per_activity(annual_activity);
579 let total_output_per_activity = self.get_total_output_per_activity();
580 assert!(total_output_per_activity > FlowPerActivity::EPSILON); annual_fixed_costs_per_activity / total_output_per_activity
582 }
583
584 pub fn max_activity(&self) -> Activity {
586 self.total_capacity() * self.process.capacity_to_activity
587 }
588
589 pub fn get_flow(&self, commodity_id: &CommodityID) -> Option<&ProcessFlow> {
591 self.flows.get(commodity_id)
592 }
593
594 pub fn iter_flows(&self) -> impl Iterator<Item = &ProcessFlow> {
596 self.flows.values()
597 }
598
599 pub fn iter_output_flows(&self) -> impl Iterator<Item = &ProcessFlow> {
601 self.flows.values().filter(|flow| {
602 flow.direction() == FlowDirection::Output
603 && matches!(
604 flow.commodity.kind,
605 CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
606 )
607 })
608 }
609
610 pub fn primary_output(&self) -> Option<&ProcessFlow> {
612 self.process
613 .primary_output
614 .as_ref()
615 .map(|commodity_id| &self.flows[commodity_id])
616 }
617
618 pub fn primary_output_commodity(&self) -> Option<&CommodityID> {
620 self.process.primary_output.as_ref()
621 }
622
623 pub fn is_commissioned(&self) -> bool {
625 matches!(&self.state, AssetState::Commissioned { .. })
626 }
627
628 pub fn is_candidate(&self) -> bool {
630 matches!(&self.state, AssetState::Candidate)
631 }
632
633 pub fn commission_year(&self) -> u32 {
635 self.commission_year
636 }
637
638 pub fn region_id(&self) -> &RegionID {
640 &self.region_id
641 }
642
643 pub fn process(&self) -> &Process {
645 &self.process
646 }
647
648 pub fn process_id(&self) -> &ProcessID {
650 &self.process.id
651 }
652
653 pub fn id(&self) -> Option<AssetID> {
655 match &self.state {
656 AssetState::Commissioned { id, .. } => Some(*id),
657 _ => None,
658 }
659 }
660
661 pub fn is_divisible(&self) -> bool {
663 matches!(self.capacity, AssetCapacity::Discrete { .. })
664 }
665
666 pub fn agent_id(&self) -> Option<&AgentID> {
668 match &self.state {
669 AssetState::Commissioned { agent_id, .. } | AssetState::Ready { agent_id, .. } => {
670 Some(agent_id)
671 }
672 AssetState::Candidate => None,
673 }
674 }
675
676 pub fn capacity(&self) -> AssetCapacity {
678 self.capacity
679 }
680
681 pub fn total_capacity(&self) -> Capacity {
683 self.capacity().total_capacity()
684 }
685
686 pub fn set_capacity(&mut self, capacity: AssetCapacity) {
690 assert!(
691 capacity.total_capacity() >= Capacity(0.0),
692 "Capacity must be >= 0"
693 );
694 self.capacity().assert_same_type(capacity);
695 assert!(
696 self.get_num_mothballed_units() <= capacity.n_units().unwrap_or(1),
697 "Cannot set capacity to a smaller number of units than are currently mothballed"
698 );
699
700 self.capacity = capacity;
701 }
702
703 pub fn increase_capacity(&mut self, capacity: AssetCapacity) {
705 assert!(
706 capacity.total_capacity() > Capacity(0.0),
707 "Capacity increase must be positive"
708 );
709
710 self.capacity = self.capacity + capacity;
711 }
712
713 fn commission(&mut self, id: AssetID) {
722 let (agent_id, reason) = match &self.state {
723 AssetState::Ready {
724 agent_id,
725 commission_reason,
726 } => (agent_id, commission_reason),
727 state => panic!("Assets with state {state} cannot be commissioned"),
728 };
729 debug!(
730 "Commissioning '{}' asset (ID: {}, capacity: {}) for agent '{}' (reason: {})",
731 self.process_id(),
732 id,
733 self.total_capacity(),
734 agent_id,
735 reason
736 );
737 self.state = AssetState::Commissioned {
738 id,
739 agent_id: agent_id.clone(),
740 mothball_events: vec_deque![],
741 };
742 }
743
744 pub fn select_candidate_for_investment(&mut self, agent_id: AgentID) {
746 assert!(
747 self.is_candidate(),
748 "select_candidate_for_investment can only be called on Candidate assets"
749 );
750 check_capacity_valid_for_asset(self.total_capacity()).unwrap();
751 self.state = AssetState::Ready {
752 agent_id,
753 commission_reason: "selected",
754 };
755 }
756
757 fn get_mothball_events(&self) -> Option<&VecDeque<MothballEvent>> {
759 match &self.state {
760 AssetState::Commissioned {
761 mothball_events, ..
762 } => Some(mothball_events),
763 _ => None,
764 }
765 }
766
767 fn get_mothball_events_mut(&mut self) -> Option<&mut VecDeque<MothballEvent>> {
769 match &mut self.state {
770 AssetState::Commissioned {
771 mothball_events, ..
772 } => Some(mothball_events),
773 _ => None,
774 }
775 }
776
777 pub fn has_any_mothballed_units(&self) -> bool {
779 self.get_mothball_events()
780 .is_some_and(|events| !events.is_empty())
781 }
782
783 pub fn get_num_mothballed_units(&self) -> u32 {
787 let Some(events) = self.get_mothball_events() else {
788 return 0;
789 };
790
791 events.iter().map(|event| event.num_units).sum()
792 }
793
794 pub fn get_num_nonmothballed_units(&self) -> u32 {
798 self.num_units() - self.get_num_mothballed_units()
799 }
800
801 pub fn num_units(&self) -> u32 {
805 self.capacity().n_units().unwrap_or(1)
806 }
807
808 pub fn unit_size(&self) -> Option<Capacity> {
810 match self.capacity() {
811 AssetCapacity::Discrete(_, size) => Some(size),
812 AssetCapacity::Continuous(_) => None,
813 }
814 }
815
816 pub fn max_installable_capacity(
825 &self,
826 commodity_portion: Dimensionless,
827 ) -> Option<AssetCapacity> {
828 assert!(
829 !self.is_commissioned(),
830 "max_installable_capacity can only be called on uncommissioned assets"
831 );
832 assert!(
833 commodity_portion >= Dimensionless(0.0) && commodity_portion <= Dimensionless(1.0),
834 "commodity_portion must be between 0 and 1 inclusive"
835 );
836
837 self.process
838 .investment_constraints
839 .get(&(self.region_id.clone(), self.commission_year))
840 .and_then(|c| c.get_addition_limit().map(|l| l * commodity_portion))
841 .map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size()))
842 }
843}
844
845#[allow(clippy::missing_fields_in_debug)]
846impl std::fmt::Debug for Asset {
847 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848 f.debug_struct("Asset")
849 .field("state", &self.state)
850 .field("process_id", &self.process_id())
851 .field("region_id", &self.region_id)
852 .field("capacity", &self.total_capacity())
853 .field("commission_year", &self.commission_year)
854 .finish()
855 }
856}
857
858pub fn check_region_year_valid_for_process(
860 process: &Process,
861 region_id: &RegionID,
862 year: u32,
863) -> Result<()> {
864 ensure!(
865 process.regions.contains(region_id),
866 "Process {} does not operate in region {}",
867 process.id,
868 region_id
869 );
870 ensure!(
871 process.active_for_year(year),
872 "Process {} does not operate in the year {}",
873 process.id,
874 year
875 );
876 Ok(())
877}
878
879#[derive(Clone, Debug, PartialEq, derive_more::Deref, derive_more::Into)]
881pub struct UserAsset(#[deref(forward)] AssetRef);
882
883impl UserAsset {
884 pub fn new(
886 agent_id: AgentID,
887 process: Rc<Process>,
888 region_id: RegionID,
889 capacity: Capacity,
890 commission_year: u32,
891 max_decommission_year: Option<u32>,
892 ) -> Result<Self> {
893 check_capacity_valid_for_asset(capacity)?;
894 let unit_size = process.unit_size;
895 let asset = Asset::new_with_state(
896 AssetState::Ready {
897 agent_id,
898 commission_reason: "user input",
899 },
900 process,
901 region_id,
902 AssetCapacity::from_capacity(capacity, unit_size),
903 commission_year,
904 max_decommission_year,
905 )?;
906
907 Ok(Self(asset.into()))
908 }
909}
910
911#[cfg(test)]
912impl From<Asset> for UserAsset {
913 fn from(asset: Asset) -> Self {
914 assert!(
915 matches!(asset.state, AssetState::Ready { .. }),
916 "User assets must be in Ready state"
917 );
918 Self(asset.into())
919 }
920}
921
922pub fn check_capacity_valid_for_asset(capacity: Capacity) -> Result<()> {
924 ensure!(
925 capacity.is_finite() && capacity > Capacity(0.0),
926 "Capacity must be a finite, positive number"
927 );
928 Ok(())
929}
930
931fn log_decommissioning(asset: &Asset, num_units: u32, reason: &str) {
933 let (id, agent_id) = match &asset.state {
934 AssetState::Commissioned { id, agent_id, .. } => (*id, agent_id.clone()),
935 _ => panic!("Cannot decommission an asset that hasn't been commissioned"),
936 };
937 debug!(
938 "Decommissioning {}/{} units of '{}' asset (ID: {}) for agent '{}' (reason: {})",
939 num_units,
940 asset.num_units(),
941 asset.process_id(),
942 id,
943 agent_id,
944 reason
945 );
946}
947
948#[derive(Clone, Debug, derive_more::Deref, derive_more::From, derive_more::Into)]
954pub struct AssetRef(#[deref(forward)] Rc<Asset>);
955
956impl AssetRef {
957 pub fn make_mut(&mut self) -> &mut Asset {
959 Rc::make_mut(&mut self.0)
960 }
961
962 fn get_asset_cmp(&self) -> AssetCmp<'_> {
964 if let Some(id) = self.id() {
965 AssetCmp::WithID(id)
966 } else {
967 AssetCmp::WithoutID((
968 self.process_id(),
969 self.region_id(),
970 self.commission_year,
971 self.agent_id(),
972 ))
973 }
974 }
975
976 pub fn with_subset_of_units(self, new_num_units: u32) -> Self {
987 if new_num_units == self.num_units() {
988 return self;
989 }
990
991 assert!(new_num_units > 0, "Cannot make an asset with zero units");
992
993 let (max_num_units, unit_size) = match self.capacity() {
994 AssetCapacity::Discrete(max_num_units, unit_size) => (max_num_units, unit_size),
995 AssetCapacity::Continuous(_) => {
996 panic!("Non-divisible assets can only have one unit");
997 }
998 };
999
1000 assert!(
1001 new_num_units <= max_num_units,
1002 "Cannot make an asset with more units than original"
1003 );
1004
1005 let new_num_mothballed = new_num_units.saturating_sub(self.get_num_nonmothballed_units());
1010 let mut asset = self.with_mothballed_units(new_num_mothballed, None);
1011 asset
1012 .make_mut()
1013 .set_capacity(AssetCapacity::Discrete(new_num_units, unit_size));
1014 asset
1015 }
1016
1017 fn decommission(self, reason: &str) {
1019 log_decommissioning(&self, self.num_units(), reason);
1020 }
1021
1022 fn with_decommission_mothballed(self, year: u32, mothball_years: u32) -> Option<Self> {
1026 let events = self
1027 .get_mothball_events()
1028 .expect("Can only decommission mothballed units in commissioned assets");
1029
1030 let units_to_remove: u32 = events
1032 .iter()
1033 .take_while(|event| event.year <= year.saturating_sub(mothball_years))
1034 .map(|event| event.num_units)
1035 .sum();
1036 if units_to_remove == 0 {
1037 return Some(self);
1039 }
1040
1041 let reason = format!(
1042 "The asset has not been used for the set mothball years ({mothball_years} years)."
1043 );
1044 let new_num_units = self.num_units() - units_to_remove;
1045 if new_num_units == 0 {
1046 self.decommission(&reason);
1047 return None;
1048 }
1049
1050 log_decommissioning(&self, units_to_remove, &reason);
1053 Some(self.with_subset_of_units(new_num_units))
1054 }
1055
1056 pub fn with_mothballed_units(mut self, num_units: u32, year: Option<u32>) -> Self {
1066 if num_units == 0 {
1067 return self.with_no_mothballed_units();
1069 }
1070
1071 let num_already_mothballed = self.get_num_mothballed_units();
1072 if num_units == num_already_mothballed {
1073 return self;
1075 }
1076
1077 assert!(
1078 num_units <= self.num_units(),
1079 "Cannot mothball more units than asset represents"
1080 );
1081
1082 let events = self.make_mut().get_mothball_events_mut().expect(
1084 "Cannot change number of mothballed units for an asset that hasn't been commissioned",
1085 );
1086 if num_units < num_already_mothballed {
1087 let mut remaining = num_already_mothballed - num_units;
1090 while remaining > 0
1091 && let Some(event) = events.front_mut()
1092 {
1093 let to_remove = event.num_units.min(remaining);
1094 event.num_units -= to_remove;
1095 remaining -= to_remove;
1096 if event.num_units == 0 {
1097 events.pop_front();
1098 }
1099 }
1100 } else {
1101 let year =
1102 year.expect("Cannot increase number of mothballed units without supplying year");
1103
1104 if let Some(event) = events.back() {
1105 assert!(
1108 event.year <= year,
1109 "Attempting to mothball units in a year in the past"
1110 );
1111 }
1112
1113 events.push_back(MothballEvent {
1115 year,
1116 num_units: num_units - num_already_mothballed,
1117 });
1118 }
1119
1120 self
1121 }
1122
1123 pub fn with_no_mothballed_units(mut self) -> Self {
1127 if self.has_any_mothballed_units() {
1128 self.make_mut().get_mothball_events_mut().unwrap().clear();
1130 }
1131
1132 self
1133 }
1134}
1135
1136impl From<Asset> for AssetRef {
1137 fn from(value: Asset) -> Self {
1138 Self::from(Rc::new(value))
1139 }
1140}
1141
1142impl Eq for AssetRef {}
1143
1144impl PartialEq for AssetRef {
1145 fn eq(&self, other: &Self) -> bool {
1146 self.get_asset_cmp() == other.get_asset_cmp()
1147 }
1148}
1149
1150impl Ord for AssetRef {
1151 fn cmp(&self, other: &Self) -> Ordering {
1152 self.get_asset_cmp().cmp(&other.get_asset_cmp())
1153 }
1154}
1155
1156impl PartialOrd for AssetRef {
1157 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1158 Some(self.cmp(other))
1159 }
1160}
1161
1162impl Hash for AssetRef {
1163 fn hash<H: Hasher>(&self, state: &mut H) {
1164 self.get_asset_cmp().hash(state);
1165 }
1166}
1167
1168#[derive(PartialEq, PartialOrd, Eq, Ord, Hash)]
1174enum AssetCmp<'a> {
1175 WithID(AssetID),
1176 WithoutID((&'a ProcessID, &'a RegionID, u32, Option<&'a AgentID>)),
1177}
1178
1179pub trait AssetIterator<'a>: Iterator<Item = &'a AssetRef> + Sized
1181where
1182 Self: 'a,
1183{
1184 fn filter_agent(self, agent_id: &'a AgentID) -> impl Iterator<Item = &'a AssetRef> + 'a {
1186 self.filter(move |asset| asset.agent_id() == Some(agent_id))
1187 }
1188
1189 fn filter_primary_producers_of(
1191 self,
1192 commodity_id: &'a CommodityID,
1193 ) -> impl Iterator<Item = &'a AssetRef> + 'a {
1194 self.filter(move |asset| {
1195 asset
1196 .primary_output()
1197 .is_some_and(|flow| &flow.commodity.id == commodity_id)
1198 })
1199 }
1200
1201 fn filter_region(self, region_id: &'a RegionID) -> impl Iterator<Item = &'a AssetRef> + 'a {
1203 self.filter(move |asset| asset.region_id == *region_id)
1204 }
1205
1206 fn flows_for_commodity(
1208 self,
1209 commodity_id: &'a CommodityID,
1210 ) -> impl Iterator<Item = (&'a AssetRef, &'a ProcessFlow)> + 'a {
1211 self.filter_map(|asset| Some((asset, asset.get_flow(commodity_id)?)))
1212 }
1213}
1214
1215impl<'a, I> AssetIterator<'a> for I where I: Iterator<Item = &'a AssetRef> + Sized + 'a {}
1216
1217#[cfg(test)]
1218mod tests {
1219 use super::*;
1220 use crate::commodity::Commodity;
1221 use crate::fixture::{
1222 agent_id, assert_error, assert_patched_runs_ok_simple, assert_validate_fails_with_simple,
1223 asset, asset_divisible, process, process_activity_limits_map, process_flows_map, region_id,
1224 svd_commodity, time_slice, time_slice_info,
1225 };
1226 use crate::patch::FilePatch;
1227 use crate::process::{FlowType, Process, ProcessFlow};
1228 use crate::region::RegionID;
1229 use crate::time_slice::{TimeSliceID, TimeSliceInfo};
1230 use crate::units::{
1231 ActivityPerCapacity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity,
1232 MoneyPerFlow,
1233 };
1234 use float_cmp::assert_approx_eq;
1235 use indexmap::indexmap;
1236 use itertools::assert_equal;
1237 use rstest::{fixture, rstest};
1238 use std::rc::Rc;
1239
1240 #[fixture]
1242 fn commissioned_divisible(mut asset_divisible: Asset) -> AssetRef {
1243 asset_divisible.commission(AssetID(0));
1244 assert_eq!(asset_divisible.num_units(), 3);
1245 AssetRef::from(asset_divisible)
1246 }
1247
1248 #[rstest]
1249 fn get_input_cost_from_prices_works(
1250 region_id: RegionID,
1251 svd_commodity: Commodity,
1252 mut process: Process,
1253 time_slice: TimeSliceID,
1254 ) {
1255 let commodity_rc = Rc::new(svd_commodity);
1257 let process_flow = ProcessFlow {
1258 commodity: Rc::clone(&commodity_rc),
1259 coeff: FlowPerActivity(-2.0), kind: FlowType::Fixed,
1261 cost: MoneyPerFlow(0.0),
1262 };
1263 let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
1264 let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
1265 process.flows = process_flows_map;
1266
1267 let asset =
1269 Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(1.0), 2020).unwrap();
1270
1271 let mut input_prices = PriceMap::default();
1273 input_prices.insert(&commodity_rc.id, ®ion_id, &time_slice, MoneyPerFlow(3.0));
1274
1275 let cost = asset.get_input_cost_from_prices(&input_prices, &time_slice);
1277 assert_approx_eq!(MoneyPerActivity, cost, MoneyPerActivity(6.0));
1279 }
1280
1281 #[fixture]
1282 fn process_with_activity_limits(
1283 mut process: Process,
1284 time_slice_info: TimeSliceInfo,
1285 time_slice: TimeSliceID,
1286 ) -> Process {
1287 let mut activity_limits = ActivityLimits::new_with_full_availability(&time_slice_info);
1289 activity_limits.add_time_slice_limit(time_slice, Dimensionless(0.1)..=Dimensionless(0.5));
1290 process.activity_limits =
1291 process_activity_limits_map(process.regions.clone(), activity_limits);
1292
1293 process.capacity_to_activity = ActivityPerCapacity(2.0);
1295 process
1296 }
1297
1298 #[fixture]
1299 fn asset_with_activity_limits(process_with_activity_limits: Process) -> Asset {
1300 Asset::new_ready(
1301 "agent1".into(),
1302 Rc::new(process_with_activity_limits),
1303 "GBR".into(),
1304 Capacity(2.0),
1305 2010,
1306 )
1307 .unwrap()
1308 }
1309
1310 #[rstest]
1311 fn asset_get_activity_per_capacity_limits(
1312 asset_with_activity_limits: Asset,
1313 time_slice: TimeSliceID,
1314 ) {
1315 assert_eq!(
1317 asset_with_activity_limits.get_activity_per_capacity_limits(&time_slice),
1318 ActivityPerCapacity(0.2)..=ActivityPerCapacity(1.0)
1319 );
1320 }
1321
1322 #[rstest]
1323 #[case(Capacity(0.01))]
1324 #[case(Capacity(0.5))]
1325 #[case(Capacity(1.0))]
1326 #[case(Capacity(100.0))]
1327 fn user_asset_new_valid(
1328 agent_id: AgentID,
1329 process: Process,
1330 region_id: RegionID,
1331 #[case] capacity: Capacity,
1332 ) {
1333 let asset =
1334 UserAsset::new(agent_id, process.into(), region_id, capacity, 2015, None).unwrap();
1335 assert!(asset.id().is_none());
1336 }
1337
1338 #[rstest]
1339 #[case(Capacity(0.0))]
1340 #[case(Capacity(-0.01))]
1341 #[case(Capacity(-1.0))]
1342 #[case(Capacity(f64::NAN))]
1343 #[case(Capacity(f64::INFINITY))]
1344 #[case(Capacity(f64::NEG_INFINITY))]
1345 fn user_asset_new_invalid_capacity(
1346 agent_id: AgentID,
1347 process: Process,
1348 region_id: RegionID,
1349 #[case] capacity: Capacity,
1350 ) {
1351 assert_error!(
1352 UserAsset::new(agent_id, process.into(), region_id, capacity, 2015, None),
1353 "Capacity must be a finite, positive number"
1354 );
1355 }
1356
1357 #[rstest]
1358 fn user_asset_new_invalid_commission_year(
1359 agent_id: AgentID,
1360 process: Process,
1361 region_id: RegionID,
1362 ) {
1363 assert_error!(
1364 UserAsset::new(
1365 agent_id,
1366 process.into(),
1367 region_id,
1368 Capacity(1.0),
1369 2007,
1370 None
1371 ),
1372 "Process process1 does not operate in the year 2007"
1373 );
1374 }
1375
1376 #[rstest]
1377 fn user_asset_new_invalid_region(agent_id: AgentID, process: Process) {
1378 let region_id = RegionID("FRA".into());
1379 assert_error!(
1380 UserAsset::new(
1381 agent_id,
1382 process.into(),
1383 region_id,
1384 Capacity(1.0),
1385 2015,
1386 None
1387 ),
1388 "Process process1 does not operate in region FRA"
1389 );
1390 }
1391
1392 #[rstest]
1393 #[case::subset(2, false)]
1394 #[case::all_all_units(3, true)]
1395 fn with_subset_of_units(
1396 asset_divisible: Asset,
1397 #[case] num_units: u32,
1398 #[case] expect_same_asset: bool,
1399 ) {
1400 let asset = AssetRef::from(asset_divisible);
1401 let asset_subset = asset.clone().with_subset_of_units(num_units);
1402
1403 assert_eq!(
1404 asset_subset.capacity(),
1405 AssetCapacity::Discrete(num_units, Capacity(4.0))
1406 );
1407 assert_eq!(asset_subset.capacity().n_units(), Some(num_units));
1408 assert_eq!(asset_subset.id(), asset.id());
1409 assert_eq!(asset_subset.agent_id(), asset.agent_id());
1410 assert_eq!(Rc::ptr_eq(&asset_subset.0, &asset.0), expect_same_asset);
1411 assert_eq!(asset.capacity(), AssetCapacity::Discrete(3, Capacity(4.0)));
1412 }
1413
1414 #[rstest]
1415 fn with_subset_of_units_non_divisible_asset(asset: Asset) {
1416 let asset = AssetRef::from(asset);
1417 assert!(Rc::ptr_eq(
1418 &asset.0,
1419 &asset.clone().with_subset_of_units(1).0
1420 ));
1421 }
1422
1423 #[rstest]
1424 #[should_panic(expected = "Non-divisible assets can only have one unit")]
1425 fn with_subset_of_units_panics_for_non_divisible_asset(asset: Asset) {
1426 AssetRef::from(asset).with_subset_of_units(2);
1427 }
1428
1429 #[rstest]
1430 #[should_panic(expected = "Cannot make an asset with zero units")]
1431 fn with_subset_of_units_panics_for_zero_units(commissioned_divisible: AssetRef) {
1432 commissioned_divisible.with_subset_of_units(0);
1433 }
1434
1435 #[rstest]
1436 #[should_panic(expected = "Cannot make an asset with more units than original")]
1437 fn with_subset_of_units_panics_for_too_many_units(commissioned_divisible: AssetRef) {
1438 commissioned_divisible.with_subset_of_units(4);
1439 }
1440
1441 #[rstest]
1442 fn asset_commission(process: Process) {
1443 let mut asset = Asset::new_ready(
1445 "agent1".into(),
1446 process.into(),
1447 "GBR".into(),
1448 Capacity(1.0),
1449 2020,
1450 )
1451 .unwrap();
1452 asset.commission(AssetID(2));
1453 assert!(asset.is_commissioned());
1454 assert_eq!(asset.id(), Some(AssetID(2)));
1455 }
1456
1457 #[rstest]
1458 #[should_panic(expected = "Assets with state Candidate cannot be commissioned")]
1459 fn commission_wrong_states(process: Process) {
1460 let mut asset =
1461 Asset::new_candidate(process.into(), "GBR".into(), Capacity(1.0), 2020).unwrap();
1462 asset.commission(AssetID(1));
1463 }
1464
1465 #[test]
1466 fn commission_year_before_time_horizon() {
1467 let processes_patch = FilePatch::new("processes.csv")
1468 .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,")
1469 .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,");
1470
1471 let patches = vec![
1474 processes_patch.clone(),
1475 FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,1980"),
1476 ];
1477 assert_patched_runs_ok_simple!(patches);
1478
1479 let patches = vec![
1481 processes_patch,
1482 FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,1970"),
1483 ];
1484 assert_validate_fails_with_simple!(
1485 patches,
1486 "Agent A0_GEX has asset with commission year 1970, not within process GASDRV commission years: 1980..=2040"
1487 );
1488 }
1489
1490 #[test]
1491 fn commission_year_after_time_horizon() {
1492 let processes_patch = FilePatch::new("processes.csv")
1493 .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,")
1494 .with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0,");
1495
1496 let patches = vec![
1498 processes_patch.clone(),
1499 FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,2050"),
1500 ];
1501 assert_patched_runs_ok_simple!(patches);
1502
1503 let patches = vec![
1505 processes_patch,
1506 FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,2060"),
1507 ];
1508 assert_validate_fails_with_simple!(
1509 patches,
1510 "Agent A0_GEX has asset with commission year 2060, not within process GASDRV commission years: 2020..=2050"
1511 );
1512 }
1513
1514 #[rstest]
1515 fn max_installable_capacity(mut process: Process, region_id: RegionID) {
1516 process.investment_constraints.insert(
1518 (region_id.clone(), 2015),
1519 Rc::new(crate::process::ProcessInvestmentConstraint {
1520 addition_limit: Some(Capacity(3.0)),
1521 }),
1522 );
1523 let process_rc = Rc::new(process);
1524
1525 let asset =
1527 Asset::new_candidate(process_rc.clone(), region_id.clone(), Capacity(1.0), 2015)
1528 .unwrap();
1529
1530 let result = asset.max_installable_capacity(Dimensionless(0.5));
1532 assert_eq!(result, Some(AssetCapacity::Continuous(Capacity(1.5))));
1533 }
1534
1535 #[rstest]
1536 #[case::none(0)]
1537 #[case::some(2)]
1538 #[case::all(3)]
1539 fn mothball_unit_counts(commissioned_divisible: AssetRef, #[case] num_mothballed: u32) {
1540 assert_eq!(commissioned_divisible.num_units(), 3);
1541 let asset = commissioned_divisible.with_mothballed_units(num_mothballed, Some(2020));
1542 assert_eq!(asset.get_num_mothballed_units(), num_mothballed);
1543 assert_eq!(asset.get_num_nonmothballed_units(), 3 - num_mothballed);
1544 assert_eq!(asset.has_any_mothballed_units(), num_mothballed > 0);
1545 }
1546
1547 #[rstest]
1548 fn mothball_counts_non_commissioned(asset: Asset, process: Process) {
1549 let ready = AssetRef::from(asset);
1551 let candidate = AssetRef::from(
1552 Asset::new_candidate(process.into(), "GBR".into(), Capacity(1.0), 2020).unwrap(),
1553 );
1554 for asset in [ready, candidate] {
1555 assert!(!asset.has_any_mothballed_units());
1556 assert_eq!(asset.get_num_mothballed_units(), 0);
1557 assert_eq!(asset.get_num_nonmothballed_units(), asset.num_units());
1558 }
1559 }
1560
1561 #[rstest]
1562 fn with_mothballed_units_accumulates_events(commissioned_divisible: AssetRef) {
1563 let asset = commissioned_divisible.with_mothballed_units(1, Some(2020));
1565 assert_equal(
1566 asset.get_mothball_events().unwrap().iter(),
1567 &[MothballEvent {
1568 year: 2020,
1569 num_units: 1,
1570 }],
1571 );
1572
1573 let asset = asset.with_mothballed_units(2, Some(2022));
1575 assert_equal(
1576 asset.get_mothball_events().unwrap().iter(),
1577 &[
1578 MothballEvent {
1579 year: 2020,
1580 num_units: 1,
1581 },
1582 MothballEvent {
1583 year: 2022,
1584 num_units: 1,
1585 },
1586 ],
1587 );
1588 }
1589
1590 #[rstest]
1591 fn with_mothballed_units_decrease_removes_oldest_first(commissioned_divisible: AssetRef) {
1592 let asset = commissioned_divisible
1594 .with_mothballed_units(1, Some(2020))
1595 .with_mothballed_units(3, Some(2022));
1596 assert_equal(
1597 asset.get_mothball_events().unwrap().iter(),
1598 &[
1599 MothballEvent {
1600 year: 2020,
1601 num_units: 1,
1602 },
1603 MothballEvent {
1604 year: 2022,
1605 num_units: 2,
1606 },
1607 ],
1608 );
1609
1610 let asset = asset.with_mothballed_units(1, None);
1613 assert_eq!(asset.get_num_mothballed_units(), 1);
1614 assert_equal(
1615 asset.get_mothball_events().unwrap().iter(),
1616 &[MothballEvent {
1617 year: 2022,
1618 num_units: 1,
1619 }],
1620 );
1621 }
1622
1623 #[rstest]
1624 fn with_mothballed_units_noop_returns_same_rc(commissioned_divisible: AssetRef) {
1625 let asset = commissioned_divisible.with_mothballed_units(2, Some(2020));
1626 let same = asset.clone().with_mothballed_units(2, Some(2099));
1628 assert!(Rc::ptr_eq(&asset.0, &same.0));
1629 }
1630
1631 #[rstest]
1632 fn with_mothballed_units_zero_unmothballs(commissioned_divisible: AssetRef) {
1633 let asset = commissioned_divisible.with_mothballed_units(2, Some(2020));
1634 assert!(asset.has_any_mothballed_units());
1635
1636 let asset = asset.with_mothballed_units(0, None);
1637 assert!(!asset.has_any_mothballed_units());
1638 assert_eq!(asset.get_num_mothballed_units(), 0);
1639 }
1640
1641 #[rstest]
1642 #[should_panic(expected = "Cannot mothball more units than asset represents")]
1643 fn with_mothballed_units_panics_for_too_many_units(commissioned_divisible: AssetRef) {
1644 commissioned_divisible.with_mothballed_units(4, Some(2020));
1645 }
1646
1647 #[rstest]
1648 #[should_panic(
1649 expected = "Cannot change number of mothballed units for an asset that hasn't been commissioned"
1650 )]
1651 fn with_mothballed_units_panics_for_non_commissioned(asset: Asset) {
1652 AssetRef::from(asset).with_mothballed_units(1, Some(2020));
1653 }
1654
1655 #[rstest]
1656 #[should_panic(expected = "Cannot increase number of mothballed units without supplying year")]
1657 fn with_mothballed_units_panics_when_increasing_without_year(commissioned_divisible: AssetRef) {
1658 commissioned_divisible.with_mothballed_units(1, None);
1659 }
1660
1661 #[rstest]
1662 #[should_panic(expected = "Attempting to mothball units in a year in the past")]
1663 fn with_mothballed_units_panics_when_mothballing_in_the_past(commissioned_divisible: AssetRef) {
1664 commissioned_divisible
1667 .with_mothballed_units(1, Some(2020))
1668 .with_mothballed_units(2, Some(2019));
1669 }
1670
1671 #[rstest]
1672 fn with_no_mothballed_units_clears_events(commissioned_divisible: AssetRef) {
1673 let asset = commissioned_divisible.with_mothballed_units(2, Some(2020));
1674 let asset = asset.with_no_mothballed_units();
1675 assert!(!asset.has_any_mothballed_units());
1676 assert_eq!(asset.get_num_mothballed_units(), 0);
1677 }
1678
1679 #[rstest]
1680 fn with_no_mothballed_units_noop_returns_same_rc(commissioned_divisible: AssetRef) {
1681 let asset = commissioned_divisible;
1683 let same = asset.clone().with_no_mothballed_units();
1684 assert!(Rc::ptr_eq(&asset.0, &same.0));
1685 }
1686
1687 #[rstest]
1688 fn with_subset_of_units_caps_mothballed(commissioned_divisible: AssetRef) {
1689 let asset = commissioned_divisible.with_mothballed_units(3, Some(2020));
1691 assert_eq!(asset.get_num_mothballed_units(), 3);
1692
1693 let subset = asset.with_subset_of_units(2);
1695 assert_eq!(subset.num_units(), 2);
1696 assert_eq!(subset.get_num_mothballed_units(), 2);
1697 }
1698
1699 #[rstest]
1700 fn with_decommission_mothballed_nothing_old_enough(commissioned_divisible: AssetRef) {
1701 let asset = commissioned_divisible.with_mothballed_units(1, Some(2020));
1702 let result = asset
1704 .clone()
1705 .with_decommission_mothballed(2025, 20)
1706 .unwrap();
1707 assert!(Rc::ptr_eq(&asset.0, &result.0));
1708 }
1709
1710 #[rstest]
1711 fn with_decommission_mothballed_partial(commissioned_divisible: AssetRef) {
1712 let asset = commissioned_divisible
1714 .with_mothballed_units(1, Some(2010))
1715 .with_mothballed_units(2, Some(2020));
1716
1717 let result = asset.with_decommission_mothballed(2025, 10).unwrap();
1719 assert_eq!(result.num_units(), 2);
1720 assert_eq!(result.get_num_mothballed_units(), 1);
1721 assert_equal(
1722 result.get_mothball_events().unwrap().iter(),
1723 &[MothballEvent {
1724 year: 2020,
1725 num_units: 1,
1726 }],
1727 );
1728 }
1729
1730 #[rstest]
1731 fn with_decommission_mothballed_all(commissioned_divisible: AssetRef) {
1732 let asset = commissioned_divisible.with_mothballed_units(3, Some(2010));
1734 assert!(asset.with_decommission_mothballed(2025, 10).is_none());
1735 }
1736}