Skip to main content

muse2/
asset.rs

1//! Assets are instances of a process which are owned and invested in by agents.
2use 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/// A unique identifier for an asset
32#[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/// Indicates the year and number of units mothballed
48#[derive(PartialEq, Debug, Clone)]
49pub struct MothballEvent {
50    year: u32,
51    num_units: u32,
52}
53
54/// The state of an asset
55///
56/// New assets are created as either `Ready` or `Candidate` assets. `Ready` assets from the input
57/// data have a fixed capacity and capital costs already accounted for, whereas `Candidate` assets'
58/// capital costs are not yet accounted for, and their capacity is determined by the investment
59/// algorithm.
60///
61/// `Ready` assets can be converted to `Commissioned` assets by calling the `commission` method (or
62/// via pool operations that commission ready assets).
63#[derive(Clone, Debug, PartialEq, strum::Display)]
64pub enum AssetState {
65    /// The asset has been commissioned
66    Commissioned {
67        /// The ID of the asset
68        id: AssetID,
69        /// The ID of the agent that owns the asset
70        agent_id: AgentID,
71        /// Years in which all of some of the asset was mothballed.
72        ///
73        /// Invariants: This **must** be sorted by year with older years first and the total number
74        /// of units must not exceed the total for this asset.
75        mothball_events: VecDeque<MothballEvent>,
76    },
77    /// The asset is ready for investment, but not yet confirmed
78    Ready {
79        /// The ID of the agent that would own the asset
80        agent_id: AgentID,
81        /// The reason why this asset is due to be commissioned
82        commission_reason: &'static str,
83    },
84    /// The asset is a candidate for investment but has not yet been selected by an agent
85    Candidate,
86}
87
88/// An asset controlled by an agent.
89#[derive(Clone)]
90pub struct Asset {
91    /// The status of the asset
92    state: AssetState,
93    /// The [`Process`] that this asset corresponds to
94    process: Rc<Process>,
95    /// Activity limits for this asset
96    activity_limits: Rc<ActivityLimits>,
97    /// The commodity flows for this asset
98    flows: Rc<IndexMap<CommodityID, ProcessFlow>>,
99    /// The [`ProcessParameter`] corresponding to the asset's region and commission year
100    process_parameter: Rc<ProcessParameter>,
101    /// The region in which the asset is located
102    region_id: RegionID,
103    /// Capacity of asset (for candidates this is a hypothetical capacity which may be altered)
104    capacity: AssetCapacity,
105    /// The year the asset was/will be commissioned
106    commission_year: u32,
107    /// The maximum year that the asset could be decommissioned
108    max_decommission_year: u32,
109}
110
111impl Asset {
112    /// Create a new candidate asset
113    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    /// Create a new candidate for use in dispatch runs
131    ///
132    /// These candidates will have a single continuous capacity specified by the model parameter
133    /// `candidate_asset_capacity`, regardless of whether the underlying process is divisible or
134    /// not.
135    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    /// Create a new candidate asset from a commissioned asset
152    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    /// Create a new ready asset
162    ///
163    /// This is only used for testing. In the real program, Ready assets can only be created from
164    /// Candidate assets by calling `select_candidate_for_investment`.
165    #[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    /// Create a new commissioned asset
188    ///
189    /// This is only used for testing. WARNING: These assets always have an ID of zero, so can
190    /// create hash collisions. Use with care.
191    #[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    /// Private helper to create an asset with the given state
215    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, &region_id, commission_year)?;
224        ensure!(
225            capacity.total_capacity() >= Capacity(0.0),
226            "Capacity must be non-negative"
227        );
228
229        // There should be activity limits, commodity flows and process parameters for all
230        // **milestone** years, but it is possible to have assets that are commissioned before the
231        // simulation start from assets.csv. We check for the presence of the params lazily to
232        // prevent users having to supply them for all the possible valid years before the time
233        // horizon.
234        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    /// Get the state of this asset
290    pub fn state(&self) -> &AssetState {
291        &self.state
292    }
293
294    /// The process parameter for this asset
295    pub fn process_parameter(&self) -> &ProcessParameter {
296        &self.process_parameter
297    }
298
299    /// The last year in which this asset should be decommissioned
300    pub fn max_decommission_year(&self) -> u32 {
301        self.max_decommission_year
302    }
303
304    /// Get the activity limits per unit of capacity for this asset in a particular time slice
305    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    /// Get the activity limits for this asset for a given time slice selection
315    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    /// Get the activity limits per unit of capacity for this asset for a given time slice selection
328    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    /// Iterate over activity limits for this asset
338    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    /// Iterate over activity per capacity limits for this asset
353    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    /// Gets the total SED/SVD output per unit of activity for this asset
368    ///
369    /// Note: Since we are summing coefficients from different commodities, this ONLY makes sense
370    /// if these commodities have the same units (e.g., all in PJ). Users are currently not made to
371    /// give units for commodities, so we cannot possibly enforce this. Something to potentially
372    /// address in future.
373    pub fn get_total_output_per_activity(&self) -> FlowPerActivity {
374        self.iter_output_flows().map(|flow| flow.coeff).sum()
375    }
376
377    /// Get the operating cost for this asset in a given year and time slice
378    pub fn get_operating_cost(&self, year: u32, time_slice: &TimeSliceID) -> MoneyPerActivity {
379        // The cost for all commodity flows (including levies/incentives)
380        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    /// Get the total revenue from all flows for this asset.
389    ///
390    /// If a price is missing, it is assumed to be zero.
391    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    /// Get the total revenue from all flows excluding the primary output.
400    ///
401    /// If a price is missing, it is assumed to be zero.
402    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    /// Get the total cost of purchasing input commodities per unit of activity for this asset.
415    ///
416    /// If a price is missing, there is assumed to be no cost.
417    pub fn get_input_cost_from_prices(
418        &self,
419        prices: &PriceMap,
420        time_slice: &TimeSliceID,
421    ) -> MoneyPerActivity {
422        // Revenues of input flows are negative costs, so we negate the result
423        -self.get_revenue_from_flows_with_filter(prices, time_slice, |x| {
424            x.direction() == FlowDirection::Input
425        })
426    }
427
428    /// Get the total revenue from a subset of flows.
429    ///
430    /// Takes a function as an argument to filter the flows. If a price is missing, it is assumed to
431    /// be zero.
432    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    /// Get the generic activity cost per unit of activity for this asset.
453    ///
454    /// These are all activity-related costs that are not associated with specific SED/SVD outputs.
455    /// Includes levies, flow costs, costs of inputs and variable operating costs
456    fn get_generic_activity_cost(
457        &self,
458        prices: &PriceMap,
459        year: u32,
460        time_slice: &TimeSliceID,
461    ) -> MoneyPerActivity {
462        // The cost of purchasing input commodities
463        let cost_of_inputs = self.get_input_cost_from_prices(prices, time_slice);
464
465        // Flow costs/levies for all flows except SED/SVD outputs
466        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    /// Iterate over marginal costs for a filtered set of SED/SVD output commodities for this asset
483    ///
484    /// For each SED/SVD output commodity, the marginal cost is calculated as the sum of:
485    /// - Generic activity costs (variable operating costs, cost of purchasing inputs, plus all
486    ///   levies and flow costs not associated with specific SED/SVD outputs), which are
487    ///   shared equally over all SED/SVD outputs
488    /// - Production levies and flow costs for the specific SED/SVD output commodity
489    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        // Iterator over SED/SVD output flows matching the filter
497        let mut output_flows_iter = self
498            .iter_output_flows()
499            .filter(move |flow| filter(&flow.commodity.id))
500            .peekable();
501
502        // If there are no output flows after filtering, return an empty iterator
503        if output_flows_iter.peek().is_none() {
504            return Box::new(std::iter::empty::<(CommodityID, MoneyPerFlow)>());
505        }
506
507        // Calculate generic activity costs.
508        // This is all activity costs not associated with specific SED/SVD outputs, which will get
509        // shared equally over all SED/SVD outputs. Includes levies, flow costs, costs of inputs and
510        // variable operating costs
511        let generic_activity_cost = self.get_generic_activity_cost(prices, year, time_slice);
512
513        // Share generic activity costs equally over all SED/SVD outputs
514        // We sum the output coefficients of all SED/SVD commodities to get total output, then
515        // divide costs by this total output to get the generic cost per unit of output.
516        // Note: only works if all SED/SVD outputs have the same units - not currently checked!
517        let total_output_per_activity = self.get_total_output_per_activity();
518        assert!(total_output_per_activity > FlowPerActivity::EPSILON); // input checks should guarantee this
519        let generic_cost_per_flow = generic_activity_cost / total_output_per_activity;
520
521        // Iterate over SED/SVD output flows
522        Box::new(output_flows_iter.map(move |flow| {
523            // Get the costs for this specific commodity flow
524            let commodity_specific_costs_per_flow =
525                flow.get_total_cost_per_flow(&self.region_id, year, time_slice);
526
527            // Add these to the generic costs to get total cost for this commodity
528            let marginal_cost = generic_cost_per_flow + commodity_specific_costs_per_flow;
529            (flow.commodity.id.clone(), marginal_cost)
530        }))
531    }
532
533    /// Iterate over marginal costs for all SED/SVD output commodities for this asset
534    ///
535    /// See `iter_marginal_costs_with_filter` for details.
536    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    /// Get the annual capital cost per unit of capacity for this asset
546    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    /// Get the annual fixed costs (AFC) per unit of activity for this asset
554    ///
555    /// Total capital costs and fixed opex are shared equally over the year in accordance with the
556    /// annual activity.
557    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    /// Get the annual fixed costs (AFC) per unit of output flow for this asset
573    ///
574    /// Total capital costs and fixed opex are shared equally across all output flows in accordance
575    /// with the annual activity and total output per unit of activity.
576    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); // input checks should guarantee this
581        annual_fixed_costs_per_activity / total_output_per_activity
582    }
583
584    /// Maximum activity for this asset
585    pub fn max_activity(&self) -> Activity {
586        self.total_capacity() * self.process.capacity_to_activity
587    }
588
589    /// Get a specific process flow
590    pub fn get_flow(&self, commodity_id: &CommodityID) -> Option<&ProcessFlow> {
591        self.flows.get(commodity_id)
592    }
593
594    /// Iterate over the asset's flows
595    pub fn iter_flows(&self) -> impl Iterator<Item = &ProcessFlow> {
596        self.flows.values()
597    }
598
599    /// Iterate over the asset's output SED/SVD flows
600    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    /// Get the primary output flow (if any) for this asset
611    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    /// Get the primary output commodity (if any) for this asset
619    pub fn primary_output_commodity(&self) -> Option<&CommodityID> {
620        self.process.primary_output.as_ref()
621    }
622
623    /// Whether this asset has been commissioned
624    pub fn is_commissioned(&self) -> bool {
625        matches!(&self.state, AssetState::Commissioned { .. })
626    }
627
628    /// Whether this asset is a candidate
629    pub fn is_candidate(&self) -> bool {
630        matches!(&self.state, AssetState::Candidate)
631    }
632
633    /// Get the commission year for this asset
634    pub fn commission_year(&self) -> u32 {
635        self.commission_year
636    }
637
638    /// Get the region ID for this asset
639    pub fn region_id(&self) -> &RegionID {
640        &self.region_id
641    }
642
643    /// Get the process for this asset
644    pub fn process(&self) -> &Process {
645        &self.process
646    }
647
648    /// Get the process ID for this asset
649    pub fn process_id(&self) -> &ProcessID {
650        &self.process.id
651    }
652
653    /// Get the ID for this asset
654    pub fn id(&self) -> Option<AssetID> {
655        match &self.state {
656            AssetState::Commissioned { id, .. } => Some(*id),
657            _ => None,
658        }
659    }
660
661    /// Whether this asset is divisible
662    pub fn is_divisible(&self) -> bool {
663        matches!(self.capacity, AssetCapacity::Discrete { .. })
664    }
665
666    /// Get the agent ID for this asset, if any
667    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    /// Get the capacity for this asset
677    pub fn capacity(&self) -> AssetCapacity {
678        self.capacity
679    }
680
681    /// Get the total capacity for this asset
682    pub fn total_capacity(&self) -> Capacity {
683        self.capacity().total_capacity()
684    }
685
686    /// Set the capacity of this asset.
687    ///
688    /// Note that this should be done with care!
689    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    /// Increase the capacity for this asset
704    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    /// Commission the asset.
714    ///
715    /// Only assets with an [`AssetState`] of `Ready` can be commissioned. If the asset's state is
716    /// something else, this function will panic.
717    ///
718    /// # Arguments
719    ///
720    /// * `id` - The ID to give the newly commissioned asset
721    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    /// Select a Candidate asset for investment, converting it to a Ready state
745    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    /// Get the mothball events for this asset, if commissioned
758    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    /// Get the mothball events (mutably) for this asset, if commissioned
768    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    /// Whether this asset has any units mothballed
778    pub fn has_any_mothballed_units(&self) -> bool {
779        self.get_mothball_events()
780            .is_some_and(|events| !events.is_empty())
781    }
782
783    /// Get the number of units which are mothballed.
784    ///
785    /// For non-commissioned assets, this always returns zero.
786    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    /// Get the remaining number of units that are not mothballed.
795    ///
796    /// For non-commissioned assets, this always returns the total number of units.
797    pub fn get_num_nonmothballed_units(&self) -> u32 {
798        self.num_units() - self.get_num_mothballed_units()
799    }
800
801    /// The number of units this asset represents
802    ///
803    /// If divisible, returns the total number of units, otherwise returns one.
804    pub fn num_units(&self) -> u32 {
805        self.capacity().n_units().unwrap_or(1)
806    }
807
808    /// Get the unit size for this asset's capacity (if any)
809    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    /// For non-commissioned assets, get the maximum capacity permitted to be installed based on the
817    /// investment constraints for the asset's process.
818    ///
819    /// The limit is taken from the process's investment constraints for the asset's region and
820    /// commission year, and the portion of the commodity demand being considered.
821    ///
822    /// For divisible assets, the returned capacity will be rounded down to the nearest multiple of
823    /// the asset's unit size.
824    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
858/// Whether the process operates in the specified region and year
859pub 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/// An asset defined by the user in the assets input file
880#[derive(Clone, Debug, PartialEq, derive_more::Deref, derive_more::Into)]
881pub struct UserAsset(#[deref(forward)] AssetRef);
882
883impl UserAsset {
884    /// Create a new [`UserAsset`]
885    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
922/// Whether the specified value is a valid capacity for an asset
923pub 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
931/// Log that the specified number of units has been decommissioned for the given asset
932fn 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/// A wrapper containing a reference-counted [`Asset`].
949///
950/// [`AssetRef`] implements equality, ordering, and hashing using an [`AssetID`], if available, but
951/// otherwise using a combination of other fields which should be unique at all the relevant points
952/// in the simulation.
953#[derive(Clone, Debug, derive_more::Deref, derive_more::From, derive_more::Into)]
954pub struct AssetRef(#[deref(forward)] Rc<Asset>);
955
956impl AssetRef {
957    /// Make a mutable reference to the underlying [`Asset`]
958    pub fn make_mut(&mut self) -> &mut Asset {
959        Rc::make_mut(&mut self.0)
960    }
961
962    /// Get a representation of this [`AssetRef`] that can be used for comparisons
963    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    /// Get an [`AssetRef`] representing a subset of this asset's units.
977    ///
978    /// For non-divisible assets, `new_num_units` must be one. If some of the asset's units are
979    /// mothballed, these are discarded before non-mothballed units. For example, if an asset has
980    /// seven units of which four are mothballed and we are reducing the number of units to four,
981    /// the new asset will have one mothballed unit.
982    ///
983    /// # Panics
984    ///
985    /// Panics if `new_num_units` is zero or exceeds the total capacity of this asset.
986    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        // Make a new Asset with fewer units. If there are more mothballed units than the new asset
1006        // will have, we reduce this number to avoid there being more mothballed units than the new
1007        // asset has, which would be a logic error. We discard mothballed before non-mothballed
1008        // units.
1009        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    /// Decommission this asset
1018    fn decommission(self, reason: &str) {
1019        log_decommissioning(&self, self.num_units(), reason);
1020    }
1021
1022    /// Decommission any units that were mothballed at least `mothball_years` ago.
1023    ///
1024    /// If the asset still has some units remaining, it is returned, else None.
1025    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        // Mothball events are ordered oldest-first, so this sums the units mothballed longest ago
1031        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            // Nothing to do. Return self unmodified.
1038            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        // `with_subset_of_units` discards the oldest mothballed units first, which are exactly the
1051        // ones being decommissioned here.
1052        log_decommissioning(&self, units_to_remove, &reason);
1053        Some(self.with_subset_of_units(new_num_units))
1054    }
1055
1056    /// Return a new [`AssetRef`] with the specified number of units mothballed.
1057    ///
1058    /// If `num_units` equals the number of already mothballed units, the original asset is
1059    /// returned. If additional units may be mothballed, a value must be provided for `year`.
1060    ///
1061    /// # Panics
1062    ///
1063    /// Panics if attempting to mothball more units than the asset represents or if attempting to
1064    /// change the number of mothballed units for a non-commissioned asset.
1065    pub fn with_mothballed_units(mut self, num_units: u32, year: Option<u32>) -> Self {
1066        if num_units == 0 {
1067            // Small optimisation
1068            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            // Nothing to do. Return self unmodified.
1074            return self;
1075        }
1076
1077        assert!(
1078            num_units <= self.num_units(),
1079            "Cannot mothball more units than asset represents"
1080        );
1081
1082        // Now that we know we have to modify self, call make_mut().
1083        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            // Remove mothballing events until only required num of mothballed units remains,
1088            // starting with oldest
1089            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                // Need to check this as adding events in the past breaks the expected invariant for
1106                // `mothball_events`
1107                assert!(
1108                    event.year <= year,
1109                    "Attempting to mothball units in a year in the past"
1110                );
1111            }
1112
1113            // Mothball extra units in specified year
1114            events.push_back(MothballEvent {
1115                year,
1116                num_units: num_units - num_already_mothballed,
1117            });
1118        }
1119
1120        self
1121    }
1122
1123    /// Returns a new [`AssetRef`] with no mothballed units.
1124    ///
1125    /// If the asset has no mothballed units, the original asset is returned.
1126    pub fn with_no_mothballed_units(mut self) -> Self {
1127        if self.has_any_mothballed_units() {
1128            // Only commissioned assets can have mothballed units, so this is safe
1129            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/// A data structure representing the fields of an [`Asset`] that should be used for comparisons.
1169///
1170/// For assets that have an ID (i.e. have been commissioned at some point), we can compare based on
1171/// the ID. Otherwise, we fall back on comparing other properties. The combination of these
1172/// properties should be unique within the simulation (e.g. for assets being input into dispatch).
1173#[derive(PartialEq, PartialOrd, Eq, Ord, Hash)]
1174enum AssetCmp<'a> {
1175    WithID(AssetID),
1176    WithoutID((&'a ProcessID, &'a RegionID, u32, Option<&'a AgentID>)),
1177}
1178
1179/// Additional methods for iterating over assets
1180pub trait AssetIterator<'a>: Iterator<Item = &'a AssetRef> + Sized
1181where
1182    Self: 'a,
1183{
1184    /// Filter assets by the agent that owns them
1185    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    /// Iterate over assets that have the given commodity as a primary output
1190    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    /// Filter the assets by region
1202    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    /// Iterate over process flows affecting the given commodity
1207    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    /// A commissioned divisible asset with three units.
1241    #[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        // Update the process flows using the existing commodity fixture
1256        let commodity_rc = Rc::new(svd_commodity);
1257        let process_flow = ProcessFlow {
1258            commodity: Rc::clone(&commodity_rc),
1259            coeff: FlowPerActivity(-2.0), // Input
1260            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        // Create asset
1268        let asset =
1269            Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(1.0), 2020).unwrap();
1270
1271        // Set input prices
1272        let mut input_prices = PriceMap::default();
1273        input_prices.insert(&commodity_rc.id, &region_id, &time_slice, MoneyPerFlow(3.0));
1274
1275        // Call function
1276        let cost = asset.get_input_cost_from_prices(&input_prices, &time_slice);
1277        // Should be -coeff * price = -(-2.0) * 3.0 = 6.0
1278        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        // Add activity limits to the process
1288        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        // Update cap2act
1294        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        // With cap2act of 2, and activity limits of 0.1..=0.5, should get 0.2..=1.0
1316        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        // Test successful commissioning of Ready asset
1444        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        // Check we can run model with asset commissioned before time horizon (simple starts in
1472        // 2020)
1473        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        // This should fail if it is not one of the years supported by the process, though
1480        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        // Check we can run model with asset commissioned after time horizon (simple ends in 2040)
1497        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        // This should fail if it is not one of the years supported by the process, though
1504        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        // Set an addition limit of 3 for (region, year 2015)
1517        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        // Create a candidate asset with commission year 2015
1526        let asset =
1527            Asset::new_candidate(process_rc.clone(), region_id.clone(), Capacity(1.0), 2015)
1528                .unwrap();
1529
1530        // commodity_portion = 0.5 -> limit = 3 * 0.5 = 1.5
1531        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        // Non-commissioned assets never have mothballed units, regardless of state
1550        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        // Mothball one unit in 2020
1564        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        // Mothball a second unit in 2022: events are retained in chronological order
1574        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        // Mothball 1 unit in 2020, then 2 more (3 total) in 2022
1593        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        // Reduce to a single mothballed unit: the oldest event is fully removed and the newer
1611        // event is partially reduced, leaving exactly one mothballed unit
1612        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        // Requesting the same number of mothballed units is a no-op (the year is ignored)
1627        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        // Mothball a unit in 2020, then attempt to mothball another in an earlier year, which would
1665        // break the chronological ordering invariant of the mothball events
1666        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        // `asset_divisble` has no mothballed units, so the original Rc is returned unchanged
1682        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        // Mothball all 3 units
1690        let asset = commissioned_divisible.with_mothballed_units(3, Some(2020));
1691        assert_eq!(asset.get_num_mothballed_units(), 3);
1692
1693        // Taking a subset of 2 units caps the mothballed count at the new number of units
1694        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        // Threshold is 2005, so the 2020 event is not old enough: the asset is returned unchanged
1703        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        // Mothball 1 unit in 2010 and 1 unit in 2020 (leaving 1 unit active)
1713        let asset = commissioned_divisible
1714            .with_mothballed_units(1, Some(2010))
1715            .with_mothballed_units(2, Some(2020));
1716
1717        // With a threshold of 2015, only the 2010 event is old enough to decommission
1718        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        // All units mothballed long enough ago: the whole asset is decommissioned
1733        let asset = commissioned_divisible.with_mothballed_units(3, Some(2010));
1734        assert!(asset.with_decommission_mothballed(2025, 10).is_none());
1735    }
1736}