Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 15 additions & 49 deletions docs/model/investment.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,56 +137,9 @@ providing investment and dynamic decommissioning decisions.

### Tools

#### Tool A: NPV
#### Tool A: LCOX (`objective_type` = "lcox")

This method is used when the decision rule is `single` and the objective is annualised profit for
agents serving commodity \\( c \\). It iteratively builds a supply portfolio by selecting
options that offer the highest annualised profit for serving the current commodity demand. The
economic evaluation uses \\( \pi_{prevMSY} \\) prices and takes account of asset-specific
operational constraints (e.g., minimum load levels) and the balance level of the target commodity
(time slice profile, seasonal or annual). For each asset option:

- **Optimise capacity and dispatch to maximise annualised profit:** Solve a small optimisation
sub-problem to maximise the asset's surplus, subject to its operational rules and the specific
demand tranche it is being asked to serve.

\\[
maximise \Big\\{\sum_t act_t AC\_{t}^{NPV}
\Big\\}
\\]

Where \\( act_t \\) is a decision variable, and subject to:

- The asset operational constraints (e.g., \\( avail_{LB}, avail_{EQ} \\), etc.), activity less
than capacity, applied to its activity profile \\( act_t \\).

- A demand constraint, where output cannot exceed demand in the tranche, which adapts based on the
commodity's balance level (time slice, season, annual).

- Capacity is constrained up to \\( CapMaxBuild \\) for candidates, and to known capacity for
existing assets.

- **Decide on metric:** The type of metric used to compare profitability is dependent on the value of
\\(\text{AFC}\\). If \\(\text{AFC} = 0\\) within the tolerance provided by the `float_cmp` crate,
the associated investment option is always prioritised over options with \\(\text{AFC} > 0\\).

- **If \\(\text{AFC} > 0\\), Use the profitability index \\(\text{PI}\\) metric:** This is the total
annualised surplus divided by the annualised fixed cost.
\\[
\text{PI} =
\frac{\sum\_t act\_t \cdot \text{AC}\_t^{\text{NPV}}}{\text{AFC} \cdot \text{cap}}
\\]

- **If \\(\text{AFC} = 0\\), Use the total annualised surplus metric \\(\text{TAS}\\):**
\\[
\text{TAS} =
\sum\_t act\_t \cdot \text{AC}\_t^{\text{NPV}}
\\]

#### Tool B: LCOX

This method is used when decision rule is single objective and objective is LCOX for agents' serving
commodity \\( c \\). This method constructs a supply portfolio (from new candidates \\( ca \\), new
This method constructs a supply portfolio (from new candidates \\( ca \\), new
import infrastructure \\( ca_{import} \\), and available existing assets \\( ex \\)) to meet target
\\( U_{c} \\) at the lowest cost for the investor. As above, the appraisal for each option
explicitly accounts for its own operational constraints and adapts based on the \\( balance\_level
Expand Down Expand Up @@ -226,6 +179,19 @@ For each asset option:
\times \text{AC}_t^{\text{LCOX}}}{\sum_t act_t}
\\]

#### Tool B: NPV (`objective_type` = "npv")

This method uses the Specific Net Annualised Surplus (SNAS) to rank options. It is similar in
Comment thread
dc2917 marked this conversation as resolved.
structure to the LCOX calculation, but uses activity values that include the commodity of interest
and compares options by *maximising* surplus:

\\[
\text{SNAS} = \frac{\sum_t act_t \times AC_t^{\text{NPV}} - \text{AFC} \times \text{cap}_r}{\sum_t
act_t}
\\]

Higher SNAS values indicate more profitable investments.

#### Equal-Metric Fallback

If two or more investment options from the same tool have equal metrics, the following tie-breaking
Expand Down
2 changes: 1 addition & 1 deletion docs/model/prices.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ SVD}} \text{OutputCoefficient}_c}
\\]

> Note: this only works if all output commodities are measured in the same energy units (e.g. PJ).
> For this reason, MUSE2 disallows processes that have output commodities with differing units.*
> For this reason, MUSE2 disallows processes that have output commodities with differing units.

The final full cost of output commodity \\( c \\) is:
\\[
Expand Down
130 changes: 9 additions & 121 deletions src/finance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
use crate::time_slice::TimeSliceID;
use crate::units::{Activity, Capacity, Dimensionless, Money, MoneyPerActivity, MoneyPerCapacity};
use indexmap::IndexMap;
use serde::Serialize;

/// Calculates the capital recovery factor (CRF) for a given lifetime and discount rate.
///
Expand All @@ -29,52 +28,22 @@ pub fn annual_capital_cost(
capital_cost * crf
}

/// Represents the profitability index of an investment
/// in terms of its annualised components.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct ProfitabilityIndex {
/// The total annualised surplus of an asset
pub total_annualised_surplus: Money,
/// The total annualised fixed cost of an asset
pub annualised_fixed_cost: Money,
}

