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::{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, Money, MoneyPerActivity, MoneyPerCapacity};
10use anyhow::Result;
11use costs::annual_fixed_cost;
12use erased_serde::Serialize as ErasedSerialize;
13use indexmap::IndexMap;
14use serde::Serialize;
15use std::any::Any;
16use std::cmp::Ordering;
17
18pub mod coefficients;
19mod constraints;
20mod costs;
21mod optimisation;
22use coefficients::ObjectiveCoefficients;
23use float_cmp::approx_eq;
24use float_cmp::{ApproxEq, F64Margin};
25use optimisation::perform_optimisation;
26
27/// Compares two values with approximate equality checking.
28///
29/// Returns `Ordering::Equal` if the values are approximately equal
30/// according to the default floating-point margin, otherwise returns
31/// their relative ordering based on `a.partial_cmp(&b)`.
32///
33/// This is useful when comparing floating-point-based types where exact
34/// equality may not be appropriate due to numerical precision limitations.
35///
36/// # Panics
37///
38/// Panics if `partial_cmp` returns `None` (i.e., if either value is NaN).
39fn compare_approx<T>(a: T, b: T) -> Ordering
40where
41    T: Copy + PartialOrd + ApproxEq<Margin = F64Margin>,
42{
43    if a.approx_eq(b, F64Margin::default()) {
44        Ordering::Equal
45    } else {
46        a.partial_cmp(&b).expect("Cannot compare NaN values")
47    }
48}
49
50/// The output of investment appraisal required to compare potential investment decisions
51pub struct AppraisalOutput {
52    /// The asset being appraised
53    pub asset: AssetRef,
54    /// The hypothetical capacity to install
55    pub capacity: AssetCapacity,
56    /// Time slice level activity of the asset
57    pub activity: IndexMap<TimeSliceID, Activity>,
58    /// The hypothetical unmet demand following investment in this asset
59    pub unmet_demand: DemandMap,
60    /// The comparison metric to compare investment decisions
61    pub metric: Box<dyn MetricTrait>,
62    /// Capacity and activity coefficients used in the appraisal
63    pub coefficients: ObjectiveCoefficients,
64    /// Demand profile used in the appraisal
65    pub demand: DemandMap,
66}
67
68impl AppraisalOutput {
69    /// Compare this appraisal to another on the basis of the comparison metric.
70    ///
71    /// Note that if the metrics are approximately equal (as determined by the [`approx_eq!`] macro)
72    /// then [`Ordering::Equal`] is returned. The reason for this is because different CPU
73    /// architectures may lead to subtly different values for the comparison metrics and if the
74    /// value is very similar to another, then it can lead to different decisions being made,
75    /// depending on the user's platform (e.g. macOS ARM vs. Windows). We want to avoid this, if
76    /// possible, which is why we use a more approximate comparison.
77    pub fn compare_metric(&self, other: &Self) -> Ordering {
78        assert!(
79            !(self.metric.value().is_nan() || other.metric.value().is_nan()),
80            "Appraisal metric cannot be NaN"
81        );
82        self.metric.compare(other.metric.as_ref())
83    }
84}
85
86/// Supertrait for appraisal metrics that can be serialised and compared.
87pub trait MetricTrait: ComparableMetric + ErasedSerialize {}
88erased_serde::serialize_trait_object!(MetricTrait);
89
90/// Trait for appraisal metrics that can be compared.
91///
92/// Implementers define how their values should be compared to determine
93/// which investment option is preferable through the `compare` method.
94pub trait ComparableMetric: Any + Send + Sync {
95    /// Returns the numeric value of this metric.
96    fn value(&self) -> f64;
97
98    /// Compares this metric with another of the same type.
99    ///
100    /// Returns `Ordering::Less` if `self` is better than `other`,
101    /// `Ordering::Greater` if `other` is better, or `Ordering::Equal`
102    /// if they are approximately equal.
103    ///
104    /// # Panics
105    ///
106    /// Panics if `other` is not the same concrete type as `self`.
107    fn compare(&self, other: &dyn ComparableMetric) -> Ordering;
108
109    /// Helper for downcasting to enable type-safe comparison.
110    fn as_any(&self) -> &dyn Any;
111}
112
113/// Levelised Cost of X (LCOX) metric.
114///
115/// Represents the average cost per unit of output. Lower values indicate
116/// more cost-effective investments.
117#[derive(Debug, Clone, Serialize)]
118pub struct LCOXMetric {
119    /// The calculated cost value for this LCOX metric
120    pub cost: MoneyPerActivity,
121}
122
123impl LCOXMetric {
124    /// Creates a new `LCOXMetric` with the given cost.
125    pub fn new(cost: MoneyPerActivity) -> Self {
126        Self { cost }
127    }
128}
129
130impl ComparableMetric for LCOXMetric {
131    fn value(&self) -> f64 {
132        self.cost.value()
133    }
134
135    fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
136        let other = other
137            .as_any()
138            .downcast_ref::<Self>()
139            .expect("Cannot compare metrics of different types");
140
141        compare_approx(self.cost, other.cost)
142    }
143
144    fn as_any(&self) -> &dyn Any {
145        self
146    }
147}
148
149/// `LCOXMetric` implements the `MetricTrait` supertrait.
150impl MetricTrait for LCOXMetric {}
151
152/// Net Present Value (NPV) metric
153#[derive(Debug, Clone, Serialize)]
154pub struct NPVMetric(ProfitabilityIndex);
155
156impl NPVMetric {
157    /// Creates a new `NPVMetric` with the given profitability index.
158    pub fn new(profitability_index: ProfitabilityIndex) -> Self {
159        Self(profitability_index)
160    }
161
162    /// Returns true if this metric represents a zero fixed cost case.
163    fn is_zero_fixed_cost(&self) -> bool {
164        approx_eq!(Money, self.0.annualised_fixed_cost, Money(0.0))
165    }
166}
167
168impl ComparableMetric for NPVMetric {
169    fn value(&self) -> f64 {
170        if self.is_zero_fixed_cost() {
171            self.0.total_annualised_surplus.value()
172        } else {
173            self.0.value().value()
174        }
175    }
176
177    /// Higher profitability index values indicate more profitable investments.
178    /// When annual fixed cost is zero, the profitability index is infinite and
179    /// total surplus is used for comparison instead.
180    fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
181        let other = other
182            .as_any()
183            .downcast_ref::<Self>()
184            .expect("Cannot compare metrics of different types");
185
186        // Handle comparison based on fixed cost status
187        match (self.is_zero_fixed_cost(), other.is_zero_fixed_cost()) {
188            // Both have zero fixed cost: compare total surplus (higher is better)
189            (true, true) => {
190                let self_surplus = self.0.total_annualised_surplus;
191                let other_surplus = other.0.total_annualised_surplus;
192                compare_approx(other_surplus, self_surplus)
193            }
194            // Both have non-zero fixed cost: compare profitability index (higher is better)
195            (false, false) => {
196                let self_pi = self.0.value();
197                let other_pi = other.0.value();
198                compare_approx(other_pi, self_pi)
199            }
200            // Zero fixed cost is always better than non-zero fixed cost
201            (true, false) => Ordering::Less,
202            (false, true) => Ordering::Greater,
203        }
204    }
205
206    fn as_any(&self) -> &dyn Any {
207        self
208    }
209}
210
211/// `NPVMetric` implements the `MetricTrait` supertrait.
212impl MetricTrait for NPVMetric {}
213
214/// Calculate LCOX for a hypothetical investment in the given asset.
215///
216/// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can
217/// include other flows, we use the term LCOX.
218///
219/// # Returns
220///
221/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand.
222/// The returned `metric` is the LCOX value (lower is better).
223fn calculate_lcox(
224    model: &Model,
225    asset: &AssetRef,
226    max_capacity: Option<AssetCapacity>,
227    commodity: &Commodity,
228    coefficients: &ObjectiveCoefficients,
229    demand: &DemandMap,
230) -> Result<AppraisalOutput> {
231    let results = perform_optimisation(
232        asset,
233        max_capacity,
234        commodity,
235        coefficients,
236        demand,
237        &model.time_slice_info,
238        highs::Sense::Minimise,
239    )?;
240
241    let cost_index = lcox(
242        results.capacity.total_capacity(),
243        coefficients.capacity_coefficient,
244        &results.activity,
245        &coefficients.activity_coefficients,
246    );
247
248    Ok(AppraisalOutput {
249        asset: asset.clone(),
250        capacity: results.capacity,
251        activity: results.activity,
252        unmet_demand: results.unmet_demand,
253        metric: Box::new(LCOXMetric::new(cost_index)),
254        coefficients: coefficients.clone(),
255        demand: demand.clone(),
256    })
257}
258
259/// Calculate NPV for a hypothetical investment in the given asset.
260///
261/// # Returns
262///
263/// An `AppraisalOutput` containing the hypothetical capacity, activity profile and unmet demand.
264fn calculate_npv(
265    model: &Model,
266    asset: &AssetRef,
267    max_capacity: Option<AssetCapacity>,
268    commodity: &Commodity,
269    coefficients: &ObjectiveCoefficients,
270    demand: &DemandMap,
271) -> Result<AppraisalOutput> {
272    let results = perform_optimisation(
273        asset,
274        max_capacity,
275        commodity,
276        coefficients,
277        demand,
278        &model.time_slice_info,
279        highs::Sense::Maximise,
280    )?;
281
282    let annual_fixed_cost = annual_fixed_cost(asset);
283    assert!(
284        annual_fixed_cost >= MoneyPerCapacity(0.0),
285        "The current NPV calculation does not support negative annual fixed costs"
286    );
287
288    let profitability_index = profitability_index(
289        results.capacity.total_capacity(),
290        annual_fixed_cost,
291        &results.activity,
292        &coefficients.activity_coefficients,
293    );
294
295    Ok(AppraisalOutput {
296        asset: asset.clone(),
297        capacity: results.capacity,
298        activity: results.activity,
299        unmet_demand: results.unmet_demand,
300        metric: Box::new(NPVMetric::new(profitability_index)),
301        coefficients: coefficients.clone(),
302        demand: demand.clone(),
303    })
304}
305
306/// Appraise the given investment with the specified objective type
307///
308/// # Returns
309///
310/// The `AppraisalOutput` produced by the selected appraisal method. The `metric` field is
311/// comparable with other appraisals of the same type (npv/lcox).
312pub fn appraise_investment(
313    model: &Model,
314    asset: &AssetRef,
315    max_capacity: Option<AssetCapacity>,
316    commodity: &Commodity,
317    objective_type: &ObjectiveType,
318    coefficients: &ObjectiveCoefficients,
319    demand: &DemandMap,
320) -> Result<AppraisalOutput> {
321    let appraisal_method = match objective_type {
322        ObjectiveType::LevelisedCostOfX => calculate_lcox,
323        ObjectiveType::NetPresentValue => calculate_npv,
324    };
325    appraisal_method(model, asset, max_capacity, commodity, coefficients, demand)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::finance::ProfitabilityIndex;
332    use crate::units::{Money, MoneyPerActivity};
333    use rstest::rstest;
334
335    /// Parametrised tests for LCOX metric comparison.
336    #[rstest]
337    #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
338    #[case(5.0, 10.0, Ordering::Less, "first_lower_cost_is_better")]
339    #[case(10.0, 5.0, Ordering::Greater, "second_lower_cost_is_better")]
340    fn lcox_metric_comparison(
341        #[case] cost1: f64,
342        #[case] cost2: f64,
343        #[case] expected: Ordering,
344        #[case] description: &str,
345    ) {
346        let metric1 = LCOXMetric::new(MoneyPerActivity(cost1));
347        let metric2 = LCOXMetric::new(MoneyPerActivity(cost2));
348
349        assert_eq!(
350            metric1.compare(&metric2),
351            expected,
352            "Failed comparison for case: {description}"
353        );
354    }
355
356    /// Parametrised tests for NPV metric comparison.
357    #[rstest]
358    // Both zero AFC: compare by total surplus (higher is better)
359    #[case(100.0, 0.0, 50.0, 0.0, Ordering::Less, "both_zero_afc_first_better")]
360    #[case(
361        50.0,
362        0.0,
363        100.0,
364        0.0,
365        Ordering::Greater,
366        "both_zero_afc_second_better"
367    )]
368    #[case(100.0, 0.0, 100.0, 0.0, Ordering::Equal, "both_zero_afc_equal")]
369    // Both approximately zero AFC (same as both zero): compare by total surplus (higher is better)
370    #[case(
371        100.0,
372        1e-10,
373        50.0,
374        1e-10,
375        Ordering::Less,
376        "both_approx_zero_afc_first_better"
377    )]
378    #[case(
379        100.0,
380        1e-10,
381        200.0,
382        50.0,
383        Ordering::Less,
384        "approx_zero_afc_beats_nonzero"
385    )]
386    #[case(
387        200.0,
388        50.0,
389        100.0,
390        1e-10,
391        Ordering::Greater,
392        "nonzero_afc_loses_to_approx_zero"
393    )]
394    // Both non-zero AFC: compare by profitability index (higher is better)
395    #[case(
396        200.0,
397        100.0,
398        150.0,
399        100.0,
400        Ordering::Less,
401        "both_nonzero_afc_first_better"
402    )]
403    #[case(
404        150.0,
405        100.0,
406        200.0,
407        100.0,
408        Ordering::Greater,
409        "both_nonzero_afc_second_better"
410    )]
411    #[case(200.0, 100.0, 200.0, 100.0, Ordering::Equal, "both_nonzero_afc_equal")]
412    // Zero vs non-zero AFC: zero or approximately zero is always better
413    #[case(
414        10.0,
415        0.0,
416        1000.0,
417        100.0,
418        Ordering::Less,
419        "first_zero_afc_beats_second_nonzero_afc"
420    )]
421    #[case(
422        10.0,
423        1e-10,
424        1000.0,
425        100.0,
426        Ordering::Less,
427        "first_approx_zero_afc_beats_second_nonzero_afc"
428    )]
429    #[case(
430        1000.0,
431        100.0,
432        10.0,
433        0.0,
434        Ordering::Greater,
435        "second_zero_afc_beats_first_nonzero_afc"
436    )]
437    #[case(
438        1000.0,
439        100.0,
440        10.0,
441        1e-10,
442        Ordering::Greater,
443        "second_nonzero_afc_beats_first_approx_zero_afc"
444    )]
445    fn npv_metric_comparison(
446        #[case] surplus1: f64,
447        #[case] fixed_cost1: f64,
448        #[case] surplus2: f64,
449        #[case] fixed_cost2: f64,
450        #[case] expected: Ordering,
451        #[case] description: &str,
452    ) {
453        let metric1 = NPVMetric::new(ProfitabilityIndex {
454            total_annualised_surplus: Money(surplus1),
455            annualised_fixed_cost: Money(fixed_cost1),
456        });
457        let metric2 = NPVMetric::new(ProfitabilityIndex {
458            total_annualised_surplus: Money(surplus2),
459            annualised_fixed_cost: Money(fixed_cost2),
460        });
461
462        assert_eq!(
463            metric1.compare(&metric2),
464            expected,
465            "Failed comparison for case: {description}"
466        );
467    }
468}