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, AssetCapacity, AssetRef};
5use crate::commodity::Commodity;
6use crate::finance::{ProfitabilityIndex, lcox, profitability_index};
7use crate::model::Model;
8use crate::time_slice::TimeSliceID;
9use crate::units::{Activity, Capacity, Money, 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::approx_eq;
26use float_cmp::{ApproxEq, F64Margin};
27use optimisation::perform_optimisation;
28
29/// Compares two values with approximate equality checking.
30///
31/// Returns `Ordering::Equal` if the values are approximately equal
32/// according to the default floating-point margin, otherwise returns
33/// their relative ordering based on `a.partial_cmp(&b)`.
34///
35/// This is useful when comparing floating-point-based types where exact
36/// equality may not be appropriate due to numerical precision limitations.
37///
38/// # Panics
39///
40/// Panics if `partial_cmp` returns `None` (i.e., if either value is NaN).
41fn compare_approx<T>(a: T, b: T) -> Ordering
42where
43    T: Copy + PartialOrd + ApproxEq<Margin = F64Margin>,
44{
45    if a.approx_eq(b, F64Margin::default()) {
46        Ordering::Equal
47    } else {
48        a.partial_cmp(&b).expect("Cannot compare NaN values")
49    }
50}
51
52/// The output of investment appraisal required to compare potential investment decisions
53pub struct AppraisalOutput {
54    /// The asset being appraised
55    pub asset: AssetRef,
56    /// The hypothetical capacity to install
57    pub capacity: AssetCapacity,
58    /// Time slice level activity of the asset
59    pub activity: IndexMap<TimeSliceID, Activity>,
60    /// The hypothetical unmet demand following investment in this asset
61    pub unmet_demand: DemandMap,
62    /// The comparison metric to compare investment decisions
63    pub metric: Option<Box<dyn MetricTrait>>,
64    /// Capacity and activity coefficients used in the appraisal
65    pub coefficients: Rc<ObjectiveCoefficients>,
66}
67
68impl AppraisalOutput {
69    /// Create a new `AppraisalOutput`
70    fn new<T: MetricTrait>(
71        asset: AssetRef,
72        capacity: AssetCapacity,
73        results: ResultsMap,
74        metric: Option<T>,
75        coefficients: Rc<ObjectiveCoefficients>,
76    ) -> Self {
77        Self {
78            asset,
79            capacity,
80            activity: results.activity,
81            unmet_demand: results.unmet_demand,
82            metric: metric.map(|m| Box::new(m) as Box<dyn MetricTrait>),
83            coefficients,
84        }
85    }
86    /// Compare this appraisal to another on the basis of the comparison metric.
87    ///
88    /// Note that if the metrics are approximately equal (as determined by the [`approx_eq!`] macro)
89    /// then [`Ordering::Equal`] is returned. The reason for this is because different CPU
90    /// architectures may lead to subtly different values for the comparison metrics and if the
91    /// value is very similar to another, then it can lead to different decisions being made,
92    /// depending on the user's platform (e.g. macOS ARM vs. Windows). We want to avoid this, if
93    /// possible, which is why we use a more approximate comparison.
94    pub fn compare_metric(&self, other: &Self) -> Ordering {
95        assert!(
96            self.is_valid() && other.is_valid(),
97            "Cannot compare non-valid outputs"
98        );
99
100        // We've already checked the metrics aren't `None` in `is_valid`
101        self.metric
102            .as_ref()
103            .unwrap()
104            .compare(other.metric.as_ref().unwrap().as_ref())
105    }
106
107    /// Whether this [`AppraisalOutput`] is a valid output.
108    ///
109    /// Specifically, it checks whether the metric is a valid value (not `None`) and that the
110    /// calculated capacity is greater than zero.
111    pub fn is_valid(&self) -> bool {
112        self.metric.is_some() && self.capacity.total_capacity() > Capacity(0.0)
113    }
114}
115
116/// Supertrait for appraisal metrics that can be serialised and compared.
117pub trait MetricTrait: ComparableMetric + ErasedSerialize {}
118erased_serde::serialize_trait_object!(MetricTrait);
119
120/// Trait for appraisal metrics that can be compared.
121///
122/// Implementers define how their values should be compared to determine
123/// which investment option is preferable through the `compare` method.
124pub trait ComparableMetric: Any + Send + Sync {
125    /// Returns the numeric value of this metric.
126    fn value(&self) -> f64;
127
128    /// Compares this metric with another of the same type.
129    ///
130    /// Returns `Ordering::Less` if `self` is better than `other`,
131    /// `Ordering::Greater` if `other` is better, or `Ordering::Equal`
132    /// if they are approximately equal.
133    ///
134    /// # Panics
135    ///
136    /// Panics if `other` is not the same concrete type as `self`.
137    fn compare(&self, other: &dyn ComparableMetric) -> Ordering;
138
139    /// Helper for downcasting to enable type-safe comparison.
140    fn as_any(&self) -> &dyn Any;
141}
142
143/// Levelised Cost of X (LCOX) metric.
144///
145/// Represents the average cost per unit of output. Lower values indicate
146/// more cost-effective investments.
147#[derive(Debug, Clone, Serialize)]
148pub struct LCOXMetric {
149    /// The calculated cost value for this LCOX metric
150    pub cost: MoneyPerActivity,
151}
152
153impl LCOXMetric {
154    /// Creates a new `LCOXMetric` with the given cost.
155    pub fn new(cost: MoneyPerActivity) -> Self {
156        Self { cost }
157    }
158}
159
160impl ComparableMetric for LCOXMetric {
161    fn value(&self) -> f64 {
162        self.cost.value()
163    }
164
165    fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
166        let other = other
167            .as_any()
168            .downcast_ref::<Self>()
169            .expect("Cannot compare metrics of different types");
170
171        compare_approx(self.cost, other.cost)
172    }
173
174    fn as_any(&self) -> &dyn Any {
175        self
176    }
177}
178
179/// `LCOXMetric` implements the `MetricTrait` supertrait.
180impl MetricTrait for LCOXMetric {}
181
182/// Net Present Value (NPV) metric
183#[derive(Debug, Clone, Serialize)]
184pub struct NPVMetric(ProfitabilityIndex);
185
186impl NPVMetric {
187    /// Creates a new `NPVMetric` with the given profitability index.
188    pub fn new(profitability_index: ProfitabilityIndex) -> Self {
189        Self(profitability_index)
190    }
191
192    /// Returns true if this metric represents a zero fixed cost case.
193    fn is_zero_fixed_cost(&self) -> bool {
194        approx_eq!(Money, self.0.annualised_fixed_cost, Money(0.0))
195    }
196}
197
198impl ComparableMetric for NPVMetric {
199    fn value(&self) -> f64 {
200        if self.is_zero_fixed_cost() {
201            self.0.total_annualised_surplus.value()
202        } else {
203            self.0.value().value()
204        }
205    }
206
207    /// Higher profitability index values indicate more profitable investments.
208    /// When annual fixed cost is zero, the profitability index is infinite and
209    /// total surplus is used for comparison instead.
210    fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
211        let other = other
212            .as_any()
213            .downcast_ref::<Self>()
214            .expect("Cannot compare metrics of different types");
215
216        // Handle comparison based on fixed cost status
217        match (self.is_zero_fixed_cost(), other.is_zero_fixed_cost()) {
218            // Both have zero fixed cost: compare total surplus (higher is better)
219            (true, true) => {
220                let self_surplus = self.0.total_annualised_surplus;
221                let other_surplus = other.0.total_annualised_surplus;
222                compare_approx(other_surplus, self_surplus)
223            }
224            // Both have non-zero fixed cost: compare profitability index (higher is better)
225            (false, false) => {
226                let self_pi = self.0.value();
227                let other_pi = other.0.value();
228                compare_approx(other_pi, self_pi)
229            }
230            // Zero fixed cost is always better than non-zero fixed cost
231            (true, false) => Ordering::Less,
232            (false, true) => Ordering::Greater,
233        }
234    }
235
236    fn as_any(&self) -> &dyn Any {
237        self
238    }
239}
240
241/// `NPVMetric` implements the `MetricTrait` supertrait.
242impl MetricTrait for NPVMetric {}
243
244/// Calculate LCOX for a hypothetical investment in the given asset.
245///
246/// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can
247/// include other flows, we use the term LCOX.
248///
249/// # Returns
250///
251/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand.
252/// The returned `metric` is the LCOX value (lower is better).
253fn calculate_lcox(
254    model: &Model,
255    asset: &AssetRef,
256    max_capacity: AssetCapacity,
257    commodity: &Commodity,
258    coefficients: &Rc<ObjectiveCoefficients>,
259    demand: &DemandMap,
260) -> Result<AppraisalOutput> {
261    let results = perform_optimisation(
262        model,
263        asset,
264        max_capacity,
265        commodity,
266        coefficients,
267        demand,
268        highs::Sense::Minimise,
269    )?;
270
271    let cost_index = lcox(
272        results.capacity.total_capacity(),
273        coefficients.capacity_coefficient,
274        &results.activity,
275        &coefficients.activity_coefficients,
276    );
277
278    Ok(AppraisalOutput::new(
279        asset.clone(),
280        results.capacity,
281        results,
282        cost_index.map(LCOXMetric::new),
283        coefficients.clone(),
284    ))
285}
286
287/// Calculate NPV for a hypothetical investment in the given asset.
288///
289/// # Returns
290///
291/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand.
292fn calculate_npv(
293    model: &Model,
294    asset: &AssetRef,
295    max_capacity: AssetCapacity,
296    commodity: &Commodity,
297    coefficients: &Rc<ObjectiveCoefficients>,
298    demand: &DemandMap,
299) -> Result<AppraisalOutput> {
300    let results = perform_optimisation(
301        model,
302        asset,
303        max_capacity,
304        commodity,
305        coefficients,
306        demand,
307        highs::Sense::Maximise,
308    )?;
309
310    let annual_fixed_cost = annual_fixed_cost(asset);
311    assert!(
312        annual_fixed_cost >= MoneyPerCapacity(0.0),
313        "The current NPV calculation does not support negative annual fixed costs"
314    );
315
316    let profitability_index = profitability_index(
317        results.capacity.total_capacity(),
318        annual_fixed_cost,
319        &results.activity,
320        &coefficients.activity_coefficients,
321    );
322
323    Ok(AppraisalOutput::new(
324        asset.clone(),
325        max_capacity,
326        results,
327        Some(NPVMetric::new(profitability_index)),
328        coefficients.clone(),
329    ))
330}
331
332/// Appraise the given investment with the specified objective type.
333///
334/// # Returns
335///
336/// The `AppraisalOutput` produced by the selected appraisal method. The `metric` field is
337/// comparable with other appraisals of the same type (npv/lcox).
338pub fn appraise_investment(
339    model: &Model,
340    asset: &AssetRef,
341    max_capacity: Option<AssetCapacity>,
342    commodity: &Commodity,
343    objective_type: &ObjectiveType,
344    coefficients: &Rc<ObjectiveCoefficients>,
345    demand: &DemandMap,
346) -> Result<AppraisalOutput> {
347    let max_capacity = max_capacity.unwrap_or(asset.capacity());
348    let appraisal_method = match objective_type {
349        ObjectiveType::LevelisedCostOfX => calculate_lcox,
350        ObjectiveType::NetPresentValue => calculate_npv,
351    };
352    appraisal_method(model, asset, max_capacity, commodity, coefficients, demand)
353}
354
355/// Compare assets as a fallback if metrics are equal.
356///
357/// Commissioned assets are ordered before uncommissioned and newer before older.
358///
359/// Used as a fallback to sort assets when they have equal appraisal tool outputs.
360fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering {
361    (asset2.is_commissioned(), asset2.commission_year())
362        .cmp(&(asset1.is_commissioned(), asset1.commission_year()))
363}
364
365/// Sort appraisal outputs by their investment priority and exclude non-feasible options.
366///
367/// Investment priority is primarily decided by appraisal metric. When appraisal metrics are equal,
368/// a tie-breaker fallback is used. Commissioned assets are preferred over uncommissioned assets,
369/// and newer assets are preferred over older ones. The function does not guarantee that all ties
370/// will be resolved.
371///
372/// Before sorting, outputs are filtered using [`AppraisalOutput::is_valid`], which excludes entries
373/// with invalid metrics (e.g. `None`) as well as zero capacity. This avoids meaningless or `NaN`
374/// appraisal metrics that could cause the program to panic, so the length of the returned vector
375/// may be less than the input.
376pub fn sort_and_filter_appraisal_outputs(outputs_for_opts: &mut Vec<AppraisalOutput>) {
377    outputs_for_opts.retain(AppraisalOutput::is_valid);
378    outputs_for_opts.sort_by(|output1, output2| match output1.compare_metric(output2) {
379        // If equal, we fall back on comparing asset properties
380        Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset),
381        cmp => cmp,
382    });
383}
384
385/// Counts the number of top appraisal outputs in a sorted slice that are indistinguishable
386/// by both metric and fallback ordering. Excludes the first element from the count.
387pub fn count_equal_and_best_appraisal_outputs(outputs: &[AppraisalOutput]) -> usize {
388    if outputs.is_empty() {
389        return 0;
390    }
391    outputs[1..]
392        .iter()
393        .take_while(|output| {
394            output.compare_metric(&outputs[0]).is_eq()
395                && compare_asset_fallback(&output.asset, &outputs[0].asset).is_eq()
396        })
397        .count()
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::agent::AgentID;
404    use crate::finance::ProfitabilityIndex;
405    use crate::fixture::{agent_id, asset, process, region_id};
406    use crate::process::Process;
407    use crate::region::RegionID;
408    use crate::units::{Money, MoneyPerActivity, MoneyPerFlow};
409    use float_cmp::assert_approx_eq;
410    use rstest::rstest;
411    use std::rc::Rc;
412
413    /// Parametrised tests for LCOX metric comparison.
414    #[rstest]
415    #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
416    #[case(5.0, 10.0, Ordering::Less, "first_lower_cost_is_better")]
417    #[case(10.0, 5.0, Ordering::Greater, "second_lower_cost_is_better")]
418    fn lcox_metric_comparison(
419        #[case] cost1: f64,
420        #[case] cost2: f64,
421        #[case] expected: Ordering,
422        #[case] description: &str,
423    ) {
424        let metric1 = LCOXMetric::new(MoneyPerActivity(cost1));
425        let metric2 = LCOXMetric::new(MoneyPerActivity(cost2));
426
427        assert_eq!(
428            metric1.compare(&metric2),
429            expected,
430            "Failed comparison for case: {description}"
431        );
432    }
433
434    /// Parametrised tests for NPV metric comparison.
435    #[rstest]
436    // Both zero AFC: compare by total surplus (higher is better)
437    #[case(100.0, 0.0, 50.0, 0.0, Ordering::Less, "both_zero_afc_first_better")]
438    #[case(
439        50.0,
440        0.0,
441        100.0,
442        0.0,
443        Ordering::Greater,
444        "both_zero_afc_second_better"
445    )]
446    #[case(100.0, 0.0, 100.0, 0.0, Ordering::Equal, "both_zero_afc_equal")]
447    // Both approximately zero AFC (same as both zero): compare by total surplus (higher is better)
448    #[case(
449        100.0,
450        1e-10,
451        50.0,
452        1e-10,
453        Ordering::Less,
454        "both_approx_zero_afc_first_better"
455    )]
456    #[case(
457        100.0,
458        1e-10,
459        200.0,
460        50.0,
461        Ordering::Less,
462        "approx_zero_afc_beats_nonzero"
463    )]
464    #[case(
465        200.0,
466        50.0,
467        100.0,
468        1e-10,
469        Ordering::Greater,
470        "nonzero_afc_loses_to_approx_zero"
471    )]
472    // Both non-zero AFC: compare by profitability index (higher is better)
473    #[case(
474        200.0,
475        100.0,
476        150.0,
477        100.0,
478        Ordering::Less,
479        "both_nonzero_afc_first_better"
480    )]
481    #[case(
482        150.0,
483        100.0,
484        200.0,
485        100.0,
486        Ordering::Greater,
487        "both_nonzero_afc_second_better"
488    )]
489    #[case(200.0, 100.0, 200.0, 100.0, Ordering::Equal, "both_nonzero_afc_equal")]
490    // Zero vs non-zero AFC: zero or approximately zero is always better
491    #[case(
492        10.0,
493        0.0,
494        1000.0,
495        100.0,
496        Ordering::Less,
497        "first_zero_afc_beats_second_nonzero_afc"
498    )]
499    #[case(
500        10.0,
501        1e-10,
502        1000.0,
503        100.0,
504        Ordering::Less,
505        "first_approx_zero_afc_beats_second_nonzero_afc"
506    )]
507    #[case(
508        1000.0,
509        100.0,
510        10.0,
511        0.0,
512        Ordering::Greater,
513        "second_zero_afc_beats_first_nonzero_afc"
514    )]
515    #[case(
516        1000.0,
517        100.0,
518        10.0,
519        1e-10,
520        Ordering::Greater,
521        "second_nonzero_afc_beats_first_approx_zero_afc"
522    )]
523    fn npv_metric_comparison(
524        #[case] surplus1: f64,
525        #[case] fixed_cost1: f64,
526        #[case] surplus2: f64,
527        #[case] fixed_cost2: f64,
528        #[case] expected: Ordering,
529        #[case] description: &str,
530    ) {
531        let metric1 = NPVMetric::new(ProfitabilityIndex {
532            total_annualised_surplus: Money(surplus1),
533            annualised_fixed_cost: Money(fixed_cost1),
534        });
535        let metric2 = NPVMetric::new(ProfitabilityIndex {
536            total_annualised_surplus: Money(surplus2),
537            annualised_fixed_cost: Money(fixed_cost2),
538        });
539
540        assert_eq!(
541            metric1.compare(&metric2),
542            expected,
543            "Failed comparison for case: {description}"
544        );
545    }
546
547    #[rstest]
548    fn compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) {
549        let process = Rc::new(process);
550        let capacity = Capacity(2.0);
551        let asset1 = Asset::new_commissioned(
552            agent_id.clone(),
553            process.clone(),
554            region_id.clone(),
555            capacity,
556            2015,
557        )
558        .unwrap();
559        let asset2 =
560            Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap();
561        let asset3 =
562            Asset::new_commissioned(agent_id, process, region_id.clone(), capacity, 2010).unwrap();
563
564        assert!(compare_asset_fallback(&asset1, &asset1).is_eq());
565        assert!(compare_asset_fallback(&asset2, &asset2).is_eq());
566        assert!(compare_asset_fallback(&asset3, &asset3).is_eq());
567        assert!(compare_asset_fallback(&asset1, &asset2).is_lt());
568        assert!(compare_asset_fallback(&asset2, &asset1).is_gt());
569        assert!(compare_asset_fallback(&asset1, &asset3).is_lt());
570        assert!(compare_asset_fallback(&asset3, &asset1).is_gt());
571        assert!(compare_asset_fallback(&asset3, &asset2).is_lt());
572        assert!(compare_asset_fallback(&asset2, &asset3).is_gt());
573    }
574
575    fn objective_coeffs() -> Rc<ObjectiveCoefficients> {
576        Rc::new(ObjectiveCoefficients {
577            capacity_coefficient: MoneyPerCapacity(0.0),
578            activity_coefficients: IndexMap::new(),
579            unmet_demand_coefficient: MoneyPerFlow(0.0),
580        })
581    }
582
583    /// Creates appraisal from corresponding assets and metrics
584    ///
585    /// # Panics
586    ///
587    /// Panics if `assets` and `metrics` have different lengths
588    fn appraisal_outputs(
589        assets: Vec<Asset>,
590        metrics: Vec<Box<dyn MetricTrait>>,
591    ) -> Vec<AppraisalOutput> {
592        assert_eq!(
593            assets.len(),
594            metrics.len(),
595            "assets and metrics must have the same length"
596        );
597
598        assets
599            .into_iter()
600            .zip(metrics)
601            .map(|(asset, metric)| AppraisalOutput {
602                asset: AssetRef::from(asset),
603                capacity: AssetCapacity::Continuous(Capacity(10.0)),
604                coefficients: objective_coeffs(),
605                activity: IndexMap::new(),
606                unmet_demand: IndexMap::new(),
607                metric: Some(metric),
608            })
609            .collect()
610    }
611
612    /// Creates appraisal outputs with given metrics.
613    /// Copies the provided default asset for each metric.
614    fn appraisal_outputs_with_investment_priority_invariant_to_assets(
615        metrics: Vec<Box<dyn MetricTrait>>,
616        asset: &Asset,
617    ) -> Vec<AppraisalOutput> {
618        let assets = vec![asset.clone(); metrics.len()];
619        appraisal_outputs(assets, metrics)
620    }
621
622    /// Test sorting by LCOX metric when invariant to asset properties
623    #[rstest]
624    fn appraisal_sort_by_lcox_metric(asset: Asset) {
625        let metrics: Vec<Box<dyn MetricTrait>> = vec![
626            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
627            Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
628            Box::new(LCOXMetric::new(MoneyPerActivity(7.0))),
629        ];
630
631        let mut outputs =
632            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
633        sort_and_filter_appraisal_outputs(&mut outputs);
634
635        assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 3.0); // Best (lowest)
636        assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0);
637        assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 7.0); // Worst (highest)
638    }
639
640    /// Test sorting by NPV profitability index when invariant to asset properties
641    #[rstest]
642    fn appraisal_sort_by_npv_metric(asset: Asset) {
643        let metrics: Vec<Box<dyn MetricTrait>> = vec![
644            Box::new(NPVMetric::new(ProfitabilityIndex {
645                total_annualised_surplus: Money(200.0),
646                annualised_fixed_cost: Money(100.0),
647            })),
648            Box::new(NPVMetric::new(ProfitabilityIndex {
649                total_annualised_surplus: Money(300.0),
650                annualised_fixed_cost: Money(100.0),
651            })),
652            Box::new(NPVMetric::new(ProfitabilityIndex {
653                total_annualised_surplus: Money(150.0),
654                annualised_fixed_cost: Money(100.0),
655            })),
656        ];
657
658        let mut outputs =
659            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
660        sort_and_filter_appraisal_outputs(&mut outputs);
661
662        // Higher profitability index is better, so should be sorted: 3.0, 2.0, 1.5
663        assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 3.0); // Best (highest PI)
664        assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 2.0);
665        assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 1.5); // Worst (lowest PI)
666    }
667
668    /// Test that NPV metrics with zero annual fixed cost are prioritised above all others
669    /// when invariant to asset properties
670    #[rstest]
671    fn appraisal_sort_by_npv_metric_zero_afc_prioritised(asset: Asset) {
672        let metrics: Vec<Box<dyn MetricTrait>> = vec![
673            // Very high profitability index but non-zero AFC
674            Box::new(NPVMetric::new(ProfitabilityIndex {
675                total_annualised_surplus: Money(1000.0),
676                annualised_fixed_cost: Money(100.0),
677            })),
678            // Zero AFC with modest surplus - should be prioritised first
679            Box::new(NPVMetric::new(ProfitabilityIndex {
680                total_annualised_surplus: Money(50.0),
681                annualised_fixed_cost: Money(0.0),
682            })),
683            // Another high profitability index but non-zero AFC
684            Box::new(NPVMetric::new(ProfitabilityIndex {
685                total_annualised_surplus: Money(500.0),
686                annualised_fixed_cost: Money(50.0),
687            })),
688        ];
689
690        let mut outputs =
691            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
692        sort_and_filter_appraisal_outputs(&mut outputs);
693
694        // Zero AFC should be first despite lower absolute surplus value
695        assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 50.0); // Zero AFC (uses surplus)
696        assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 10.0); // PI = 1000/100
697        assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 10.0); // PI = 500/50
698    }
699
700    /// Test that mixing LCOX and NPV metrics causes a runtime panic during comparison
701    #[rstest]
702    #[should_panic(expected = "Cannot compare metrics of different types")]
703    fn appraisal_sort_by_mixed_metrics_panics(asset: Asset) {
704        let metrics: Vec<Box<dyn MetricTrait>> = vec![
705            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
706            Box::new(NPVMetric::new(ProfitabilityIndex {
707                total_annualised_surplus: Money(200.0),
708                annualised_fixed_cost: Money(100.0),
709            })),
710            Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
711        ];
712
713        let mut outputs =
714            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
715        // This should panic when trying to compare different metric types
716        sort_and_filter_appraisal_outputs(&mut outputs);
717    }
718
719    /// Test that when metrics are equal, commissioned assets are sorted by commission year (newer first)
720    #[rstest]
721    fn appraisal_sort_by_commission_year_when_metrics_equal(
722        process: Process,
723        region_id: RegionID,
724        agent_id: AgentID,
725    ) {
726        let process_rc = Rc::new(process);
727        let capacity = Capacity(10.0);
728        let commission_years = [2015, 2020, 2010];
729
730        let assets: Vec<_> = commission_years
731            .iter()
732            .map(|&year| {
733                Asset::new_commissioned(
734                    agent_id.clone(),
735                    process_rc.clone(),
736                    region_id.clone(),
737                    capacity,
738                    year,
739                )
740                .unwrap()
741            })
742            .collect();
743
744        // All metrics have the same value
745        let metrics: Vec<Box<dyn MetricTrait>> = vec![
746            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
747            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
748            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
749        ];
750
751        let mut outputs = appraisal_outputs(assets, metrics);
752        sort_and_filter_appraisal_outputs(&mut outputs);
753
754        // Should be sorted by commission year, newest first: 2020, 2015, 2010
755        assert_eq!(outputs[0].asset.commission_year(), 2020);
756        assert_eq!(outputs[1].asset.commission_year(), 2015);
757        assert_eq!(outputs[2].asset.commission_year(), 2010);
758    }
759
760    /// Test that when metrics and commission years are equal, the original order is preserved
761    #[rstest]
762    fn appraisal_sort_maintains_order_when_all_equal(process: Process, region_id: RegionID) {
763        let process_rc = Rc::new(process);
764        let capacity = Capacity(10.0);
765        let commission_year = 2015;
766        let agent_ids = ["agent1", "agent2", "agent3"];
767
768        let assets: Vec<_> = agent_ids
769            .iter()
770            .map(|&id| {
771                Asset::new_commissioned(
772                    AgentID(id.into()),
773                    process_rc.clone(),
774                    region_id.clone(),
775                    capacity,
776                    commission_year,
777                )
778                .unwrap()
779            })
780            .collect();
781
782        let metrics: Vec<Box<dyn MetricTrait>> = vec![
783            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
784            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
785            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
786        ];
787
788        let mut outputs = appraisal_outputs(assets.clone(), metrics);
789        sort_and_filter_appraisal_outputs(&mut outputs);
790
791        // Verify order is preserved - should match the original agent_ids array
792        for (&expected_id, output) in agent_ids.iter().zip(outputs) {
793            assert_eq!(output.asset.agent_id(), Some(&AgentID(expected_id.into())));
794        }
795    }
796
797    /// Test that commissioned assets are prioritised over non-commissioned assets when metrics are equal
798    #[rstest]
799    fn appraisal_sort_commissioned_before_uncommissioned_when_metrics_equal(
800        process: Process,
801        region_id: RegionID,
802        agent_id: AgentID,
803    ) {
804        let process_rc = Rc::new(process);
805        let capacity = Capacity(10.0);
806
807        // Create a mix of commissioned and candidate (non-commissioned) assets
808        let commissioned_asset_newer = Asset::new_commissioned(
809            agent_id.clone(),
810            process_rc.clone(),
811            region_id.clone(),
812            capacity,
813            2020,
814        )
815        .unwrap();
816
817        let commissioned_asset_older = Asset::new_commissioned(
818            agent_id.clone(),
819            process_rc.clone(),
820            region_id.clone(),
821            capacity,
822            2015,
823        )
824        .unwrap();
825
826        let candidate_asset =
827            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
828
829        let assets = vec![
830            candidate_asset.clone(),
831            commissioned_asset_older.clone(),
832            candidate_asset.clone(),
833            commissioned_asset_newer.clone(),
834        ];
835
836        // All metrics have identical values to test fallback ordering
837        let metrics: Vec<Box<dyn MetricTrait>> = vec![
838            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
839            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
840            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
841            Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
842        ];
843
844        let mut outputs = appraisal_outputs(assets, metrics);
845        sort_and_filter_appraisal_outputs(&mut outputs);
846
847        // Commissioned assets should be prioritised first
848        assert!(outputs[0].asset.is_commissioned());
849        assert!(outputs[0].asset.commission_year() == 2020);
850        assert!(outputs[1].asset.is_commissioned());
851        assert!(outputs[1].asset.commission_year() == 2015);
852
853        // Non-commissioned assets should come after
854        assert!(!outputs[2].asset.is_commissioned());
855        assert!(!outputs[3].asset.is_commissioned());
856    }
857
858    /// Test that appraisal metric is prioritised over asset properties when sorting
859    #[rstest]
860    fn appraisal_metric_is_prioritised_over_asset_properties(
861        process: Process,
862        region_id: RegionID,
863        agent_id: AgentID,
864    ) {
865        let process_rc = Rc::new(process);
866        let capacity = Capacity(10.0);
867
868        // Create a mix of commissioned and candidate (non-commissioned) assets
869        let commissioned_asset_newer = Asset::new_commissioned(
870            agent_id.clone(),
871            process_rc.clone(),
872            region_id.clone(),
873            capacity,
874            2020,
875        )
876        .unwrap();
877
878        let commissioned_asset_older = Asset::new_commissioned(
879            agent_id.clone(),
880            process_rc.clone(),
881            region_id.clone(),
882            capacity,
883            2015,
884        )
885        .unwrap();
886
887        let candidate_asset =
888            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
889
890        let assets = vec![
891            candidate_asset.clone(),
892            commissioned_asset_older.clone(),
893            candidate_asset.clone(),
894            commissioned_asset_newer.clone(),
895        ];
896
897        // Make one metric slightly better than all others
898        let baseline_metric_value = 5.0;
899        let best_metric_value = baseline_metric_value - 1e-5;
900        let metrics: Vec<Box<dyn MetricTrait>> = vec![
901            Box::new(LCOXMetric::new(MoneyPerActivity(best_metric_value))),
902            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
903            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
904            Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
905        ];
906
907        let mut outputs = appraisal_outputs(assets, metrics);
908        sort_and_filter_appraisal_outputs(&mut outputs);
909
910        // non-commissioned asset prioritised because it has a slightly better metric
911        assert_approx_eq!(
912            f64,
913            outputs[0].metric.as_ref().unwrap().value(),
914            best_metric_value
915        );
916    }
917
918    /// Test that appraisal outputs with zero capacity are filtered out during sorting.
919    #[rstest]
920    fn appraisal_sort_filters_zero_capacity_outputs(asset: Asset) {
921        let metric = LCOXMetric::new(MoneyPerActivity(1.0));
922        let metrics = [
923            Box::new(metric.clone()),
924            Box::new(metric.clone()),
925            Box::new(metric),
926        ];
927
928        // Create outputs with zero capacity
929        let mut outputs: Vec<AppraisalOutput> = metrics
930            .into_iter()
931            .map(|metric| AppraisalOutput {
932                asset: AssetRef::from(asset.clone()),
933                capacity: AssetCapacity::Continuous(Capacity(0.0)),
934                coefficients: objective_coeffs(),
935                activity: IndexMap::new(),
936                unmet_demand: IndexMap::new(),
937                metric: Some(metric),
938            })
939            .collect();
940
941        sort_and_filter_appraisal_outputs(&mut outputs);
942
943        // All zero capacity outputs should be filtered out
944        assert_eq!(outputs.len(), 0);
945    }
946
947    /// Test that appraisal outputs with an invalid metric are filtered out
948    #[rstest]
949    fn appraisal_sort_filters_invalid_metric(asset: Asset) {
950        let output = AppraisalOutput {
951            asset: AssetRef::from(asset),
952            capacity: AssetCapacity::Continuous(Capacity(1.0)), // non-zero capacity
953            coefficients: objective_coeffs(),
954            activity: IndexMap::new(),
955            unmet_demand: IndexMap::new(),
956            metric: None,
957        };
958        let mut outputs = vec![output];
959
960        sort_and_filter_appraisal_outputs(&mut outputs);
961
962        // The invalid output should have been filtered out
963        assert_eq!(outputs.len(), 0);
964    }
965
966    /// Tests for counting number of equal metrics using identical assets so only metric values
967    /// affect the count.
968    #[rstest]
969    #[case(vec![5.0], 0, "single_element")]
970    #[case(vec![5.0, 5.0, 5.0], 2, "all_equal_returns_len_minus_one")]
971    #[case(vec![1.0, 2.0, 3.0], 0, "none_equal_to_best")]
972    #[case(vec![5.0, 5.0, 9.0], 1, "partial_equality_stops_at_first_difference")]
973    #[case(vec![5.0, 5.0, 9.0, 5.0], 1, "equality_does_not_resume_after_gap")]
974    fn count_equal_best_lcox_metric(
975        asset: Asset,
976        #[case] metric_values: Vec<f64>,
977        #[case] expected_count: usize,
978        #[case] description: &str,
979    ) {
980        let metrics: Vec<Box<dyn MetricTrait>> = metric_values
981            .into_iter()
982            .map(|v| Box::new(LCOXMetric::new(MoneyPerActivity(v))) as Box<dyn MetricTrait>)
983            .collect();
984
985        let outputs =
986            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
987
988        assert_eq!(
989            count_equal_and_best_appraisal_outputs(&outputs),
990            expected_count,
991            "Failed for case: {description}"
992        );
993    }
994
995    /// Empty slice count should return 0.
996    #[test]
997    fn count_equal_best_empty_slice_returns_zero() {
998        let outputs: Vec<AppraisalOutput> = vec![];
999        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
1000    }
1001
1002    /// Equal metrics but differing asset fallback (commissioned vs. candidate) →
1003    /// outputs are distinguishable, so count should be 0.
1004    #[rstest]
1005    fn count_equal_best_equal_metric_different_fallback_returns_zero(
1006        process: Process,
1007        region_id: RegionID,
1008        agent_id: AgentID,
1009    ) {
1010        let process_rc = Rc::new(process);
1011        let capacity = Capacity(10.0);
1012
1013        let commissioned = Asset::new_commissioned(
1014            agent_id.clone(),
1015            process_rc.clone(),
1016            region_id.clone(),
1017            capacity,
1018            2020,
1019        )
1020        .unwrap();
1021        let candidate =
1022            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
1023
1024        let metric_value = MoneyPerActivity(5.0);
1025        let outputs = appraisal_outputs(
1026            vec![commissioned, candidate],
1027            vec![
1028                Box::new(LCOXMetric::new(metric_value)),
1029                Box::new(LCOXMetric::new(metric_value)),
1030            ],
1031        );
1032
1033        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
1034    }
1035
1036    /// Equal metrics and equal asset fallback (same commissioned status and commission year) →
1037    /// the second element is indistinguishable, so count should be 1.
1038    #[rstest]
1039    fn count_equal_best_equal_metric_and_equal_fallback_returns_one(
1040        process: Process,
1041        region_id: RegionID,
1042        agent_id: AgentID,
1043    ) {
1044        let process_rc = Rc::new(process);
1045        let capacity = Capacity(10.0);
1046        let year = 2020;
1047
1048        let asset1 = Asset::new_commissioned(
1049            agent_id.clone(),
1050            process_rc.clone(),
1051            region_id.clone(),
1052            capacity,
1053            year,
1054        )
1055        .unwrap();
1056        let asset2 = Asset::new_commissioned(
1057            agent_id.clone(),
1058            process_rc.clone(),
1059            region_id.clone(),
1060            capacity,
1061            year,
1062        )
1063        .unwrap();
1064
1065        let metric_value = MoneyPerActivity(5.0);
1066        let outputs = appraisal_outputs(
1067            vec![asset1, asset2],
1068            vec![
1069                Box::new(LCOXMetric::new(metric_value)),
1070                Box::new(LCOXMetric::new(metric_value)),
1071            ],
1072        );
1073
1074        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 1);
1075    }
1076
1077    /// Equal NPV metrics and identical assets → second element should be counted.
1078    #[rstest]
1079    fn count_equal_best_equal_npv_metrics(asset: Asset) {
1080        let make_npv = |surplus: f64, fixed_cost: f64| {
1081            Box::new(NPVMetric::new(ProfitabilityIndex {
1082                total_annualised_surplus: Money(surplus),
1083                annualised_fixed_cost: Money(fixed_cost),
1084            })) as Box<dyn MetricTrait>
1085        };
1086
1087        let metrics = vec![
1088            make_npv(200.0, 100.0),
1089            make_npv(200.0, 100.0), // Equal to best
1090            make_npv(100.0, 100.0), // Worse
1091        ];
1092
1093        let outputs =
1094            appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
1095
1096        assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 1);
1097    }
1098}