impl ProfitabilityIndex {
/// Calculates the value of the profitability index.
pub fn value(&self) -> Dimensionless {
assert!(
self.annualised_fixed_cost != Money(0.0),
"Annualised fixed cost cannot be zero when calculating profitability index."
);
self.total_annualised_surplus / self.annualised_fixed_cost
}
}

/// Calculates an annual profitability index based on capacity and activity.
pub fn profitability_index(
/// Calculates the SNAS (Specific Net Annualised Surplus) based on capacity and activity.
///
/// It is just the negative of the LCOX, although, unlike LCOX, it should be called with
/// activity costs that INCLUDE the commodity of interest.
pub fn snas(
capacity: Capacity,
annual_fixed_cost: MoneyPerCapacity,
activity: &IndexMap<TimeSliceID, Activity>,
activity_surpluses: &IndexMap<TimeSliceID, MoneyPerActivity>,
) -> ProfitabilityIndex {
// Calculate the annualised fixed costs
let annualised_fixed_cost = annual_fixed_cost * capacity;

// Calculate the total annualised surplus
let mut total_annualised_surplus = Money(0.0);
for (time_slice, activity) in activity {
let activity_surplus = activity_surpluses[time_slice];
total_annualised_surplus += activity_surplus * *activity;
}

ProfitabilityIndex {
total_annualised_surplus,
annualised_fixed_cost,
}
activity_costs: &IndexMap<TimeSliceID, MoneyPerActivity>,
) -> Option<MoneyPerActivity> {
lcox(capacity, annual_fixed_cost, activity, activity_costs).map(|lcox| -lcox)
Comment thread
tsmbland marked this conversation as resolved.
}

/// Calculates annual LCOX based on capacity and activity.
///
/// It should be called with activity costs that EXCLUDE the commodity of interest.
/// If the total activity is zero, then it returns `None`, otherwise `Some` LCOX value.
pub fn lcox(
capacity: Capacity,
Expand Down Expand Up @@ -104,7 +73,6 @@ mod tests {
use super::*;
use crate::time_slice::TimeSliceID;
use float_cmp::assert_approx_eq;
use indexmap::indexmap;
use rstest::rstest;

#[rstest]
Expand Down Expand Up @@ -141,86 +109,6 @@ mod tests {
assert_approx_eq!(MoneyPerCapacity, result, expected, epsilon = 1e-8);
}

#[rstest]
#[case(
100.0, 50.0,
vec![("winter", "day", 10.0), ("summer", "night", 15.0)],
vec![("winter", "day", 30.0), ("summer", "night", 20.0)],
0.12 // Expected PI: (10*30 + 15*20) / (100*50) = 600/5000 = 0.12
)]
#[case(
50.0, 100.0,
vec![("q1", "peak", 5.0)],
vec![("q1", "peak", 40.0)],
0.04 // Expected PI: (5*40) / (50*100) = 200/5000 = 0.04
)]
fn profitability_index_works(
#[case] capacity: f64,
#[case] annual_fixed_cost: f64,
#[case] activity_data: Vec<(&str, &str, f64)>,
#[case] surplus_data: Vec<(&str, &str, f64)>,
#[case] expected: f64,
) {
let activity = activity_data
.into_iter()
.map(|(season, time_of_day, value)| {
(
TimeSliceID {
season: season.into(),
time_of_day: time_of_day.into(),
},
Activity(value),
)
})
.collect();

let activity_surpluses = surplus_data
.into_iter()
.map(|(season, time_of_day, value)| {
(
TimeSliceID {
season: season.into(),
time_of_day: time_of_day.into(),
},
MoneyPerActivity(value),
)
})
.collect();

let result = profitability_index(
Capacity(capacity),
MoneyPerCapacity(annual_fixed_cost),
&activity,
&activity_surpluses,
);

assert_approx_eq!(Dimensionless, result.value(), Dimensionless(expected));
}

#[test]
fn profitability_index_zero_activity() {
let capacity = Capacity(100.0);
let annual_fixed_cost = MoneyPerCapacity(50.0);
let activity = indexmap! {};
let activity_surpluses = indexmap! {};

let result =
profitability_index(capacity, annual_fixed_cost, &activity, &activity_surpluses);
assert_eq!(result.value(), Dimensionless(0.0));
}

#[test]
#[should_panic(expected = "Annualised fixed cost cannot be zero")]
fn profitability_index_panics_on_zero_cost() {
let result = profitability_index(
Capacity(0.0),
MoneyPerCapacity(100.0),
&indexmap! {},
&indexmap! {},
);
result.value();
}

#[rstest]
#[case(
100.0, 50.0,
Expand Down
Loading
Loading