Skip to main content

muse2/simulation/investment/
appraisal.rs

1//! Calculation for investment tools such as Levelised Cost of X (LCOX) and Net Present Value (NPV).
2use super::DemandMap;
3use crate::agent::ObjectiveType;
4use crate::asset::{Asset, AssetRef};
5use crate::commodity::Commodity;
6use crate::finance::{lcox, snas};
7use crate::model::Model;
8use crate::time_slice::TimeSliceID;
9use crate::units::{Activity, MoneyPerActivity, MoneyPerCapacity};
10use anyhow::Result;
11use costs::annual_fixed_cost;
12use erased_serde::Serialize as ErasedSerialize;
13use indexmap::IndexMap;
14use optimisation::ResultsMap;
15use serde::Serialize;
16use std::any::Any;
17use std::cmp::Ordering;
18use std::rc::Rc;
19
20pub mod coefficients;
21mod constraints;
22mod costs;
23mod optimisation;
24use coefficients::ObjectiveCoefficients;
25use float_cmp::{ApproxEq, F64Margin};
26use optimisation::perform_optimisation;
27
28/// Compares two values with approximate equality checking.
29///
30/// Returns `Ordering::Equal` if the values are approximately equal
31/// according to the default floating-point margin, otherwise returns
32/// their relative ordering based on `a.partial_cmp(&b)`.
33///
34/// This is useful when comparing floating-point-based types where exact
35/// equality may not be appropriate due to numerical precision limitations.
36///
37/// # Panics
38///
39/// Panics if `partial_cmp` returns `None` (i.e., if either value is NaN).
40fn compare_approx<T>(a: T, b: T) -> Ordering
41where
42    T: Copy + PartialOrd + ApproxEq<Margin = F64Margin>,
43{
44    if a.approx_eq(b, F64Margin::default()) {
45        Ordering::Equal
46    } else {
47        a.partial_cmp(&b).expect("Cannot compare NaN values")
48    }
49}
50
51/// The output of investment appraisal required to compare potential investment decisions
52pub struct AppraisalOutput {
53    /// The asset being appraised
54    pub asset: AssetRef,
55    /// Time slice level activity of the asset
56    pub activity: IndexMap<TimeSliceID, Activity>,
57    /// The hypothetical unmet demand following investment in this asset
58    pub unmet_demand: DemandMap,
59    /// The comparison metric to compare investment decisions
60    pub metric: Option<Box<dyn MetricTrait>>,
61    /// Activity coefficients and market costs used in the appraisal
62    pub coefficients: Rc<ObjectiveCoefficients>,
63}
64
65impl AppraisalOutput {
66    /// Create a new `AppraisalOutput`
67    fn new<T: MetricTrait>(
68        asset: AssetRef,
69        results: ResultsMap,
70        metric: Option<T>,
71        coefficients: Rc<ObjectiveCoefficients>,
72    ) -> Self {
73        Self {
74            asset,
75            activity: results.activity,
76            unmet_demand: results.unmet_demand,
77            metric: metric.map(|m| Box::new(m) as Box<dyn MetricTrait>),
78            coefficients,
79        }
80    }
81    /// Compare this appraisal to another on the basis of the comparison metric.
82    ///
83    /// Note that if the metrics are approximately equal, then [`Ordering::Equal`] is returned.
84    /// The reason for this is because different CPU architectures may lead to subtly different
85    /// values for the comparison metrics and if the value is very similar to another, then it can
86    /// lead to different decisions being made, depending on the user's platform (e.g. macOS ARM
87    /// vs. Windows). We want to avoid this, if possible, which is why we use a more approximate
88    /// comparison.
89    pub fn compare_metric(&self, other: &Self) -> Ordering {
90        let (metric1, metric2) = self
91            .metric
92            .as_deref()
93            .zip(other.metric.as_deref())
94            .expect("Cannot compare non-valid outputs");
95
96        // We've already checked the metrics aren't `None`
97        metric1.compare(metric2)
98    }
99}
100
101/// Supertrait for appraisal metrics that can be serialised and compared.
102pub trait MetricTrait: ComparableMetric + ErasedSerialize {}
103erased_serde::serialize_trait_object!(MetricTrait);
104
105/// Trait for appraisal metrics that can be compared.
106///
107/// Implementers define how their values should be compared to determine
108/// which investment option is preferable through the `compare` method.
109pub trait ComparableMetric: Any + Send + Sync {
110    /// Returns the numeric value of this metric.
111    fn value(&self) -> f64;
112
113    /// Compares this metric with another of the same type.
114    ///
115    /// Returns `Ordering::Less` if `self` is better than `other`,
116    /// `Ordering::Greater` if `other` is better, or `Ordering::Equal`
117    /// if they are approximately equal.
118    ///
119    /// # Panics
120    ///
121    /// Panics if `other` is not the same concrete type as `self`.
122    fn compare(&self, other: &dyn ComparableMetric) -> Ordering;
123
124    /// Helper for downcasting to enable type-safe comparison.
125    fn as_any(&self) -> &dyn Any;
126}
127
128/// Levelised Cost of X (LCOX) metric.
129///
130/// Represents the average cost per unit of output. Lower values indicate
131/// more cost-effective investments.
132#[derive(Debug, Clone, Serialize)]
133pub struct LCOXMetric {
134    /// The calculated cost value for this LCOX metric
135    pub cost: MoneyPerActivity,
136}
137
138impl LCOXMetric {
139    /// Creates a new `LCOXMetric` with the given cost.
140    pub fn new(cost: MoneyPerActivity) -> Self {
141        Self { cost }
142    }
143}
144
145impl ComparableMetric for LCOXMetric {
146    fn value(&self) -> f64 {
147        self.cost.value()
148    }
149
150    fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
151        let other = other
152            .as_any()
153            .downcast_ref::<Self>()
154            .expect("Cannot compare metrics of different types");
155
156        compare_approx(self.cost, other.cost)
157    }
158
159    fn as_any(&self) -> &dyn Any {
160        self
161    }
162}
163
164/// `LCOXMetric` implements the `MetricTrait` supertrait.
165impl MetricTrait for LCOXMetric {}
166
167/// Net Present Value (NPV) tool metric.
168///
169/// In the NPV appraisal tool we compare options using the Specific Net Annualised Surplus (SNAS)
170/// expressed per unit activity. Higher values indicate more profitable investments.
171#[derive(Debug, Clone, Serialize)]
172pub struct NPVMetric {
173    /// The calculated SNAS value for this metric
174    pub snas: MoneyPerActivity,
175}
176
177impl NPVMetric {
178    /// Creates a new `NPVMetric` with the given SNAS value.
179    pub fn new(snas: MoneyPerActivity) -> Self {
180        Self { snas }
181    }
182}
183
184impl ComparableMetric for NPVMetric {
185    fn value(&self) -> f64 {
186        self.snas.value()
187    }
188
189    fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
190        let other = other
191            .as_any()
192            .downcast_ref::<Self>()
193            .expect("Cannot compare metrics of different types");
194
195        compare_approx(other.snas, self.snas)
196    }
197
198    fn as_any(&self) -> &dyn Any {
199        self
200    }
201}
202
203/// `NPVMetric` implements the `MetricTrait` supertrait.
204impl MetricTrait for NPVMetric {}
205
206/// Calculate LCOX for a hypothetical investment in the given asset.
207///
208/// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can
209/// include other flows, we use the term LCOX.
210///
211/// # Returns
212///
213/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand.
214/// The returned `metric` is the LCOX value (lower is better).
215fn calculate_lcox(
216    model: &Model,
217    asset: &AssetRef,
218    commodity: &Commodity,
219    coefficients: &Rc<ObjectiveCoefficients>,
220    demand: &DemandMap,
221) -> Result<AppraisalOutput> {
222    let results = perform_optimisation(model, asset, commodity, coefficients, demand)?;
223
224    let cost_index = lcox(
225        asset.capacity().total_capacity(),
226        annual_fixed_cost(asset),
227        &results.activity,
228        &coefficients.market_costs,
229    );
230
231    Ok(AppraisalOutput::new(
232        asset.clone(),
233        results,
234        cost_index.map(LCOXMetric::new),
235        coefficients.clone(),
236    ))
237}
238
239/// Calculate NPV for a hypothetical investment in the given asset.
240///
241/// # Returns
242///
243/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand.
244fn calculate_npv(
245    model: &Model,
246    asset: &AssetRef,
247    commodity: &Commodity,
248    coefficients: &Rc<ObjectiveCoefficients>,
249    demand: &DemandMap,
250) -> Result<AppraisalOutput> {
251    let results = perform_optimisation(model, asset, commodity, coefficients, demand)?;
252
253    let annual_fixed_cost = annual_fixed_cost(asset);
254    assert!(
255        annual_fixed_cost >= MoneyPerCapacity(0.0),
256        "The current NPV calculation does not support negative annual fixed costs"
257    );
258
259    let snas = snas(
260        asset.capacity().total_capacity(),
261        annual_fixed_cost,
262        &results.activity,
263        &coefficients.market_costs,
264    );
265
266    Ok(AppraisalOutput::new(
267        asset.clone(),
268        results,
269        snas.map(NPVMetric::new),
270        coefficients.clone(),
271    ))
272}
273
274/// Appraise the given investment with the specified objective type.
275///
276/// # Returns
277///
278/// The `AppraisalOutput` produced by the selected appraisal method. The `metric` field is
279/// comparable with other appraisals of the same type (npv/lcox).
280pub fn appraise_investment(
281    model: &Model,
282    asset: &AssetRef,
283    commodity: &Commodity,
284    objective_type: &ObjectiveType,
285    coefficients: &Rc<ObjectiveCoefficients>,
286    demand: &DemandMap,
287) -> Result<AppraisalOutput> {
288    let appraisal_method = match objective_type {
289        ObjectiveType::LevelisedCostOfX => calculate_lcox,
290        ObjectiveType::NetPresentValue => calculate_npv,
291    };
292    appraisal_method(model, asset, commodity, coefficients, demand)
293}
294
295/// Compare assets as a fallback if metrics are equal.
296///
297/// Commissioned assets are ordered before uncommissioned and newer before older.
298///
299/// Used as a fallback to sort assets when they have equal appraisal tool outputs.
300fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering {
301    (asset2.is_commissioned(), asset2.commission_year())
302        .cmp(&(asset1.is_commissioned(), asset1.commission_year()))
303}
304
305/// Sort appraisal outputs by their investment priority and exclude non-feasible options.
306///
307/// Investment priority is primarily decided by appraisal metric. When appraisal metrics are equal,
308/// a tie-breaker fallback is used. Commissioned assets are preferred over uncommissioned assets,
309/// and newer assets are preferred over older ones. The function does not guarantee that all ties
310/// will be resolved.
311///
312/// Before sorting, outputs are filtered to exclude entries with invalid metrics (i.e. `None`), so
313/// the length of the returned vector may be less than the input.
314///
315/// # Returns
316///
317/// Returns the number of non-feasible assets which were removed.
318pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec<AppraisalOutput>) -> usize {
319    let old_len = outputs.len();
320    outputs.retain(|output| output.metric.is_some());
321    let num_nonfeasible = old_len - outputs.len();
322
323    outputs.sort_by(|output1, output2| match output1.compare_metric(output2) {
324        // If equal, we fall back on comparing asset properties
325        Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset),
326        cmp => cmp,
327    });
328
329    num_nonfeasible
330}
331
332/// Counts the number of top appraisal outputs in a sorted slice that are indistinguishable
333/// by both metric and fallback ordering. Excludes the first element from the count.
334pub fn count_equal_and_best_appraisal_outputs(outputs: &[AppraisalOutput]) -> usize {
335    if outputs.is_empty() {
336        return 0;
337    }
338    outputs[1..]
339        .iter()
340        .take_while(|output| {
341            output.compare_metric(&outputs[0]).is_eq()
342                && compare_asset_fallback(&output.asset, &outputs[0].asset).is_eq()
343        })
344        .count()
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::agent::AgentID;
351    use crate::fixture::{agent_id, asset, process, region_id};
352    use crate::process::Process;
353    use crate::region::RegionID;
354    use crate::units::{Capacity, MoneyPerActivity};
355    use float_cmp::assert_approx_eq;
356    use rstest::rstest;
357    use std::rc::Rc;
358
359    /// Parametrised tests for LCOX metric comparison.
360    #[rstest]
361    #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
362    #[case(5.0, 10.0, Ordering::Less, "first_lower_cost_is_better")]
363    #[case(10.0, 5.0, Ordering::Greater, "second_lower_cost_is_better")]
364    fn lcox_metric_comparison(
365        #[case] cost1: f64,
366        #[case] cost2: f64,
367        #[case] expected: Ordering,
368        #[case] description: &str,
369    ) {
370        let metric1 = LCOXMetric::new(MoneyPerActivity(cost1));
371        let metric2 = LCOXMetric::new(MoneyPerActivity(cost2));
372
373        assert_eq!(
374            metric1.compare(&metric2),
375            expected,
376            "Failed comparison for case: {description}"
377        );
378    }
379
380    /// Parametrised tests for NPV metric comparison.
381    #[rstest]
382    #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
383    #[case(5.0, 10.0, Ordering::Greater, "second_higher_metric_is_better")]
384    #[case(10.0, 5.0, Ordering::Less, "first_higher_metric_is_better")]
385    fn npv_metric_comparison(
386        #[case] cost1: f64,
387        #[case] cost2: f64,
388        #[case] expected: Ordering,
389        #[case] description: &str,
390    ) {
391        let metric1 = NPVMetric::new(MoneyPerActivity(cost1));
392        let metric2 = NPVMetric::new(MoneyPerActivity(cost2));
393
394        assert_eq!(
395            metric1.compare(&metric2),
396            expected,
397            "Failed comparison for case: {description}"
398        );
399    }
400
401    #[rstest]
402    fn compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) {
403        let process = Rc::new(process);
404        let capacity = Capacity(2.0);
405        let asset1 = Asset::new_commissioned(
406            agent_id.clone(),
407            process.clone(),
408            region_id.clone(),
409            capacity,
410            2015,
411        )
412        .unwrap();
413        let asset2 =
414            Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap();
415        let asset3 =
416            Asset::new_commissioned(agent_id, process, region_id.clone(), capacity, 2010).unwrap();
417
418        assert!(compare_asset_fallback(&asset1, &asset1).is_eq());
419        assert!(compare_asset_fallback(&asset2, &asset2).is_eq());
420        assert!(compare_asset_fallback(&asset3, &asset3).is_eq());
421        assert!(compare_asset_fallback(&asset1, &asset2).is_lt());
422        assert!(compare_asset_fallback(&asset2, &asset1).is_gt());
423        assert!(compare_asset_fallback(&asset1, &asset3).is_lt());
424        assert!(compare_asset_fallback(&asset3, &asset1).is_gt());
425        assert!(compare_asset_fallback(&asset3, &asset2).is_lt());
426        assert!(compare_asset_fallback(&asset2, &asset3).is_gt());
427    }
428
429    fn objective_coeffs() -> Rc<ObjectiveCoefficients> {
430        Rc::new(ObjectiveCoefficients {
431            activity_coefficients: IndexMap::new(),
432            market_costs: IndexMap::new(),
433        })
434    }
435
436    /// Creates appraisal from corresponding assets and metrics
437    ///
438    /// # Panics
439    ///
440    /// Panics if `assets` and `metrics` have different lengths
441    fn appraisal_outputs(
442        assets: Vec<Asset>,
443        metrics: Vec<Box<dyn MetricTrait>>,
444    ) -> Vec<AppraisalOutput> {
445        assert_eq!(
446            assets.len(),
447            metrics.len(),
448            "assets and metrics must have the same length"
449        );
450
451        assets
452            .into_iter()
453            .zip(metrics)
454            .map(|(asset, metric)| AppraisalOutput {
455                asset: AssetRef::from(asset),
456                coefficients: objective_coeffs(),
457                activity: IndexMap::new(),
458                unmet_demand: IndexMap::new(),
459                metric: Some(metric),
460            })
461            .collect()
462    }
463
464    /// Creates appraisal outputs with given metrics.
465    /// Copies the provided default asset for each metric.
466    fn appraisal_outputs_with_investment_priority_invariant_to_assets(
467        metrics: Vec<Box<dyn MetricTrait>>,
468        asset: &Asset,
469    ) -> Vec<AppraisalOutput> {
470        let assets = vec![asset.clone(); metrics.len()];
471        appraisal_outputs(assets, metrics)
472    }
473
474    /// Test sorting by LCOX metric when invariant to asset properties
475    #[rstest]
476    fn appraisal_sort_by_lcox_metric(asset: Asset) {
477        let metrics: Vec<Box<dyn MetricTrait>> = vec![
478            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
479            Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
480            Box::new(LCOXMetric::new(MoneyPerActivity(7.0))),
481        ];
482
483        let mut outputs =
484            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
485        sort_and_filter_appraisal_outputs(&mut outputs);
486
487        assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 3.0); // Best (lowest)
488        assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0);
489        assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 7.0); // Worst (highest)
490    }
491
492    /// Test sorting by NPV metric when invariant to asset properties
493    #[rstest]
494    fn appraisal_sort_by_npv_metric(asset: Asset) {
495        let metrics: Vec<Box<dyn MetricTrait>> = vec![
496            Box::new(NPVMetric::new(MoneyPerActivity(5.0))),
497            Box::new(NPVMetric::new(MoneyPerActivity(3.0))),
498            Box::new(NPVMetric::new(MoneyPerActivity(7.0))),
499        ];
500
501        let mut outputs =
502            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
503        sort_and_filter_appraisal_outputs(&mut outputs);
504
505        assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 7.0); // Best (highest)
506        assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0);
507        assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 3.0); // Worst (lowest)
508    }
509
510    /// Test that mixing LCOX and NPV metrics causes a runtime panic during comparison
511    #[rstest]
512    #[should_panic(expected = "Cannot compare metrics of different types")]
513    fn appraisal_sort_by_mixed_metrics_panics(asset: Asset) {
514        let metrics: Vec<Box<dyn MetricTrait>> = vec![
515            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
516            Box::new(NPVMetric::new(MoneyPerActivity(3.0))),
517            Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
518        ];
519
520        let mut outputs =
521            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
522        // This should panic when trying to compare different metric types
523        sort_and_filter_appraisal_outputs(&mut outputs);
524    }
525
526    /// Test that when metrics are equal, commissioned assets are sorted by commission year (newer first)
527    #[rstest]
528    fn appraisal_sort_by_commission_year_when_metrics_equal(
529        process: Process,
530        region_id: RegionID,
531        agent_id: AgentID,
532    ) {
533        let process_rc = Rc::new(process);
534        let capacity = Capacity(10.0);
535        let commission_years = [2015, 2020, 2010];
536
537        let assets: Vec<_> = commission_years
538            .iter()
539            .map(|&year| {
540                Asset::new_commissioned(
541                    agent_id.clone(),
542                    process_rc.clone(),
543                    region_id.clone(),
544                    capacity,
545                    year,
546                )
547                .unwrap()
548            })
549            .collect();
550
551        // All metrics have the same value
552        let metrics: Vec<Box<dyn MetricTrait>> = vec![
553            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
554            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
555            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
556        ];
557
558        let mut outputs = appraisal_outputs(assets, metrics);
559        sort_and_filter_appraisal_outputs(&mut outputs);
560
561        // Should be sorted by commission year, newest first: 2020, 2015, 2010
562        assert_eq!(outputs[0].asset.commission_year(), 2020);
563        assert_eq!(outputs[1].asset.commission_year(), 2015);
564        assert_eq!(outputs[2].asset.commission_year(), 2010);
565    }
566
567    /// Test that when metrics and commission years are equal, the original order is preserved
568    #[rstest]
569    fn appraisal_sort_maintains_order_when_all_equal(process: Process, region_id: RegionID) {
570        let process_rc = Rc::new(process);
571        let capacity = Capacity(10.0);
572        let commission_year = 2015;
573        let agent_ids = ["agent1", "agent2", "agent3"];
574
575        let assets: Vec<_> = agent_ids
576            .iter()
577            .map(|&id| {
578                Asset::new_commissioned(
579                    AgentID(id.into()),
580                    process_rc.clone(),
581                    region_id.clone(),
582                    capacity,
583                    commission_year,
584                )
585                .unwrap()
586            })
587            .collect();
588
589        let metrics: Vec<Box<dyn MetricTrait>> = vec![
590            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
591            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
592            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
593        ];
594
595        let mut outputs = appraisal_outputs(assets.clone(), metrics);
596        sort_and_filter_appraisal_outputs(&mut outputs);
597
598        // Verify order is preserved - should match the original agent_ids array
599        for (&expected_id, output) in agent_ids.iter().zip(outputs) {
600            assert_eq!(output.asset.agent_id(), Some(&AgentID(expected_id.into())));
601        }
602    }
603
604    /// Test that commissioned assets are prioritised over non-commissioned assets when metrics are equal
605    #[rstest]
606    fn appraisal_sort_commissioned_before_uncommissioned_when_metrics_equal(
607        process: Process,
608        region_id: RegionID,
609        agent_id: AgentID,
610    ) {
611        let process_rc = Rc::new(process);
612        let capacity = Capacity(10.0);
613
614        // Create a mix of commissioned and candidate (non-commissioned) assets
615        let commissioned_asset_newer = Asset::new_commissioned(
616            agent_id.clone(),
617            process_rc.clone(),
618            region_id.clone(),
619            capacity,
620            2020,
621        )
622        .unwrap();
623
624        let commissioned_asset_older = Asset::new_commissioned(
625            agent_id.clone(),
626            process_rc.clone(),
627            region_id.clone(),
628            capacity,
629            2015,
630        )
631        .unwrap();
632
633        let candidate_asset =
634            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
635
636        let assets = vec![
637            candidate_asset.clone(),
638            commissioned_asset_older.clone(),
639            candidate_asset.clone(),
640            commissioned_asset_newer.clone(),
641        ];
642
643        // All metrics have identical values to test fallback ordering
644        let metrics: Vec<Box<dyn MetricTrait>> = vec![
645            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
646            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
647            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
648            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
649        ];
650
651        let mut outputs = appraisal_outputs(assets, metrics);
652        sort_and_filter_appraisal_outputs(&mut outputs);
653
654        // Commissioned assets should be prioritised first
655        assert!(outputs[0].asset.is_commissioned());
656        assert_eq!(outputs[0].asset.commission_year(), 2020);
657        assert!(outputs[1].asset.is_commissioned());
658        assert_eq!(outputs[1].asset.commission_year(), 2015);
659
660        // Non-commissioned assets should come after
661        assert!(!outputs[2].asset.is_commissioned());
662        assert!(!outputs[3].asset.is_commissioned());
663    }
664
665    /// Test that appraisal metric is prioritised over asset properties when sorting
666    #[rstest]
667    fn appraisal_metric_is_prioritised_over_asset_properties(
668        process: Process,
669        region_id: RegionID,
670        agent_id: AgentID,
671    ) {
672        let process_rc = Rc::new(process);
673        let capacity = Capacity(10.0);
674
675        // Create a mix of commissioned and candidate (non-commissioned) assets
676        let commissioned_asset_newer = Asset::new_commissioned(
677            agent_id.clone(),
678            process_rc.clone(),
679            region_id.clone(),
680            capacity,
681            2020,
682        )
683        .unwrap();
684
685        let commissioned_asset_older = Asset::new_commissioned(
686            agent_id.clone(),
687            process_rc.clone(),
688            region_id.clone(),
689            capacity,
690            2015,
691        )
692        .unwrap();
693
694        let candidate_asset =
695            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
696
697        let assets = vec![
698            candidate_asset.clone(),
699            commissioned_asset_older.clone(),
700            candidate_asset.clone(),
701            commissioned_asset_newer.clone(),
702        ];
703
704        // Make one metric slightly better than all others
705        let baseline_metric_value = 5.0;
706        let best_metric_value = baseline_metric_value - 1e-5;
707        let metrics: Vec<Box<dyn MetricTrait>> = vec![
708            Box::new(LCOXMetric::new(MoneyPerActivity(best_metric_value))),
709            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
710            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
711            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
712        ];
713
714        let mut outputs = appraisal_outputs(assets, metrics);
715        sort_and_filter_appraisal_outputs(&mut outputs);
716
717        // non-commissioned asset prioritised because it has a slightly better metric
718        assert_approx_eq!(
719            f64,
720            outputs[0].metric.as_ref().unwrap().value(),
721            best_metric_value
722        );
723    }
724
725    /// Test that appraisal outputs with an invalid metric are filtered out
726    #[rstest]
727    fn appraisal_sort_filters_invalid_metric(asset: Asset) {
728        let output = AppraisalOutput {
729            asset: AssetRef::from(asset),
730            coefficients: objective_coeffs(),
731            activity: IndexMap::new(),
732            unmet_demand: IndexMap::new(),
733            metric: None,
734        };
735        let mut outputs = vec![output];
736
737        sort_and_filter_appraisal_outputs(&mut outputs);
738
739        // The invalid output should have been filtered out
740        assert_eq!(outputs.len(), 0);
741    }
742
743    /// Tests for counting number of equal metrics using identical assets so only metric values
744    /// affect the count.
745    #[rstest]
746    #[case(vec![5.0], 0, "single_element")]
747    #[case(vec![5.0, 5.0, 5.0], 2, "all_equal_returns_len_minus_one")]
748    #[case(vec![1.0, 2.0, 3.0], 0, "none_equal_to_best")]
749    #[case(vec![5.0, 5.0, 9.0], 1, "partial_equality_stops_at_first_difference")]
750    #[case(vec![5.0, 5.0, 9.0, 5.0], 1, "equality_does_not_resume_after_gap")]
751    fn count_equal_best_lcox_metric(
752        asset: Asset,
753        #[case] metric_values: Vec<f64>,
754        #[case] expected_count: usize,
755        #[case] description: &str,
756    ) {
757        let metrics: Vec<Box<dyn MetricTrait>> = metric_values
758            .into_iter()
759            .map(|v| Box::new(LCOXMetric::new(MoneyPerActivity(v))) as Box<dyn MetricTrait>)
760            .collect();
761
762        let outputs =
763            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
764
765        assert_eq!(
766            count_equal_and_best_appraisal_outputs(&outputs),
767            expected_count,
768            "Failed for case: {description}"
769        );
770    }
771
772    /// Empty slice count should return 0.
773    #[test]
774    fn count_equal_best_empty_slice_returns_zero() {
775        let outputs: Vec<AppraisalOutput> = vec![];
776        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
777    }
778
779    /// Equal metrics but differing asset fallback (commissioned vs. candidate) →
780    /// outputs are distinguishable, so count should be 0.
781    #[rstest]
782    fn count_equal_best_equal_metric_different_fallback_returns_zero(
783        process: Process,
784        region_id: RegionID,
785        agent_id: AgentID,
786    ) {
787        let process_rc = Rc::new(process);
788        let capacity = Capacity(10.0);
789
790        let commissioned = Asset::new_commissioned(
791            agent_id.clone(),
792            process_rc.clone(),
793            region_id.clone(),
794            capacity,
795            2020,
796        )
797        .unwrap();
798        let candidate =
799            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
800
801        let metric_value = MoneyPerActivity(5.0);
802        let outputs = appraisal_outputs(
803            vec![commissioned, candidate],
804            vec![
805                Box::new(LCOXMetric::new(metric_value)),
806                Box::new(LCOXMetric::new(metric_value)),
807            ],
808        );
809
810        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
811    }
812
813    /// Equal metrics and equal asset fallback (same commissioned status and commission year) →
814    /// the second element is indistinguishable, so count should be 1.
815    #[rstest]
816    fn count_equal_best_equal_metric_and_equal_fallback_returns_one(
817        process: Process,
818        region_id: RegionID,
819        agent_id: AgentID,
820    ) {
821        let process_rc = Rc::new(process);
822        let capacity = Capacity(10.0);
823        let year = 2020;
824
825        let asset1 = Asset::new_commissioned(
826            agent_id.clone(),
827            process_rc.clone(),
828            region_id.clone(),
829            capacity,
830            year,
831        )
832        .unwrap();
833        let asset2 = Asset::new_commissioned(
834            agent_id.clone(),
835            process_rc.clone(),
836            region_id.clone(),
837            capacity,
838            year,
839        )
840        .unwrap();
841
842        let metric_value = MoneyPerActivity(5.0);
843        let outputs = appraisal_outputs(
844            vec![asset1, asset2],
845            vec![
846                Box::new(LCOXMetric::new(metric_value)),
847                Box::new(LCOXMetric::new(metric_value)),
848            ],
849        );
850
851        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 1);
852    }
853}