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