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::AssetRef;
5use crate::commodity::Commodity;
6use crate::finance::{lcox, profitability_index};
7use crate::model::Model;
8use crate::units::Capacity;
9use anyhow::Result;
10use std::cmp::Ordering;
11
12pub mod coefficients;
13mod constraints;
14mod costs;
15mod optimisation;
16use coefficients::ObjectiveCoefficients;
17use float_cmp::approx_eq;
18use optimisation::perform_optimisation;
19
20/// The output of investment appraisal required to compare potential investment decisions
21pub struct AppraisalOutput {
22    /// The asset being appraised
23    pub asset: AssetRef,
24    /// The hypothetical capacity to install
25    pub capacity: Capacity,
26    /// The hypothetical unmet demand following investment in this asset
27    pub unmet_demand: DemandMap,
28    /// The comparison metric to compare investment decisions (lower is better)
29    pub metric: f64,
30}
31
32impl AppraisalOutput {
33    /// Compare this appraisal to another on the basis of the comparison metric.
34    ///
35    /// Note that if the metrics are approximately equal (as determined by the [`approx_eq!`] macro)
36    /// then [`Ordering::Equal`] is returned. The reason for this is because different CPU
37    /// architectures may lead to subtly different values for the comparison metrics and if the
38    /// value is very similar to another, then it can lead to different decisions being made,
39    /// depending on the user's platform (e.g. macOS ARM vs. Windows). We want to avoid this, if
40    /// possible, which is why we use a more approximate comparison.
41    pub fn compare_metric(&self, other: &Self) -> Ordering {
42        assert!(
43            !(self.metric.is_nan() || other.metric.is_nan()),
44            "Appraisal metric cannot be NaN"
45        );
46
47        if approx_eq!(f64, self.metric, other.metric) {
48            Ordering::Equal
49        } else {
50            self.metric.partial_cmp(&other.metric).unwrap()
51        }
52    }
53}
54
55/// Calculate LCOX for a hypothetical investment in the given asset.
56///
57/// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can
58/// include other flows, we use the term LCOX.
59fn calculate_lcox(
60    model: &Model,
61    asset: &AssetRef,
62    max_capacity: Option<Capacity>,
63    commodity: &Commodity,
64    coefficients: &ObjectiveCoefficients,
65    demand: &DemandMap,
66) -> Result<AppraisalOutput> {
67    // Perform optimisation to calculate capacity, activity and unmet demand
68    let results = perform_optimisation(
69        asset,
70        max_capacity,
71        commodity,
72        coefficients,
73        demand,
74        &model.time_slice_info,
75        highs::Sense::Minimise,
76    )?;
77
78    // Calculate LCOX for the hypothetical investment
79    let annual_fixed_cost = coefficients.capacity_coefficient;
80    let activity_costs = &coefficients.activity_coefficients;
81    let cost_index = lcox(
82        results.capacity,
83        annual_fixed_cost,
84        &results.activity,
85        activity_costs,
86    );
87
88    // Return appraisal output
89    Ok(AppraisalOutput {
90        asset: asset.clone(),
91        capacity: results.capacity,
92        unmet_demand: results.unmet_demand,
93        metric: cost_index.value(),
94    })
95}
96
97/// Calculate NPV for a hypothetical investment in the given asset.
98fn calculate_npv(
99    model: &Model,
100    asset: &AssetRef,
101    max_capacity: Option<Capacity>,
102    commodity: &Commodity,
103    coefficients: &ObjectiveCoefficients,
104    demand: &DemandMap,
105) -> Result<AppraisalOutput> {
106    // Perform optimisation to calculate capacity, activity and unmet demand
107    let results = perform_optimisation(
108        asset,
109        max_capacity,
110        commodity,
111        coefficients,
112        demand,
113        &model.time_slice_info,
114        highs::Sense::Maximise,
115    )?;
116
117    // Calculate profitability index for the hypothetical investment
118    let annual_fixed_cost = -coefficients.capacity_coefficient;
119    let activity_surpluses = &coefficients.activity_coefficients;
120    let profitability_index = profitability_index(
121        results.capacity,
122        annual_fixed_cost,
123        &results.activity,
124        activity_surpluses,
125    );
126
127    // Return appraisal output
128    // Higher profitability index is better, so we make it negative for comparison
129    Ok(AppraisalOutput {
130        asset: asset.clone(),
131        capacity: results.capacity,
132        unmet_demand: results.unmet_demand,
133        metric: -profitability_index.value(),
134    })
135}
136
137/// Appraise the given investment with the specified objective type
138pub fn appraise_investment(
139    model: &Model,
140    asset: &AssetRef,
141    max_capacity: Option<Capacity>,
142    commodity: &Commodity,
143    objective_type: &ObjectiveType,
144    coefficients: &ObjectiveCoefficients,
145    demand: &DemandMap,
146) -> Result<AppraisalOutput> {
147    let appraisal_method = match objective_type {
148        ObjectiveType::LevelisedCostOfX => calculate_lcox,
149        ObjectiveType::NetPresentValue => calculate_npv,
150    };
151    appraisal_method(model, asset, max_capacity, commodity, coefficients, demand)
152}