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, UnitType, 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::collections::hash_map::DefaultHasher;
23use std::hash::{Hash, Hasher};
24use std::ops::RangeInclusive;
25use std::sync::Arc;
26
27mod capacity;
28pub use capacity::AssetCapacity;
29mod pool;
30pub use pool::AssetPool;
31
32/// A unique identifier for an asset
33#[derive(
34    Clone,
35    Copy,
36    Debug,
37    derive_more::Display,
38    Eq,
39    Hash,
40    Ord,
41    PartialEq,
42    PartialOrd,
43    Deserialize,
44    Serialize,
45)]
46pub struct AssetID(u32);
47
48/// Indicates the year and number of tranches mothballed
49#[derive(PartialEq, Debug, Clone)]
50pub struct MothballEvent {
51    year: u32,
52    num_tranches: u32,
53}
54
55/// The state of an asset
56///
57/// New assets are created as either `Ready` or `Candidate` assets. `Ready` assets from the input
58/// data have a fixed capacity and capital costs already accounted for, whereas `Candidate` assets'
59/// capital costs are not yet accounted for, and their capacity is determined by the investment
60/// algorithm.
61///
62/// `Ready` assets can be converted to `Commissioned` assets by calling the `commission` method (or
63/// via pool operations that commission ready assets).
64#[derive(Clone, Debug, PartialEq, strum::Display)]
65pub enum AssetState {
66    /// The asset has been commissioned
67    Commissioned {
68        /// The ID of the asset
69        id: AssetID,
70        /// The ID of the agent that owns the asset
71        agent_id: AgentID,
72        /// Years in which all of some of the asset was mothballed.
73        ///
74        /// Invariants: This **must** be sorted by year with older years first and the total number
75        /// of tranches must not exceed the total for this asset.
76        mothball_events: VecDeque<MothballEvent>,
77    },
78    /// The asset is ready for investment, but not yet confirmed
79    Ready {
80        /// The ID of the agent that would own the asset
81        agent_id: AgentID,
82        /// The reason why this asset is due to be commissioned
83        commission_reason: &'static str,
84    },
85    /// The asset is a candidate for investment but has not yet been selected by an agent
86    Candidate,
87}
88
89/// An asset controlled by an agent.
90#[derive(Clone)]
91pub struct Asset {
92    /// The status of the asset
93    state: AssetState,
94    /// The [`Process`] that this asset corresponds to
95    process: Arc<Process>,
96    /// Activity limits for this asset
97    activity_limits: Arc<ActivityLimits>,
98    /// The commodity flows for this asset
99    flows: Arc<IndexMap<CommodityID, ProcessFlow>>,
100    /// The [`ProcessParameter`] corresponding to the asset's region and commission year
101    process_parameter: Arc<ProcessParameter>,
102    /// The region in which the asset is located
103    region_id: RegionID,
104    /// Capacity of asset (for candidates this is a hypothetical capacity which may be altered)
105    capacity: AssetCapacity,
106    /// The year the asset was/will be commissioned
107    commission_year: u32,
108    /// The maximum year that the asset could be decommissioned
109    max_decommission_year: u32,
110}
111
112/// Hash a unit value while treating positive and negative zero as equal.
113fn hash_unit<U: UnitType>(value: U) -> u64 {
114    let value = value.value();
115    if value == 0.0 { 0 } else { value.to_bits() }
116}
117
118impl Asset {
119    /// Create a new candidate asset
120    ///
121    /// These candidates will have a single capacity tranche with the given `tranche_size`.
122    pub fn new_candidate(
123        process: Arc<Process>,
124        region_id: RegionID,
125        tranche_size: Capacity,
126        commission_year: u32,
127    ) -> Result<Self> {
128        Self::new_with_state(
129            AssetState::Candidate,
130            process,
131            region_id,
132            AssetCapacity::single(tranche_size),
133            commission_year,
134            None,
135        )
136    }
137
138    /// Create a new ready asset
139    ///
140    /// This is only used for testing. In the real program, Ready assets can only be created from
141    /// Candidate assets by calling `select_candidate_for_investment`.
142    #[cfg(test)]
143    pub fn new_ready(
144        agent_id: AgentID,
145        process: Arc<Process>,
146        region_id: RegionID,
147        capacity: AssetCapacity,
148        commission_year: u32,
149    ) -> Result<Self> {
150        Self::new_with_state(
151            AssetState::Ready {
152                agent_id,
153                commission_reason: "selected",
154            },
155            process,
156            region_id,
157            capacity,
158            commission_year,
159            None,
160        )
161    }
162
163    /// Create a new commissioned asset
164    ///
165    /// This is only used for testing. WARNING: These assets always have an ID of zero, so can
166    /// create hash collisions. Use with care.
167    #[cfg(test)]
168    pub fn new_commissioned(
169        agent_id: AgentID,
170        process: Arc<Process>,
171        region_id: RegionID,
172        capacity: AssetCapacity,
173        commission_year: u32,
174    ) -> Result<Self> {
175        Self::new_with_state(
176            AssetState::Commissioned {
177                id: AssetID(0),
178                agent_id,
179                mothball_events: vec_deque![],
180            },
181            process,
182            region_id,
183            capacity,
184            commission_year,
185            None,
186        )
187    }
188
189    /// Private helper to create an asset with the given state
190    fn new_with_state(
191        state: AssetState,
192        process: Arc<Process>,
193        region_id: RegionID,
194        capacity: AssetCapacity,
195        commission_year: u32,
196        max_decommission_year: Option<u32>,
197    ) -> Result<Self> {
198        check_region_year_valid_for_process(&process, &region_id, commission_year)?;
199        ensure!(
200            capacity.total_capacity() >= Capacity(0.0),
201            "Capacity must be non-negative"
202        );
203
204        // There should be activity limits, commodity flows and process parameters for all
205        // **milestone** years, but it is possible to have assets that are commissioned before the
206        // simulation start from assets.csv. We check for the presence of the params lazily to
207        // prevent users having to supply them for all the possible valid years before the time
208        // horizon.
209        let key = (region_id.clone(), commission_year);
210        let activity_limits = process
211            .activity_limits
212            .get(&key)
213            .with_context(|| {
214                format!(
215                    "No process availabilities supplied for process {} in region {} in year {}. \
216                    You should update process_availabilities.csv.",
217                    process.id, region_id, commission_year
218                )
219            })?
220            .clone();
221        let flows = process
222            .flows
223            .get(&key)
224            .with_context(|| {
225                format!(
226                    "No commodity flows supplied for process {} in region {} in year {}. \
227                    You should update process_flows.csv.",
228                    process.id, region_id, commission_year
229                )
230            })?
231            .clone();
232        let process_parameter = process
233            .parameters
234            .get(&key)
235            .with_context(|| {
236                format!(
237                    "No process parameters supplied for process {} in region {} in year {}. \
238                    You should update process_parameters.csv.",
239                    process.id, region_id, commission_year
240                )
241            })?
242            .clone();
243
244        let max_decommission_year =
245            max_decommission_year.unwrap_or(commission_year + process_parameter.lifetime);
246        ensure!(
247            max_decommission_year > commission_year,
248            "Max decommission year must be greater than commission year"
249        );
250        Ok(Self {
251            state,
252            process,
253            activity_limits,
254            flows,
255            process_parameter,
256            region_id,
257            capacity,
258            commission_year,
259            max_decommission_year,
260        })
261    }
262
263    /// Get the state of this asset
264    pub fn state(&self) -> &AssetState {
265        &self.state
266    }
267
268    /// The process parameter for this asset
269    pub fn process_parameter(&self) -> &ProcessParameter {
270        &self.process_parameter
271    }
272
273    /// The last year in which this asset should be decommissioned
274    pub fn max_decommission_year(&self) -> u32 {
275        self.max_decommission_year
276    }
277
278    /// Get the activity limits per unit of capacity for this asset in a particular time slice
279    pub fn get_activity_per_capacity_limits(
280        &self,
281        time_slice: &TimeSliceID,
282    ) -> RangeInclusive<ActivityPerCapacity> {
283        let limits = &self.activity_limits.get_limit_for_time_slice(time_slice);
284        let cap2act = self.process.capacity_to_activity;
285        (cap2act * *limits.start())..=(cap2act * *limits.end())
286    }
287
288    /// Get the activity limits for this asset for a given time slice selection
289    pub fn get_activity_limits_for_selection(
290        &self,
291        time_slice_selection: &TimeSliceSelection,
292    ) -> RangeInclusive<Activity> {
293        let activity_per_capacity_limits = self.activity_limits.get_limit(time_slice_selection);
294        let cap2act = self.process.capacity_to_activity;
295        let max_activity = self.active_capacity() * cap2act;
296        let lb = max_activity * *activity_per_capacity_limits.start();
297        let ub = max_activity * *activity_per_capacity_limits.end();
298        lb..=ub
299    }
300
301    /// Get the activity limits per unit of capacity for this asset for a given time slice selection
302    pub fn get_activity_per_capacity_limits_for_selection(
303        &self,
304        time_slice_selection: &TimeSliceSelection,
305    ) -> RangeInclusive<ActivityPerCapacity> {
306        let limits = self.activity_limits.get_limit(time_slice_selection);
307        let cap2act = self.process.capacity_to_activity;
308        (cap2act * *limits.start())..=(cap2act * *limits.end())
309    }
310
311    /// Iterate over activity limits for this asset
312    pub fn iter_activity_limits(
313        &self,
314    ) -> impl Iterator<Item = (TimeSliceSelection, RangeInclusive<Activity>)> + '_ {
315        let max_act = self.max_activity();
316        self.activity_limits
317            .iter_limits()
318            .map(move |(ts_sel, limit)| {
319                (
320                    ts_sel,
321                    (max_act * *limit.start())..=(max_act * *limit.end()),
322                )
323            })
324    }
325
326    /// Iterate over activity per capacity limits for this asset
327    pub fn iter_activity_per_capacity_limits(
328        &self,
329    ) -> impl Iterator<Item = (TimeSliceSelection, RangeInclusive<ActivityPerCapacity>)> + '_ {
330        let cap2act = self.process.capacity_to_activity;
331        self.activity_limits
332            .iter_limits()
333            .map(move |(ts_sel, limit)| {
334                (
335                    ts_sel,
336                    (cap2act * *limit.start())..=(cap2act * *limit.end()),
337                )
338            })
339    }
340
341    /// Gets the total SED/SVD output per unit of activity for this asset
342    ///
343    /// Note: Since we are summing coefficients from different commodities, this ONLY makes sense
344    /// if these commodities have the same units (e.g., all in PJ). Users are currently not made to
345    /// give units for commodities, so we cannot possibly enforce this. Something to potentially
346    /// address in future.
347    pub fn get_total_output_per_activity(&self) -> FlowPerActivity {
348        self.iter_output_flows().map(|flow| flow.coeff).sum()
349    }
350
351    /// Get the operating cost for this asset in a given year and time slice
352    pub fn get_operating_cost(&self, year: u32, time_slice: &TimeSliceID) -> MoneyPerActivity {
353        // The cost for all commodity flows (including levies/incentives)
354        let flows_cost = self
355            .iter_flows()
356            .map(|flow| flow.get_total_cost_per_activity(&self.region_id, year, time_slice))
357            .sum();
358
359        self.process_parameter.variable_operating_cost + flows_cost
360    }
361
362    /// Get the total revenue from all flows for this asset.
363    ///
364    /// If a price is missing, it is assumed to be zero.
365    pub fn get_revenue_from_flows(
366        &self,
367        prices: &PriceMap,
368        time_slice: &TimeSliceID,
369    ) -> MoneyPerActivity {
370        self.get_revenue_from_flows_with_filter(prices, time_slice, |_| true)
371    }
372
373    /// Get the total revenue from all flows excluding the primary output.
374    ///
375    /// If a price is missing, it is assumed to be zero.
376    pub fn get_revenue_from_flows_excluding_primary(
377        &self,
378        prices: &PriceMap,
379        time_slice: &TimeSliceID,
380    ) -> MoneyPerActivity {
381        let excluded_commodity = self.primary_output().map(|flow| &flow.commodity.id);
382
383        self.get_revenue_from_flows_with_filter(prices, time_slice, |flow| {
384            excluded_commodity.is_none_or(|commodity_id| commodity_id != &flow.commodity.id)
385        })
386    }
387
388    /// Get the total cost of purchasing input commodities per unit of activity for this asset.
389    ///
390    /// If a price is missing, there is assumed to be no cost.
391    pub fn get_input_cost_from_prices(
392        &self,
393        prices: &PriceMap,
394        time_slice: &TimeSliceID,
395    ) -> MoneyPerActivity {
396        // Revenues of input flows are negative costs, so we negate the result
397        -self.get_revenue_from_flows_with_filter(prices, time_slice, |x| {
398            x.direction() == FlowDirection::Input
399        })
400    }
401
402    /// Get the total revenue from a subset of flows.
403    ///
404    /// Takes a function as an argument to filter the flows. If a price is missing, it is assumed to
405    /// be zero.
406    fn get_revenue_from_flows_with_filter<F>(
407        &self,
408        prices: &PriceMap,
409        time_slice: &TimeSliceID,
410        mut filter_for_flows: F,
411    ) -> MoneyPerActivity
412    where
413        F: FnMut(&ProcessFlow) -> bool,
414    {
415        self.iter_flows()
416            .filter(|flow| filter_for_flows(flow))
417            .map(|flow| {
418                flow.coeff
419                    * prices
420                        .get(&flow.commodity.id, &self.region_id, time_slice)
421                        .unwrap_or(MoneyPerFlow(0.0))
422            })
423            .sum()
424    }
425
426    /// Get the generic activity cost per unit of activity for this asset.
427    ///
428    /// These are all activity-related costs that are not associated with specific SED/SVD outputs.
429    /// Includes levies, flow costs, costs of inputs and variable operating costs
430    fn get_generic_activity_cost(
431        &self,
432        prices: &PriceMap,
433        year: u32,
434        time_slice: &TimeSliceID,
435    ) -> MoneyPerActivity {
436        // The cost of purchasing input commodities
437        let cost_of_inputs = self.get_input_cost_from_prices(prices, time_slice);
438
439        // Flow costs/levies for all flows except SED/SVD outputs
440        let excludes_sed_svd_output = |flow: &&ProcessFlow| {
441            !(flow.direction() == FlowDirection::Output
442                && matches!(
443                    flow.commodity.kind,
444                    CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
445                ))
446        };
447        let flow_costs = self
448            .iter_flows()
449            .filter(excludes_sed_svd_output)
450            .map(|flow| flow.get_total_cost_per_activity(&self.region_id, year, time_slice))
451            .sum();
452
453        cost_of_inputs + flow_costs + self.process_parameter.variable_operating_cost
454    }
455
456    /// Iterate over marginal costs for a filtered set of SED/SVD output commodities for this asset
457    ///
458    /// For each SED/SVD output commodity, the marginal cost is calculated as the sum of:
459    /// - Generic activity costs (variable operating costs, cost of purchasing inputs, plus all
460    ///   levies and flow costs not associated with specific SED/SVD outputs), which are
461    ///   shared equally over all SED/SVD outputs
462    /// - Production levies and flow costs for the specific SED/SVD output commodity
463    pub fn iter_marginal_costs_with_filter<'a>(
464        &'a self,
465        prices: &'a PriceMap,
466        year: u32,
467        time_slice: &'a TimeSliceID,
468        filter: impl Fn(&CommodityID) -> bool + 'a,
469    ) -> Box<dyn Iterator<Item = (CommodityID, MoneyPerFlow)> + 'a> {
470        // Iterator over SED/SVD output flows matching the filter
471        let mut output_flows_iter = self
472            .iter_output_flows()
473            .filter(move |flow| filter(&flow.commodity.id))
474            .peekable();
475
476        // If there are no output flows after filtering, return an empty iterator
477        if output_flows_iter.peek().is_none() {
478            return Box::new(std::iter::empty::<(CommodityID, MoneyPerFlow)>());
479        }
480
481        // Calculate generic activity costs.
482        // This is all activity costs not associated with specific SED/SVD outputs, which will get
483        // shared equally over all SED/SVD outputs. Includes levies, flow costs, costs of inputs and
484        // variable operating costs
485        let generic_activity_cost = self.get_generic_activity_cost(prices, year, time_slice);
486
487        // Share generic activity costs equally over all SED/SVD outputs
488        // We sum the output coefficients of all SED/SVD commodities to get total output, then
489        // divide costs by this total output to get the generic cost per unit of output.
490        // Note: only works if all SED/SVD outputs have the same units - not currently checked!
491        let total_output_per_activity = self.get_total_output_per_activity();
492        assert!(total_output_per_activity > FlowPerActivity::EPSILON); // input checks should guarantee this
493        let generic_cost_per_flow = generic_activity_cost / total_output_per_activity;
494
495        // Iterate over SED/SVD output flows
496        Box::new(output_flows_iter.map(move |flow| {
497            // Get the costs for this specific commodity flow
498            let commodity_specific_costs_per_flow =
499                flow.get_total_cost_per_flow(&self.region_id, year, time_slice);
500
501            // Add these to the generic costs to get total cost for this commodity
502            let marginal_cost = generic_cost_per_flow + commodity_specific_costs_per_flow;
503            (flow.commodity.id.clone(), marginal_cost)
504        }))
505    }
506
507    /// Iterate over marginal costs for all SED/SVD output commodities for this asset
508    ///
509    /// See `iter_marginal_costs_with_filter` for details.
510    pub fn iter_marginal_costs<'a>(
511        &'a self,
512        prices: &'a PriceMap,
513        year: u32,
514        time_slice: &'a TimeSliceID,
515    ) -> Box<dyn Iterator<Item = (CommodityID, MoneyPerFlow)> + 'a> {
516        self.iter_marginal_costs_with_filter(prices, year, time_slice, move |_| true)
517    }
518
519    /// Get the annual capital cost per unit of capacity for this asset
520    pub fn get_annual_capital_cost_per_capacity(&self) -> MoneyPerCapacity {
521        let capital_cost = self.process_parameter.capital_cost;
522        let lifetime = self.process_parameter.lifetime;
523        let discount_rate = self.process_parameter.discount_rate;
524        annual_capital_cost(capital_cost, lifetime, discount_rate)
525    }
526
527    /// Get the annual fixed costs (AFC) per unit of activity for this asset
528    ///
529    /// Total capital costs and fixed opex are shared equally over the year in accordance with the
530    /// annual activity.
531    pub fn get_annual_fixed_costs_per_activity(
532        &self,
533        annual_activity: Activity,
534    ) -> MoneyPerActivity {
535        let annual_capital_cost_per_capacity = self.get_annual_capital_cost_per_capacity();
536        let annual_fixed_opex = self.process_parameter.fixed_operating_cost * Year(1.0);
537        let total_annual_fixed_costs =
538            (annual_capital_cost_per_capacity + annual_fixed_opex) * self.total_capacity();
539        assert!(
540            annual_activity > Activity::EPSILON,
541            "Cannot calculate annual fixed costs per activity for an asset with zero annual activity"
542        );
543        total_annual_fixed_costs / annual_activity
544    }
545
546    /// Get the annual fixed costs (AFC) per unit of output flow for this asset
547    ///
548    /// Total capital costs and fixed opex are shared equally across all output flows in accordance
549    /// with the annual activity and total output per unit of activity.
550    pub fn get_annual_fixed_costs_per_flow(&self, annual_activity: Activity) -> MoneyPerFlow {
551        let annual_fixed_costs_per_activity =
552            self.get_annual_fixed_costs_per_activity(annual_activity);
553        let total_output_per_activity = self.get_total_output_per_activity();
554        assert!(total_output_per_activity > FlowPerActivity::EPSILON); // input checks should guarantee this
555        annual_fixed_costs_per_activity / total_output_per_activity
556    }
557
558    /// Maximum activity for this asset
559    pub fn max_activity(&self) -> Activity {
560        self.active_capacity() * self.process.capacity_to_activity
561    }
562
563    /// Get a specific process flow
564    pub fn get_flow(&self, commodity_id: &CommodityID) -> Option<&ProcessFlow> {
565        self.flows.get(commodity_id)
566    }
567
568    /// Iterate over the asset's flows
569    pub fn iter_flows(&self) -> impl Iterator<Item = &ProcessFlow> {
570        self.flows.values()
571    }
572
573    /// Iterate over the asset's output SED/SVD flows
574    pub fn iter_output_flows(&self) -> impl Iterator<Item = &ProcessFlow> {
575        self.flows.values().filter(|flow| {
576            flow.direction() == FlowDirection::Output
577                && matches!(
578                    flow.commodity.kind,
579                    CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
580                )
581        })
582    }
583
584    /// Get the primary output flow (if any) for this asset
585    pub fn primary_output(&self) -> Option<&ProcessFlow> {
586        self.process
587            .primary_output
588            .as_ref()
589            .map(|commodity_id| &self.flows[commodity_id])
590    }
591
592    /// Get the primary output commodity (if any) for this asset
593    pub fn primary_output_commodity(&self) -> Option<&CommodityID> {
594        self.process.primary_output.as_ref()
595    }
596
597    /// Whether this asset has been commissioned
598    pub fn is_commissioned(&self) -> bool {
599        matches!(&self.state, AssetState::Commissioned { .. })
600    }
601
602    /// Whether this asset is a candidate
603    pub fn is_candidate(&self) -> bool {
604        matches!(&self.state, AssetState::Candidate)
605    }
606
607    /// Get the commission year for this asset
608    pub fn commission_year(&self) -> u32 {
609        self.commission_year
610    }
611
612    /// Get the region ID for this asset
613    pub fn region_id(&self) -> &RegionID {
614        &self.region_id
615    }
616
617    /// Get the process for this asset
618    pub fn process(&self) -> &Process {
619        &self.process
620    }
621
622    /// Get the process ID for this asset
623    pub fn process_id(&self) -> &ProcessID {
624        &self.process.id
625    }
626
627    /// Whether two assets have identical properties for the purposes of dispatch optimisation.
628    ///
629    /// Capacity, process identity and asset state are deliberately ignored. The activity limits
630    /// and flows are compared by value, so separately allocated but identical `Arc`s are still
631    /// equivalent. This method is the authoritative equality check; the dispatch hash is only used
632    /// to narrow grouping candidates and may contain collisions.
633    ///
634    /// This is deliberately conservative: any difference in variable operating cost, flows, or
635    /// activity limits, however small, means the assets are considered not equivalent.
636    pub fn is_dispatch_equivalent(&self, other: &Self) -> bool {
637        self.region_id == other.region_id
638            && self.process_parameter.variable_operating_cost
639                == other.process_parameter.variable_operating_cost
640            // `IndexMap` equality is insensitive to entry order.
641            // If the assets share the same allocation, `Arc::ptr_eq` avoids having to compare the
642            // full contents of the `IndexMap`.
643            && (Arc::ptr_eq(&self.activity_limits, &other.activity_limits)
644                || self.activity_limits == other.activity_limits)
645            && (Arc::ptr_eq(&self.flows, &other.flows) || self.flows == other.flows)
646    }
647
648    /// Calculate a hash of the properties used to determine dispatch equivalence.
649    ///
650    /// It is used as a prefilter to avoid unnecessary, and potentially expensive, comparisons with
651    /// [`Self::is_dispatch_equivalent`]. Equal hashes do not confirm equivalence, but unequal
652    /// hashes can rule out equivalence.
653    ///
654    /// This is deliberately conservative: any difference in variable operating cost, flows, or
655    /// activity limits, however small, may result in a different hash.
656    ///
657    /// Hashes are calculated lazily, rather than caching at initialisation, because only assets
658    /// used in dispatch and eligible for equal-utilisation grouping need it. The trade-off is that
659    /// some assets may end up being hashed multiple times if they take part in multiple dispatch
660    /// runs, although the performance cost of this is likely not massive.
661    pub(crate) fn dispatch_equivalence_hash(&self) -> u64 {
662        let mut hasher = DefaultHasher::new();
663
664        // Hash the region ID
665        self.region_id.hash(&mut hasher);
666
667        // Hash the variable operating cost
668        hash_unit(self.process_parameter.variable_operating_cost).hash(&mut hasher);
669
670        // Hash activity limits based on ts selection, lower and upper limits
671        let mut activity_limit_hashes = self
672            .activity_limits
673            .iter_limits()
674            .map(|(selection, limits)| {
675                let mut hasher = DefaultHasher::new();
676                selection.hash(&mut hasher);
677                hash_unit(*limits.start()).hash(&mut hasher);
678                hash_unit(*limits.end()).hash(&mut hasher);
679                hasher.finish()
680            })
681            .collect::<Vec<_>>();
682        activity_limit_hashes.sort_unstable();
683        activity_limit_hashes.hash(&mut hasher);
684
685        // Hash flows based on commodity ID, coefficient, FlowType and cost
686        let mut flow_hashes = self
687            .flows
688            .values()
689            .map(|flow| {
690                let mut hasher = DefaultHasher::new();
691                flow.commodity.id.hash(&mut hasher);
692                hash_unit(flow.coeff).hash(&mut hasher);
693                std::mem::discriminant(&flow.kind).hash(&mut hasher);
694                hash_unit(flow.cost).hash(&mut hasher);
695                hasher.finish()
696            })
697            .collect::<Vec<_>>();
698        flow_hashes.sort_unstable();
699        flow_hashes.hash(&mut hasher);
700
701        hasher.finish()
702    }
703
704    /// Get the ID for this asset
705    pub fn id(&self) -> Option<AssetID> {
706        match &self.state {
707            AssetState::Commissioned { id, .. } => Some(*id),
708            _ => None,
709        }
710    }
711
712    /// Get the agent ID for this asset, if any
713    pub fn agent_id(&self) -> Option<&AgentID> {
714        match &self.state {
715            AssetState::Commissioned { agent_id, .. } | AssetState::Ready { agent_id, .. } => {
716                Some(agent_id)
717            }
718            AssetState::Candidate => None,
719        }
720    }
721
722    /// Get the capacity for this asset
723    pub fn capacity(&self) -> AssetCapacity {
724        self.capacity
725    }
726
727    /// Get the total capacity for this asset
728    pub fn total_capacity(&self) -> Capacity {
729        self.capacity().total_capacity()
730    }
731
732    /// Set the capacity of this asset.
733    ///
734    /// Note that this should be done with care!
735    pub fn set_capacity(&mut self, capacity: AssetCapacity) {
736        assert!(
737            capacity.total_capacity() >= Capacity(0.0),
738            "Capacity must be >= 0"
739        );
740        assert!(
741            self.get_num_mothballed_tranches() <= capacity.num_tranches(),
742            "Cannot set capacity to a smaller number of tranches than are currently mothballed"
743        );
744        self.capacity = capacity;
745    }
746
747    /// Increase the capacity for this asset
748    pub fn increase_capacity(&mut self, capacity: AssetCapacity) {
749        assert!(
750            capacity.total_capacity() > Capacity(0.0),
751            "Capacity increase must be positive"
752        );
753
754        self.capacity = self.capacity + capacity;
755    }
756
757    /// Commission the asset.
758    ///
759    /// Only assets with an [`AssetState`] of `Ready` can be commissioned. If the asset's state is
760    /// something else, this function will panic.
761    ///
762    /// # Arguments
763    ///
764    /// * `id` - The ID to give the newly commissioned asset
765    fn commission(&mut self, id: AssetID) {
766        let (agent_id, reason) = match &self.state {
767            AssetState::Ready {
768                agent_id,
769                commission_reason,
770            } => (agent_id, commission_reason),
771            state => panic!("Assets with state {state} cannot be commissioned"),
772        };
773        debug!(
774            "Commissioning '{}' asset (ID: {}, capacity: {}) for agent '{}' (reason: {})",
775            self.process_id(),
776            id,
777            self.total_capacity(),
778            agent_id,
779            reason
780        );
781        self.state = AssetState::Commissioned {
782            id,
783            agent_id: agent_id.clone(),
784            mothball_events: vec_deque![],
785        };
786    }
787
788    /// Select a Candidate asset for investment, converting it to a Ready state
789    pub fn select_candidate_for_investment(&mut self, agent_id: AgentID) {
790        assert!(
791            self.is_candidate(),
792            "select_candidate_for_investment can only be called on Candidate assets"
793        );
794        self.state = AssetState::Ready {
795            agent_id,
796            commission_reason: "selected",
797        };
798    }
799
800    /// Get the mothball events for this asset, if commissioned
801    fn get_mothball_events(&self) -> Option<&VecDeque<MothballEvent>> {
802        match &self.state {
803            AssetState::Commissioned {
804                mothball_events, ..
805            } => Some(mothball_events),
806            _ => None,
807        }
808    }
809
810    /// Get the mothball events (mutably) for this asset, if commissioned
811    fn get_mothball_events_mut(&mut self) -> Option<&mut VecDeque<MothballEvent>> {
812        match &mut self.state {
813            AssetState::Commissioned {
814                mothball_events, ..
815            } => Some(mothball_events),
816            _ => None,
817        }
818    }
819
820    /// Whether this asset has any tranches mothballed
821    pub fn has_any_mothballed_tranches(&self) -> bool {
822        self.get_mothball_events()
823            .is_some_and(|events| !events.is_empty())
824    }
825
826    /// Get the number of tranches which are mothballed.
827    ///
828    /// For non-commissioned assets, this always returns zero.
829    pub fn get_num_mothballed_tranches(&self) -> u32 {
830        let Some(events) = self.get_mothball_events() else {
831            return 0;
832        };
833
834        events.iter().map(|event| event.num_tranches).sum()
835    }
836
837    /// Get the capacity which is mothballed.
838    pub fn mothballed_capacity(&self) -> Capacity {
839        self.capacity().tranche_size() * Dimensionless(self.get_num_mothballed_tranches() as f64)
840    }
841
842    /// Get the remaining number of tranches that are not mothballed.
843    ///
844    /// For non-commissioned assets, this always returns the total number of tranches.
845    pub fn get_num_nonmothballed_tranches(&self) -> u32 {
846        self.num_tranches() - self.get_num_mothballed_tranches()
847    }
848
849    /// Get the active (non-mothballed) capacity for this asset
850    pub fn active_capacity(&self) -> Capacity {
851        self.capacity().tranche_size() * Dimensionless(self.get_num_nonmothballed_tranches() as f64)
852    }
853
854    /// The number of tranches this asset represents
855    pub fn num_tranches(&self) -> u32 {
856        self.capacity().num_tranches()
857    }
858}
859
860#[allow(clippy::missing_fields_in_debug)]
861impl std::fmt::Debug for Asset {
862    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
863        f.debug_struct("Asset")
864            .field("state", &self.state)
865            .field("process_id", &self.process_id())
866            .field("region_id", &self.region_id)
867            .field("capacity", &self.total_capacity())
868            .field("commission_year", &self.commission_year)
869            .finish()
870    }
871}
872
873/// Whether the process operates in the specified region and year
874pub fn check_region_year_valid_for_process(
875    process: &Process,
876    region_id: &RegionID,
877    year: u32,
878) -> Result<()> {
879    ensure!(
880        process.regions.contains(region_id),
881        "Process {} does not operate in region {}",
882        process.id,
883        region_id
884    );
885    ensure!(
886        process.active_for_year(year),
887        "Process {} does not operate in the year {}",
888        process.id,
889        year
890    );
891    Ok(())
892}
893
894/// An asset defined by the user in the assets input file
895#[derive(Clone, Debug, PartialEq, derive_more::Deref, derive_more::Into)]
896pub struct UserAsset(#[deref(forward)] AssetRef);
897
898impl UserAsset {
899    /// Create a new [`UserAsset`] with an explicit capacity representation.
900    pub fn new(
901        agent_id: AgentID,
902        process: Arc<Process>,
903        region_id: RegionID,
904        capacity: AssetCapacity,
905        commission_year: u32,
906        max_decommission_year: Option<u32>,
907    ) -> Result<Self> {
908        check_capacity_valid_for_asset(capacity.total_capacity())?;
909        let asset = Asset::new_with_state(
910            AssetState::Ready {
911                agent_id,
912                commission_reason: "user input",
913            },
914            process,
915            region_id,
916            capacity,
917            commission_year,
918            max_decommission_year,
919        )?;
920
921        Ok(Self(asset.into()))
922    }
923}
924
925#[cfg(test)]
926impl From<Asset> for UserAsset {
927    fn from(asset: Asset) -> Self {
928        assert!(
929            matches!(asset.state, AssetState::Ready { .. }),
930            "User assets must be in Ready state"
931        );
932        Self(asset.into())
933    }
934}
935
936/// Whether the specified value is a valid capacity for an asset
937pub fn check_capacity_valid_for_asset(capacity: Capacity) -> Result<()> {
938    ensure!(
939        capacity.is_finite() && capacity > Capacity(0.0),
940        "Capacity must be a finite, positive number"
941    );
942    Ok(())
943}
944
945/// Log that the specified number of tranches has been decommissioned for the given asset
946fn log_decommissioning(asset: &Asset, num_tranches: u32, reason: &str) {
947    let (id, agent_id) = match &asset.state {
948        AssetState::Commissioned { id, agent_id, .. } => (*id, agent_id.clone()),
949        _ => panic!("Cannot decommission an asset that hasn't been commissioned"),
950    };
951    debug!(
952        "Decommissioning {}/{} tranches of '{}' asset (ID: {}) for agent '{}' (reason: {})",
953        num_tranches,
954        asset.num_tranches(),
955        asset.process_id(),
956        id,
957        agent_id,
958        reason
959    );
960}
961
962/// A wrapper containing a reference-counted [`Asset`].
963///
964/// [`AssetRef`] implements equality, ordering, and hashing using an [`AssetID`], if available, but
965/// otherwise using a combination of other fields which should be unique at all the relevant points
966/// in the simulation.
967#[derive(Clone, Debug, derive_more::Deref, derive_more::From, derive_more::Into)]
968pub struct AssetRef(#[deref(forward)] Arc<Asset>);
969
970impl AssetRef {
971    /// Make a mutable reference to the underlying [`Asset`]
972    pub fn make_mut(&mut self) -> &mut Asset {
973        Arc::make_mut(&mut self.0)
974    }
975
976    /// Get a representation of this [`AssetRef`] that can be used for comparisons
977    fn get_asset_cmp(&self) -> AssetCmp<'_> {
978        if let Some(id) = self.id() {
979            AssetCmp::WithID(id)
980        } else {
981            AssetCmp::WithoutID((
982                self.process_id(),
983                self.region_id(),
984                self.commission_year,
985                self.agent_id(),
986            ))
987        }
988    }
989
990    /// Get an [`AssetRef`] representing a single tranche of this asset.
991    pub fn as_single_tranche(self) -> Self {
992        assert!(
993            self.num_tranches() > 0,
994            "Cannot convert an asset with zero tranches to a single tranche"
995        );
996        let tranche_size = self.capacity().tranche_size();
997        let mut asset = self.with_no_mothballed_tranches();
998        asset
999            .make_mut()
1000            .set_capacity(AssetCapacity::new(1, tranche_size));
1001        asset
1002    }
1003
1004    /// Decommission this asset
1005    fn decommission(self, reason: &str) {
1006        log_decommissioning(&self, self.num_tranches(), reason);
1007    }
1008
1009    /// Decommission any tranches that were mothballed at least `mothball_years` ago.
1010    ///
1011    /// If the asset still has some tranches remaining, it is returned, else None.
1012    fn with_decommission_mothballed(self, year: u32, mothball_years: u32) -> Option<Self> {
1013        let events = self
1014            .get_mothball_events()
1015            .expect("Can only decommission mothballed tranches in commissioned assets");
1016
1017        // Mothball events are ordered oldest-first, so this sums the tranches mothballed longest ago
1018        let tranches_to_remove: u32 = events
1019            .iter()
1020            .take_while(|event| event.year <= year.saturating_sub(mothball_years))
1021            .map(|event| event.num_tranches)
1022            .sum();
1023        if tranches_to_remove == 0 {
1024            // Nothing to do. Return self unmodified.
1025            return Some(self);
1026        }
1027
1028        let reason = format!(
1029            "The asset has not been used for the set mothball years ({mothball_years} years)."
1030        );
1031        let new_num_tranches = self.num_tranches() - tranches_to_remove;
1032        if new_num_tranches == 0 {
1033            self.decommission(&reason);
1034            return None;
1035        }
1036
1037        log_decommissioning(&self, tranches_to_remove, &reason);
1038
1039        // Retain the newest mothball events, discarding the oldest events that have just been
1040        // decommissioned. The capacity is reduced after trimming the event history.
1041        let tranche_size = self.capacity().tranche_size();
1042        let new_num_mothballed =
1043            new_num_tranches.saturating_sub(self.get_num_nonmothballed_tranches());
1044        let mut asset = self.with_mothballed_tranches(new_num_mothballed, None);
1045        asset
1046            .make_mut()
1047            .set_capacity(AssetCapacity::new(new_num_tranches, tranche_size));
1048        Some(asset)
1049    }
1050
1051    /// Return a new [`AssetRef`] with the specified number of tranches mothballed.
1052    ///
1053    /// If `num_tranches` equals the number of already mothballed tranches, the original asset is
1054    /// returned. If additional tranches may be mothballed, a value must be provided for `year`.
1055    ///
1056    /// # Panics
1057    ///
1058    /// Panics if attempting to mothball more tranches than the asset represents or if attempting to
1059    /// change the number of mothballed tranches for a non-commissioned asset.
1060    pub fn with_mothballed_tranches(mut self, num_tranches: u32, year: Option<u32>) -> Self {
1061        if num_tranches == 0 {
1062            // Small optimisation
1063            return self.with_no_mothballed_tranches();
1064        }
1065
1066        let num_already_mothballed = self.get_num_mothballed_tranches();
1067        if num_tranches == num_already_mothballed {
1068            // Nothing to do. Return self unmodified.
1069            return self;
1070        }
1071
1072        assert!(
1073            num_tranches <= self.num_tranches(),
1074            "Cannot mothball more tranches than asset represents"
1075        );
1076
1077        // Now that we know we have to modify self, call make_mut().
1078        let events = self.make_mut().get_mothball_events_mut().expect(
1079            "Cannot change number of mothballed tranches for an asset that hasn't been commissioned",
1080        );
1081        if num_tranches < num_already_mothballed {
1082            // Remove mothballing events until only required num of mothballed tranches remains,
1083            // starting with oldest
1084            let mut remaining = num_already_mothballed - num_tranches;
1085            while remaining > 0
1086                && let Some(event) = events.front_mut()
1087            {
1088                let to_remove = event.num_tranches.min(remaining);
1089                event.num_tranches -= to_remove;
1090                remaining -= to_remove;
1091                if event.num_tranches == 0 {
1092                    events.pop_front();
1093                }
1094            }
1095        } else {
1096            let year =
1097                year.expect("Cannot increase number of mothballed tranches without supplying year");
1098
1099            if let Some(event) = events.back() {
1100                // Need to check this as adding events in the past breaks the expected invariant for
1101                // `mothball_events`
1102                assert!(
1103                    event.year <= year,
1104                    "Attempting to mothball tranches in a year in the past"
1105                );
1106            }
1107
1108            // Mothball extra tranches in specified year
1109            events.push_back(MothballEvent {
1110                year,
1111                num_tranches: num_tranches - num_already_mothballed,
1112            });
1113        }
1114
1115        self
1116    }
1117
1118    /// Returns a new [`AssetRef`] with no mothballed tranches.
1119    ///
1120    /// If the asset has no mothballed tranches, the original asset is returned.
1121    pub fn with_no_mothballed_tranches(mut self) -> Self {
1122        if self.has_any_mothballed_tranches() {
1123            // Only commissioned assets can have mothballed tranches, so this is safe
1124            self.make_mut().get_mothball_events_mut().unwrap().clear();
1125        }
1126
1127        self
1128    }
1129}
1130
1131impl From<Asset> for AssetRef {
1132    fn from(value: Asset) -> Self {
1133        Self::from(Arc::new(value))
1134    }
1135}
1136
1137impl Eq for AssetRef {}
1138
1139impl PartialEq for AssetRef {
1140    fn eq(&self, other: &Self) -> bool {
1141        self.get_asset_cmp() == other.get_asset_cmp()
1142    }
1143}
1144
1145impl Ord for AssetRef {
1146    fn cmp(&self, other: &Self) -> Ordering {
1147        self.get_asset_cmp().cmp(&other.get_asset_cmp())
1148    }
1149}
1150
1151impl PartialOrd for AssetRef {
1152    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1153        Some(self.cmp(other))
1154    }
1155}
1156
1157impl Hash for AssetRef {
1158    fn hash<H: Hasher>(&self, state: &mut H) {
1159        self.get_asset_cmp().hash(state);
1160    }
1161}
1162
1163/// A data structure representing the fields of an [`Asset`] that should be used for comparisons.
1164///
1165/// For assets that have an ID (i.e. have been commissioned at some point), we can compare based on
1166/// the ID. Otherwise, we fall back on comparing other properties. The combination of these
1167/// properties should be unique within the simulation (e.g. for assets being input into dispatch).
1168#[derive(PartialEq, PartialOrd, Eq, Ord, Hash)]
1169enum AssetCmp<'a> {
1170    WithID(AssetID),
1171    WithoutID((&'a ProcessID, &'a RegionID, u32, Option<&'a AgentID>)),
1172}
1173
1174/// Additional methods for iterating over assets
1175pub trait AssetIterator<'a>: Iterator<Item = &'a AssetRef> + Sized
1176where
1177    Self: 'a,
1178{
1179    /// Filter assets by the agent that owns them
1180    fn filter_agent(self, agent_id: &'a AgentID) -> impl Iterator<Item = &'a AssetRef> + 'a {
1181        self.filter(move |asset| asset.agent_id() == Some(agent_id))
1182    }
1183
1184    /// Iterate over assets that have the given commodity as a primary output
1185    fn filter_primary_producers_of(
1186        self,
1187        commodity_id: &'a CommodityID,
1188    ) -> impl Iterator<Item = &'a AssetRef> + 'a {
1189        self.filter(move |asset| {
1190            asset
1191                .primary_output()
1192                .is_some_and(|flow| &flow.commodity.id == commodity_id)
1193        })
1194    }
1195
1196    /// Filter the assets by region
1197    fn filter_region(self, region_id: &'a RegionID) -> impl Iterator<Item = &'a AssetRef> + 'a {
1198        self.filter(move |asset| asset.region_id == *region_id)
1199    }
1200
1201    /// Iterate over process flows affecting the given commodity
1202    fn flows_for_commodity(
1203        self,
1204        commodity_id: &'a CommodityID,
1205    ) -> impl Iterator<Item = (&'a AssetRef, &'a ProcessFlow)> + 'a {
1206        self.filter_map(|asset| Some((asset, asset.get_flow(commodity_id)?)))
1207    }
1208}
1209
1210impl<'a, I> AssetIterator<'a> for I where I: Iterator<Item = &'a AssetRef> + Sized + 'a {}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215    use crate::commodity::Commodity;
1216    use crate::fixture::{
1217        agent_id, assert_error, assert_patched_runs_ok_simple, assert_validate_fails_with_simple,
1218        asset, multi_tranche_asset, process, process_activity_limits_map, process_flows_map,
1219        region_id, svd_commodity, time_slice, time_slice_info,
1220    };
1221    use crate::patch::FilePatch;
1222    use crate::process::{FlowType, Process, ProcessFlow};
1223    use crate::region::RegionID;
1224    use crate::time_slice::{TimeSliceID, TimeSliceInfo};
1225    use crate::units::{
1226        ActivityPerCapacity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity,
1227        MoneyPerFlow,
1228    };
1229    use float_cmp::assert_approx_eq;
1230    use indexmap::indexmap;
1231    use itertools::assert_equal;
1232    use rstest::{fixture, rstest};
1233    use std::sync::Arc;
1234
1235    /// A commissioned asset with three tranches.
1236    #[fixture]
1237    fn commissioned_multi_tranche(mut multi_tranche_asset: Asset) -> AssetRef {
1238        multi_tranche_asset.commission(AssetID(0));
1239        assert_eq!(multi_tranche_asset.num_tranches(), 3);
1240        AssetRef::from(multi_tranche_asset)
1241    }
1242
1243    #[rstest]
1244    fn get_input_cost_from_prices_works(
1245        region_id: RegionID,
1246        svd_commodity: Commodity,
1247        mut process: Process,
1248        time_slice: TimeSliceID,
1249    ) {
1250        // Update the process flows using the existing commodity fixture
1251        let commodity_rc = Arc::new(svd_commodity);
1252        let process_flow = ProcessFlow {
1253            commodity: Arc::clone(&commodity_rc),
1254            coeff: FlowPerActivity(-2.0), // Input
1255            kind: FlowType::Fixed,
1256            cost: MoneyPerFlow(0.0),
1257        };
1258        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
1259        let process_flows_map = process_flows_map(process.regions.clone(), Arc::new(process_flows));
1260        process.flows = process_flows_map;
1261
1262        // Create asset
1263        let asset = Asset::new_candidate(Arc::new(process), region_id.clone(), Capacity(1.0), 2020)
1264            .unwrap();
1265
1266        // Set input prices
1267        let mut input_prices = PriceMap::default();
1268        input_prices.insert(&commodity_rc.id, &region_id, &time_slice, MoneyPerFlow(3.0));
1269
1270        // Call function
1271        let cost = asset.get_input_cost_from_prices(&input_prices, &time_slice);
1272        // Should be -coeff * price = -(-2.0) * 3.0 = 6.0
1273        assert_approx_eq!(MoneyPerActivity, cost, MoneyPerActivity(6.0));
1274    }
1275
1276    #[rstest]
1277    fn dispatch_equivalence_ignores_capacity(asset: Asset) {
1278        let mut other = asset.clone();
1279        other.set_capacity(AssetCapacity::single(Capacity(3.0)));
1280
1281        assert!(asset.is_dispatch_equivalent(&other));
1282        assert_eq!(
1283            asset.dispatch_equivalence_hash(),
1284            other.dispatch_equivalence_hash()
1285        );
1286    }
1287
1288    #[rstest]
1289    fn dispatch_equivalence_fails_for_different_region(asset: Asset) {
1290        let mut other = asset.clone();
1291        other.region_id = "FRA".into();
1292
1293        assert!(!asset.is_dispatch_equivalent(&other));
1294    }
1295
1296    #[rstest]
1297    fn dispatch_equivalence_fails_for_different_variable_operating_cost(asset: Asset) {
1298        let mut other = asset.clone();
1299        Arc::make_mut(&mut other.process_parameter).variable_operating_cost = MoneyPerActivity(1.0);
1300
1301        assert!(!asset.is_dispatch_equivalent(&other));
1302    }
1303
1304    #[rstest]
1305    fn dispatch_equivalence_fails_for_different_activity_limits(
1306        asset: Asset,
1307        asset_with_activity_limits: Asset,
1308    ) {
1309        assert!(!asset.is_dispatch_equivalent(&asset_with_activity_limits));
1310    }
1311
1312    #[rstest]
1313    fn dispatch_equivalence_fails_for_different_flows(asset: Asset, svd_commodity: Commodity) {
1314        let commodity = Arc::new(svd_commodity);
1315        let flow = ProcessFlow {
1316            commodity: Arc::clone(&commodity),
1317            coeff: FlowPerActivity(1.0),
1318            kind: FlowType::Fixed,
1319            cost: MoneyPerFlow(0.0),
1320        };
1321
1322        let mut other = asset.clone();
1323        other.flows = Arc::new(indexmap! { commodity.id.clone() => flow });
1324
1325        assert!(!asset.is_dispatch_equivalent(&other));
1326    }
1327
1328    #[rstest]
1329    fn dispatch_equivalence_handles_separately_allocated_values(asset: Asset) {
1330        let mut other = asset.clone();
1331        other.activity_limits = Arc::new((*asset.activity_limits).clone());
1332        other.flows = Arc::new((*asset.flows).clone());
1333
1334        assert!(asset.is_dispatch_equivalent(&other));
1335        assert_eq!(
1336            asset.dispatch_equivalence_hash(),
1337            other.dispatch_equivalence_hash()
1338        );
1339    }
1340
1341    #[rstest]
1342    fn dispatch_equivalence_ignores_state(asset: Asset) {
1343        let mut other = asset.clone();
1344        other.commission(AssetID(1));
1345
1346        assert!(asset.is_dispatch_equivalent(&other));
1347        assert_eq!(
1348            asset.dispatch_equivalence_hash(),
1349            other.dispatch_equivalence_hash()
1350        );
1351    }
1352
1353    #[rstest]
1354    fn dispatch_equivalence_is_reflexive(asset: Asset) {
1355        assert!(asset.is_dispatch_equivalent(&asset));
1356        assert_eq!(
1357            asset.dispatch_equivalence_hash(),
1358            asset.dispatch_equivalence_hash()
1359        );
1360    }
1361
1362    #[fixture]
1363    fn process_with_activity_limits(
1364        mut process: Process,
1365        time_slice_info: TimeSliceInfo,
1366        time_slice: TimeSliceID,
1367    ) -> Process {
1368        // Add activity limits to the process
1369        let mut activity_limits = ActivityLimits::new_with_full_availability(&time_slice_info);
1370        activity_limits.add_time_slice_limit(time_slice, Dimensionless(0.1)..=Dimensionless(0.5));
1371        process.activity_limits =
1372            process_activity_limits_map(process.regions.clone(), activity_limits);
1373
1374        // Update cap2act
1375        process.capacity_to_activity = ActivityPerCapacity(2.0);
1376        process
1377    }
1378
1379    #[fixture]
1380    fn asset_with_activity_limits(process_with_activity_limits: Process) -> Asset {
1381        Asset::new_ready(
1382            "agent1".into(),
1383            Arc::new(process_with_activity_limits),
1384            "GBR".into(),
1385            AssetCapacity::single(Capacity(2.0)),
1386            2010,
1387        )
1388        .unwrap()
1389    }
1390
1391    #[rstest]
1392    fn asset_get_activity_per_capacity_limits(
1393        asset_with_activity_limits: Asset,
1394        time_slice: TimeSliceID,
1395    ) {
1396        // With cap2act of 2, and activity limits of 0.1..=0.5, should get 0.2..=1.0
1397        assert_eq!(
1398            asset_with_activity_limits.get_activity_per_capacity_limits(&time_slice),
1399            ActivityPerCapacity(0.2)..=ActivityPerCapacity(1.0)
1400        );
1401    }
1402
1403    #[rstest]
1404    #[case(Capacity(0.01))]
1405    #[case(Capacity(0.5))]
1406    #[case(Capacity(1.0))]
1407    #[case(Capacity(100.0))]
1408    fn user_asset_new_valid(
1409        agent_id: AgentID,
1410        process: Process,
1411        region_id: RegionID,
1412        #[case] capacity: Capacity,
1413    ) {
1414        let asset_capacity = AssetCapacity::single(capacity);
1415        let asset = UserAsset::new(
1416            agent_id,
1417            process.into(),
1418            region_id,
1419            asset_capacity,
1420            2015,
1421            None,
1422        )
1423        .unwrap();
1424        assert!(asset.id().is_none());
1425    }
1426
1427    #[rstest]
1428    #[case(Capacity(0.0))]
1429    fn user_asset_new_zero_capacity(
1430        agent_id: AgentID,
1431        process: Process,
1432        region_id: RegionID,
1433        #[case] capacity: Capacity,
1434    ) {
1435        // It's permitted to create an AssetCapacity with zero tranche_size, but this should be
1436        // rejected for UserAsset's
1437        let asset_capacity = AssetCapacity::single(capacity);
1438        assert_error!(
1439            UserAsset::new(
1440                agent_id,
1441                process.into(),
1442                region_id,
1443                asset_capacity,
1444                2015,
1445                None
1446            ),
1447            "Capacity must be a finite, positive number"
1448        );
1449    }
1450
1451    #[rstest]
1452    fn user_asset_new_invalid_commission_year(
1453        agent_id: AgentID,
1454        process: Process,
1455        region_id: RegionID,
1456    ) {
1457        assert_error!(
1458            UserAsset::new(
1459                agent_id,
1460                process.into(),
1461                region_id,
1462                AssetCapacity::single(Capacity(1.0)),
1463                2007,
1464                None
1465            ),
1466            "Process process1 does not operate in the year 2007"
1467        );
1468    }
1469
1470    #[rstest]
1471    fn user_asset_new_invalid_region(agent_id: AgentID, process: Process) {
1472        let region_id = RegionID("FRA".into());
1473        assert_error!(
1474            UserAsset::new(
1475                agent_id,
1476                process.into(),
1477                region_id,
1478                AssetCapacity::single(Capacity(1.0)),
1479                2015,
1480                None
1481            ),
1482            "Process process1 does not operate in region FRA"
1483        );
1484    }
1485
1486    #[rstest]
1487    fn asset_commission(process: Process) {
1488        // Test successful commissioning of Ready asset
1489        let mut asset = Asset::new_ready(
1490            "agent1".into(),
1491            process.into(),
1492            "GBR".into(),
1493            AssetCapacity::single(Capacity(1.0)),
1494            2020,
1495        )
1496        .unwrap();
1497        asset.commission(AssetID(2));
1498        assert!(asset.is_commissioned());
1499        assert_eq!(asset.id(), Some(AssetID(2)));
1500    }
1501
1502    #[rstest]
1503    #[should_panic(expected = "Assets with state Candidate cannot be commissioned")]
1504    fn commission_wrong_states(process: Process) {
1505        let mut asset =
1506            Asset::new_candidate(process.into(), "GBR".into(), Capacity(1.0), 2020).unwrap();
1507        asset.commission(AssetID(1));
1508    }
1509
1510    #[test]
1511    fn commission_year_before_time_horizon() {
1512        let processes_patch = FilePatch::new("processes.csv")
1513            .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,")
1514            .with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,");
1515
1516        // Check we can run model with asset commissioned before time horizon (simple starts in
1517        // 2020)
1518        let patches = vec![
1519            processes_patch.clone(),
1520            FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,1980"),
1521        ];
1522        assert_patched_runs_ok_simple!(patches);
1523
1524        // This should fail if it is not one of the years supported by the process, though
1525        let patches = vec![
1526            processes_patch,
1527            FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,1970"),
1528        ];
1529        assert_validate_fails_with_simple!(
1530            patches,
1531            "Agent A0_GEX has asset with commission year 1970, not within process GASDRV commission years: 1980..=2040"
1532        );
1533    }
1534
1535    #[test]
1536    fn commission_year_after_time_horizon() {
1537        let processes_patch = FilePatch::new("processes.csv")
1538            .with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,")
1539            .with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0,");
1540
1541        // Check we can run model with asset commissioned after time horizon (simple ends in 2040)
1542        let patches = vec![
1543            processes_patch.clone(),
1544            FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,2050"),
1545        ];
1546        assert_patched_runs_ok_simple!(patches);
1547
1548        // This should fail if it is not one of the years supported by the process, though
1549        let patches = vec![
1550            processes_patch,
1551            FilePatch::new("assets.csv").with_addition("GASDRV,GBR,A0_GEX,4002.26,2060"),
1552        ];
1553        assert_validate_fails_with_simple!(
1554            patches,
1555            "Agent A0_GEX has asset with commission year 2060, not within process GASDRV commission years: 2020..=2050"
1556        );
1557    }
1558
1559    #[rstest]
1560    #[case::none(0)]
1561    #[case::some(2)]
1562    #[case::all(3)]
1563    fn mothball_tranche_counts(commissioned_multi_tranche: AssetRef, #[case] num_mothballed: u32) {
1564        assert_eq!(commissioned_multi_tranche.num_tranches(), 3);
1565        let asset = commissioned_multi_tranche.with_mothballed_tranches(num_mothballed, Some(2020));
1566        assert_eq!(asset.get_num_mothballed_tranches(), num_mothballed);
1567        assert_eq!(
1568            asset.mothballed_capacity(),
1569            Capacity(4.0 * num_mothballed as f64)
1570        );
1571        assert_eq!(asset.get_num_nonmothballed_tranches(), 3 - num_mothballed);
1572        assert_eq!(asset.has_any_mothballed_tranches(), num_mothballed > 0);
1573    }
1574
1575    #[rstest]
1576    fn mothball_counts_non_commissioned(asset: Asset, process: Process) {
1577        // Non-commissioned assets never have mothballed tranches, regardless of state
1578        let ready = AssetRef::from(asset);
1579        let candidate = AssetRef::from(
1580            Asset::new_candidate(process.into(), "GBR".into(), Capacity(1.0), 2020).unwrap(),
1581        );
1582        for asset in [ready, candidate] {
1583            assert!(!asset.has_any_mothballed_tranches());
1584            assert_eq!(asset.get_num_mothballed_tranches(), 0);
1585            assert_eq!(asset.get_num_nonmothballed_tranches(), asset.num_tranches());
1586        }
1587    }
1588
1589    #[rstest]
1590    fn with_mothballed_tranches_accumulates_events(commissioned_multi_tranche: AssetRef) {
1591        // Mothball one tranche in 2020
1592        let asset = commissioned_multi_tranche.with_mothballed_tranches(1, Some(2020));
1593        assert_equal(
1594            asset.get_mothball_events().unwrap().iter(),
1595            &[MothballEvent {
1596                year: 2020,
1597                num_tranches: 1,
1598            }],
1599        );
1600
1601        // Mothball a second tranche in 2022: events are retained in chronological order
1602        let asset = asset.with_mothballed_tranches(2, Some(2022));
1603        assert_equal(
1604            asset.get_mothball_events().unwrap().iter(),
1605            &[
1606                MothballEvent {
1607                    year: 2020,
1608                    num_tranches: 1,
1609                },
1610                MothballEvent {
1611                    year: 2022,
1612                    num_tranches: 1,
1613                },
1614            ],
1615        );
1616    }
1617
1618    #[rstest]
1619    fn with_mothballed_tranches_decrease_removes_oldest_first(
1620        commissioned_multi_tranche: AssetRef,
1621    ) {
1622        // Mothball 1 tranche in 2020, then 2 more (3 total) in 2022
1623        let asset = commissioned_multi_tranche
1624            .with_mothballed_tranches(1, Some(2020))
1625            .with_mothballed_tranches(3, Some(2022));
1626        assert_equal(
1627            asset.get_mothball_events().unwrap().iter(),
1628            &[
1629                MothballEvent {
1630                    year: 2020,
1631                    num_tranches: 1,
1632                },
1633                MothballEvent {
1634                    year: 2022,
1635                    num_tranches: 2,
1636                },
1637            ],
1638        );
1639
1640        // Reduce to a single mothballed tranche: the oldest event is fully removed and the newer
1641        // event is partially reduced, leaving exactly one mothballed tranche
1642        let asset = asset.with_mothballed_tranches(1, None);
1643        assert_eq!(asset.get_num_mothballed_tranches(), 1);
1644        assert_equal(
1645            asset.get_mothball_events().unwrap().iter(),
1646            &[MothballEvent {
1647                year: 2022,
1648                num_tranches: 1,
1649            }],
1650        );
1651    }
1652
1653    #[rstest]
1654    fn with_mothballed_tranches_noop_returns_same_rc(commissioned_multi_tranche: AssetRef) {
1655        let asset = commissioned_multi_tranche.with_mothballed_tranches(2, Some(2020));
1656        // Requesting the same number of mothballed tranches is a no-op (the year is ignored)
1657        let same = asset.clone().with_mothballed_tranches(2, Some(2099));
1658        assert!(Arc::ptr_eq(&asset.0, &same.0));
1659    }
1660
1661    #[rstest]
1662    fn with_mothballed_tranches_zero_unmothballs(commissioned_multi_tranche: AssetRef) {
1663        let asset = commissioned_multi_tranche.with_mothballed_tranches(2, Some(2020));
1664        assert!(asset.has_any_mothballed_tranches());
1665
1666        let asset = asset.with_mothballed_tranches(0, None);
1667        assert!(!asset.has_any_mothballed_tranches());
1668        assert_eq!(asset.get_num_mothballed_tranches(), 0);
1669    }
1670
1671    #[rstest]
1672    #[should_panic(expected = "Cannot mothball more tranches than asset represents")]
1673    fn with_mothballed_tranches_panics_for_too_many_tranches(commissioned_multi_tranche: AssetRef) {
1674        commissioned_multi_tranche.with_mothballed_tranches(4, Some(2020));
1675    }
1676
1677    #[rstest]
1678    #[should_panic(
1679        expected = "Cannot change number of mothballed tranches for an asset that hasn't been commissioned"
1680    )]
1681    fn with_mothballed_tranches_panics_for_non_commissioned(asset: Asset) {
1682        AssetRef::from(asset).with_mothballed_tranches(1, Some(2020));
1683    }
1684
1685    #[rstest]
1686    #[should_panic(
1687        expected = "Cannot increase number of mothballed tranches without supplying year"
1688    )]
1689    fn with_mothballed_tranches_panics_when_increasing_without_year(
1690        commissioned_multi_tranche: AssetRef,
1691    ) {
1692        commissioned_multi_tranche.with_mothballed_tranches(1, None);
1693    }
1694
1695    #[rstest]
1696    #[should_panic(expected = "Attempting to mothball tranches in a year in the past")]
1697    fn with_mothballed_tranches_panics_when_mothballing_in_the_past(
1698        commissioned_multi_tranche: AssetRef,
1699    ) {
1700        // Mothball a tranche in 2020, then attempt to mothball another in an earlier year, which would
1701        // break the chronological ordering invariant of the mothball events
1702        commissioned_multi_tranche
1703            .with_mothballed_tranches(1, Some(2020))
1704            .with_mothballed_tranches(2, Some(2019));
1705    }
1706
1707    #[rstest]
1708    fn with_no_mothballed_tranches_clears_events(commissioned_multi_tranche: AssetRef) {
1709        let asset = commissioned_multi_tranche.with_mothballed_tranches(2, Some(2020));
1710        let asset = asset.with_no_mothballed_tranches();
1711        assert!(!asset.has_any_mothballed_tranches());
1712        assert_eq!(asset.get_num_mothballed_tranches(), 0);
1713    }
1714
1715    #[rstest]
1716    fn with_no_mothballed_tranches_noop_returns_same_rc(commissioned_multi_tranche: AssetRef) {
1717        // `commissioned_multi_tranche` has no mothballed tranches, so the original Rc is returned unchanged
1718        let asset = commissioned_multi_tranche;
1719        let same = asset.clone().with_no_mothballed_tranches();
1720        assert!(Arc::ptr_eq(&asset.0, &same.0));
1721    }
1722
1723    #[rstest]
1724    fn with_decommission_mothballed_nothing_old_enough(commissioned_multi_tranche: AssetRef) {
1725        let asset = commissioned_multi_tranche.with_mothballed_tranches(1, Some(2020));
1726        // Threshold is 2005, so the 2020 event is not old enough: the asset is returned unchanged
1727        let result = asset
1728            .clone()
1729            .with_decommission_mothballed(2025, 20)
1730            .unwrap();
1731        assert!(Arc::ptr_eq(&asset.0, &result.0));
1732    }
1733
1734    #[rstest]
1735    fn with_decommission_mothballed_partial(commissioned_multi_tranche: AssetRef) {
1736        // Mothball 1 tranche in 2010 and 1 tranche in 2020 (leaving 1 tranche active)
1737        let asset = commissioned_multi_tranche
1738            .with_mothballed_tranches(1, Some(2010))
1739            .with_mothballed_tranches(2, Some(2020));
1740
1741        // With a threshold of 2015, only the 2010 event is old enough to decommission
1742        let result = asset
1743            .clone()
1744            .with_decommission_mothballed(2025, 10)
1745            .unwrap();
1746        assert_eq!(result.num_tranches(), 2);
1747        assert_eq!(result.capacity(), AssetCapacity::new(2, Capacity(4.0)));
1748        assert_eq!(result.id(), asset.id());
1749        assert_eq!(result.agent_id(), asset.agent_id());
1750        assert_eq!(result.get_num_mothballed_tranches(), 1);
1751        assert_equal(
1752            result.get_mothball_events().unwrap().iter(),
1753            &[MothballEvent {
1754                year: 2020,
1755                num_tranches: 1,
1756            }],
1757        );
1758    }
1759
1760    #[rstest]
1761    fn with_decommission_mothballed_all(commissioned_multi_tranche: AssetRef) {
1762        // All tranches mothballed long enough ago: the whole asset is decommissioned
1763        let asset = commissioned_multi_tranche.with_mothballed_tranches(3, Some(2010));
1764        assert!(asset.with_decommission_mothballed(2025, 10).is_none());
1765    }
1766}