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::sync::Arc;
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: Arc<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: Arc<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: &Arc<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.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: &Arc<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.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: &Arc<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::asset::AssetCapacity;
352    use crate::fixture::{agent_id, asset, process, region_id};
353    use crate::process::Process;
354    use crate::region::RegionID;
355    use crate::units::{Capacity, MoneyPerActivity};
356    use float_cmp::assert_approx_eq;
357    use rstest::rstest;
358    use std::sync::Arc;
359
360    /// Parametrised tests for LCOX metric comparison.
361    #[rstest]
362    #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
363    #[case(5.0, 10.0, Ordering::Less, "first_lower_cost_is_better")]
364    #[case(10.0, 5.0, Ordering::Greater, "second_lower_cost_is_better")]
365    fn lcox_metric_comparison(
366        #[case] cost1: f64,
367        #[case] cost2: f64,
368        #[case] expected: Ordering,
369        #[case] description: &str,
370    ) {
371        let metric1 = LCOXMetric::new(MoneyPerActivity(cost1));
372        let metric2 = LCOXMetric::new(MoneyPerActivity(cost2));
373
374        assert_eq!(
375            metric1.compare(&metric2),
376            expected,
377            "Failed comparison for case: {description}"
378        );
379    }
380
381    /// Parametrised tests for NPV metric comparison.
382    #[rstest]
383    #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
384    #[case(5.0, 10.0, Ordering::Greater, "second_higher_metric_is_better")]
385    #[case(10.0, 5.0, Ordering::Less, "first_higher_metric_is_better")]
386    fn npv_metric_comparison(
387        #[case] cost1: f64,
388        #[case] cost2: f64,
389        #[case] expected: Ordering,
390        #[case] description: &str,
391    ) {
392        let metric1 = NPVMetric::new(MoneyPerActivity(cost1));
393        let metric2 = NPVMetric::new(MoneyPerActivity(cost2));
394
395        assert_eq!(
396            metric1.compare(&metric2),
397            expected,
398            "Failed comparison for case: {description}"
399        );
400    }
401
402    #[rstest]
403    fn compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) {
404        let capacity = Capacity(2.0);
405        let process = Arc::new(process);
406        let asset1 = Asset::new_commissioned(
407            agent_id.clone(),
408            process.clone(),
409            region_id.clone(),
410            AssetCapacity::single(capacity),
411            2015,
412        )
413        .unwrap();
414        let asset2 =
415            Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap();
416        let asset3 = Asset::new_commissioned(
417            agent_id,
418            process,
419            region_id.clone(),
420            AssetCapacity::single(capacity),
421            2010,
422        )
423        .unwrap();
424
425        assert!(compare_asset_fallback(&asset1, &asset1).is_eq());
426        assert!(compare_asset_fallback(&asset2, &asset2).is_eq());
427        assert!(compare_asset_fallback(&asset3, &asset3).is_eq());
428        assert!(compare_asset_fallback(&asset1, &asset2).is_lt());
429        assert!(compare_asset_fallback(&asset2, &asset1).is_gt());
430        assert!(compare_asset_fallback(&asset1, &asset3).is_lt());
431        assert!(compare_asset_fallback(&asset3, &asset1).is_gt());
432        assert!(compare_asset_fallback(&asset3, &asset2).is_lt());
433        assert!(compare_asset_fallback(&asset2, &asset3).is_gt());
434    }
435
436    fn objective_coeffs() -> Arc<ObjectiveCoefficients> {
437        Arc::new(ObjectiveCoefficients {
438            activity_coefficients: IndexMap::new(),
439            market_costs: IndexMap::new(),
440        })
441    }
442
443    /// Creates appraisal from corresponding assets and metrics
444    ///
445    /// # Panics
446    ///
447    /// Panics if `assets` and `metrics` have different lengths
448    fn appraisal_outputs(
449        assets: Vec<Asset>,
450        metrics: Vec<Box<dyn MetricTrait>>,
451    ) -> Vec<AppraisalOutput> {
452        assert_eq!(
453            assets.len(),
454            metrics.len(),
455            "assets and metrics must have the same length"
456        );
457
458        assets
459            .into_iter()
460            .zip(metrics)
461            .map(|(asset, metric)| AppraisalOutput {
462                asset: AssetRef::from(asset),
463                coefficients: objective_coeffs(),
464                activity: IndexMap::new(),
465                unmet_demand: IndexMap::new(),
466                metric: Some(metric),
467            })
468            .collect()
469    }
470
471    /// Creates appraisal outputs with given metrics.
472    /// Copies the provided default asset for each metric.
473    fn appraisal_outputs_with_investment_priority_invariant_to_assets(
474        metrics: Vec<Box<dyn MetricTrait>>,
475        asset: &Asset,
476    ) -> Vec<AppraisalOutput> {
477        let assets = vec![asset.clone(); metrics.len()];
478        appraisal_outputs(assets, metrics)
479    }
480
481    /// Test sorting by LCOX metric when invariant to asset properties
482    #[rstest]
483    fn appraisal_sort_by_lcox_metric(asset: Asset) {
484        let metrics: Vec<Box<dyn MetricTrait>> = vec![
485            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
486            Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
487            Box::new(LCOXMetric::new(MoneyPerActivity(7.0))),
488        ];
489
490        let mut outputs =
491            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
492        sort_and_filter_appraisal_outputs(&mut outputs);
493
494        assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 3.0); // Best (lowest)
495        assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0);
496        assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 7.0); // Worst (highest)
497    }
498
499    /// Test sorting by NPV metric when invariant to asset properties
500    #[rstest]
501    fn appraisal_sort_by_npv_metric(asset: Asset) {
502        let metrics: Vec<Box<dyn MetricTrait>> = vec![
503            Box::new(NPVMetric::new(MoneyPerActivity(5.0))),
504            Box::new(NPVMetric::new(MoneyPerActivity(3.0))),
505            Box::new(NPVMetric::new(MoneyPerActivity(7.0))),
506        ];
507
508        let mut outputs =
509            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
510        sort_and_filter_appraisal_outputs(&mut outputs);
511
512        assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 7.0); // Best (highest)
513        assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0);
514        assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 3.0); // Worst (lowest)
515    }
516
517    /// Test that mixing LCOX and NPV metrics causes a runtime panic during comparison
518    #[rstest]
519    #[should_panic(expected = "Cannot compare metrics of different types")]
520    fn appraisal_sort_by_mixed_metrics_panics(asset: Asset) {
521        let metrics: Vec<Box<dyn MetricTrait>> = vec![
522            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
523            Box::new(NPVMetric::new(MoneyPerActivity(3.0))),
524            Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
525        ];
526
527        let mut outputs =
528            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
529        // This should panic when trying to compare different metric types
530        sort_and_filter_appraisal_outputs(&mut outputs);
531    }
532
533    /// Test that when metrics are equal, commissioned assets are sorted by commission year (newer first)
534    #[rstest]
535    fn appraisal_sort_by_commission_year_when_metrics_equal(
536        process: Process,
537        region_id: RegionID,
538        agent_id: AgentID,
539    ) {
540        let process_rc = Arc::new(process);
541        let capacity = Capacity(10.0);
542        let commission_years = [2015, 2020, 2010];
543
544        let assets: Vec<_> = commission_years
545            .iter()
546            .map(|&year| {
547                Asset::new_commissioned(
548                    agent_id.clone(),
549                    process_rc.clone(),
550                    region_id.clone(),
551                    AssetCapacity::single(capacity),
552                    year,
553                )
554                .unwrap()
555            })
556            .collect();
557
558        // All metrics have the same value
559        let metrics: Vec<Box<dyn MetricTrait>> = vec![
560            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
561            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
562            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
563        ];
564
565        let mut outputs = appraisal_outputs(assets, metrics);
566        sort_and_filter_appraisal_outputs(&mut outputs);
567
568        // Should be sorted by commission year, newest first: 2020, 2015, 2010
569        assert_eq!(outputs[0].asset.commission_year(), 2020);
570        assert_eq!(outputs[1].asset.commission_year(), 2015);
571        assert_eq!(outputs[2].asset.commission_year(), 2010);
572    }
573
574    /// Test that when metrics and commission years are equal, the original order is preserved
575    #[rstest]
576    fn appraisal_sort_maintains_order_when_all_equal(process: Process, region_id: RegionID) {
577        let process_rc = Arc::new(process);
578        let capacity = AssetCapacity::single(Capacity(10.0));
579        let commission_year = 2015;
580        let agent_ids = ["agent1", "agent2", "agent3"];
581
582        let assets: Vec<_> = agent_ids
583            .iter()
584            .map(|&id| {
585                Asset::new_commissioned(
586                    AgentID(id.into()),
587                    process_rc.clone(),
588                    region_id.clone(),
589                    capacity,
590                    commission_year,
591                )
592                .unwrap()
593            })
594            .collect();
595
596        let metrics: Vec<Box<dyn MetricTrait>> = vec![
597            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
598            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
599            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
600        ];
601
602        let mut outputs = appraisal_outputs(assets.clone(), metrics);
603        sort_and_filter_appraisal_outputs(&mut outputs);
604
605        // Verify order is preserved - should match the original agent_ids array
606        for (&expected_id, output) in agent_ids.iter().zip(outputs) {
607            assert_eq!(output.asset.agent_id(), Some(&AgentID(expected_id.into())));
608        }
609    }
610
611    /// Test that commissioned assets are prioritised over non-commissioned assets when metrics are equal
612    #[rstest]
613    fn appraisal_sort_commissioned_before_uncommissioned_when_metrics_equal(
614        process: Process,
615        region_id: RegionID,
616        agent_id: AgentID,
617    ) {
618        let process_rc = Arc::new(process);
619        let capacity = Capacity(10.0);
620
621        // Create a mix of commissioned and candidate (non-commissioned) assets
622        let commissioned_asset_newer = Asset::new_commissioned(
623            agent_id.clone(),
624            process_rc.clone(),
625            region_id.clone(),
626            AssetCapacity::single(capacity),
627            2020,
628        )
629        .unwrap();
630
631        let commissioned_asset_older = Asset::new_commissioned(
632            agent_id.clone(),
633            process_rc.clone(),
634            region_id.clone(),
635            AssetCapacity::single(capacity),
636            2015,
637        )
638        .unwrap();
639
640        let candidate_asset =
641            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
642
643        let assets = vec![
644            candidate_asset.clone(),
645            commissioned_asset_older.clone(),
646            candidate_asset.clone(),
647            commissioned_asset_newer.clone(),
648        ];
649
650        // All metrics have identical values to test fallback ordering
651        let metrics: Vec<Box<dyn MetricTrait>> = vec![
652            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
653            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
654            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
655            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
656        ];
657
658        let mut outputs = appraisal_outputs(assets, metrics);
659        sort_and_filter_appraisal_outputs(&mut outputs);
660
661        // Commissioned assets should be prioritised first
662        assert!(outputs[0].asset.is_commissioned());
663        assert_eq!(outputs[0].asset.commission_year(), 2020);
664        assert!(outputs[1].asset.is_commissioned());
665        assert_eq!(outputs[1].asset.commission_year(), 2015);
666
667        // Non-commissioned assets should come after
668        assert!(!outputs[2].asset.is_commissioned());
669        assert!(!outputs[3].asset.is_commissioned());
670    }
671
672    /// Test that appraisal metric is prioritised over asset properties when sorting
673    #[rstest]
674    fn appraisal_metric_is_prioritised_over_asset_properties(
675        process: Process,
676        region_id: RegionID,
677        agent_id: AgentID,
678    ) {
679        let process_rc = Arc::new(process);
680        let capacity = Capacity(10.0);
681
682        // Create a mix of commissioned and candidate (non-commissioned) assets
683        let commissioned_asset_newer = Asset::new_commissioned(
684            agent_id.clone(),
685            process_rc.clone(),
686            region_id.clone(),
687            AssetCapacity::single(capacity),
688            2020,
689        )
690        .unwrap();
691
692        let commissioned_asset_older = Asset::new_commissioned(
693            agent_id.clone(),
694            process_rc.clone(),
695            region_id.clone(),
696            AssetCapacity::single(capacity),
697            2015,
698        )
699        .unwrap();
700
701        let candidate_asset =
702            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
703
704        let assets = vec![
705            candidate_asset.clone(),
706            commissioned_asset_older.clone(),
707            candidate_asset.clone(),
708            commissioned_asset_newer.clone(),
709        ];
710
711        // Make one metric slightly better than all others
712        let baseline_metric_value = 5.0;
713        let best_metric_value = baseline_metric_value - 1e-5;
714        let metrics: Vec<Box<dyn MetricTrait>> = vec![
715            Box::new(LCOXMetric::new(MoneyPerActivity(best_metric_value))),
716            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
717            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
718            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
719        ];
720
721        let mut outputs = appraisal_outputs(assets, metrics);
722        sort_and_filter_appraisal_outputs(&mut outputs);
723
724        // non-commissioned asset prioritised because it has a slightly better metric
725        assert_approx_eq!(
726            f64,
727            outputs[0].metric.as_ref().unwrap().value(),
728            best_metric_value
729        );
730    }
731
732    /// Test that appraisal outputs with an invalid metric are filtered out
733    #[rstest]
734    fn appraisal_sort_filters_invalid_metric(asset: Asset) {
735        let output = AppraisalOutput {
736            asset: AssetRef::from(asset),
737            coefficients: objective_coeffs(),
738            activity: IndexMap::new(),
739            unmet_demand: IndexMap::new(),
740            metric: None,
741        };
742        let mut outputs = vec![output];
743
744        sort_and_filter_appraisal_outputs(&mut outputs);
745
746        // The invalid output should have been filtered out
747        assert_eq!(outputs.len(), 0);
748    }
749
750    /// Tests for counting number of equal metrics using identical assets so only metric values
751    /// affect the count.
752    #[rstest]
753    #[case(vec![5.0], 0, "single_element")]
754    #[case(vec![5.0, 5.0, 5.0], 2, "all_equal_returns_len_minus_one")]
755    #[case(vec![1.0, 2.0, 3.0], 0, "none_equal_to_best")]
756    #[case(vec![5.0, 5.0, 9.0], 1, "partial_equality_stops_at_first_difference")]
757    #[case(vec![5.0, 5.0, 9.0, 5.0], 1, "equality_does_not_resume_after_gap")]
758    fn count_equal_best_lcox_metric(
759        asset: Asset,
760        #[case] metric_values: Vec<f64>,
761        #[case] expected_count: usize,
762        #[case] description: &str,
763    ) {
764        let metrics: Vec<Box<dyn MetricTrait>> = metric_values
765            .into_iter()
766            .map(|v| Box::new(LCOXMetric::new(MoneyPerActivity(v))) as Box<dyn MetricTrait>)
767            .collect();
768
769        let outputs =
770            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
771
772        assert_eq!(
773            count_equal_and_best_appraisal_outputs(&outputs),
774            expected_count,
775            "Failed for case: {description}"
776        );
777    }
778
779    /// Empty slice count should return 0.
780    #[test]
781    fn count_equal_best_empty_slice_returns_zero() {
782        let outputs: Vec<AppraisalOutput> = vec![];
783        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
784    }
785
786    /// Equal metrics but differing asset fallback (commissioned vs. candidate) →
787    /// outputs are distinguishable, so count should be 0.
788    #[rstest]
789    fn count_equal_best_equal_metric_different_fallback_returns_zero(
790        process: Process,
791        region_id: RegionID,
792        agent_id: AgentID,
793    ) {
794        let process_rc = Arc::new(process);
795        let capacity = Capacity(10.0);
796
797        let commissioned = Asset::new_commissioned(
798            agent_id.clone(),
799            process_rc.clone(),
800            region_id.clone(),
801            AssetCapacity::single(capacity),
802            2020,
803        )
804        .unwrap();
805        let candidate =
806            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
807
808        let metric_value = MoneyPerActivity(5.0);
809        let outputs = appraisal_outputs(
810            vec![commissioned, candidate],
811            vec![
812                Box::new(LCOXMetric::new(metric_value)),
813                Box::new(LCOXMetric::new(metric_value)),
814            ],
815        );
816
817        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
818    }
819
820    /// Equal metrics and equal asset fallback (same commissioned status and commission year) →
821    /// the second element is indistinguishable, so count should be 1.
822    #[rstest]
823    fn count_equal_best_equal_metric_and_equal_fallback_returns_one(
824        process: Process,
825        region_id: RegionID,
826        agent_id: AgentID,
827    ) {
828        let process_rc = Arc::new(process);
829        let capacity = Capacity(10.0);
830        let year = 2020;
831
832        let asset1 = Asset::new_commissioned(
833            agent_id.clone(),
834            process_rc.clone(),
835            region_id.clone(),
836            AssetCapacity::single(capacity),
837            year,
838        )
839        .unwrap();
840        let asset2 = Asset::new_commissioned(
841            agent_id.clone(),
842            process_rc.clone(),
843            region_id.clone(),
844            AssetCapacity::single(capacity),
845            year,
846        )
847        .unwrap();
848
849        let metric_value = MoneyPerActivity(5.0);
850        let outputs = appraisal_outputs(
851            vec![asset1, asset2],
852            vec![
853                Box::new(LCOXMetric::new(metric_value)),
854                Box::new(LCOXMetric::new(metric_value)),
855            ],
856        );
857
858        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 1);
859    }
860}