Skip to content
Open
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
27 changes: 23 additions & 4 deletions src/finance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,29 @@ pub fn annual_capital_cost(
capital_cost * crf
}

/// Represents the profitability index of an investment
/// in terms of it's annualised components.
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 {
self.total_annualised_surplus / self.annualised_fixed_cost
}
}

/// Calculates an annual profitability index based on capacity and activity.
pub fn profitability_index(
capacity: Capacity,
annual_fixed_cost: MoneyPerCapacity,
activity: &IndexMap<TimeSliceID, Activity>,
activity_surpluses: &IndexMap<TimeSliceID, MoneyPerActivity>,
) -> Dimensionless {
) -> ProfitabilityIndex {
// Calculate the annualised fixed costs
let annualised_fixed_cost = annual_fixed_cost * capacity;

Expand All @@ -45,7 +61,10 @@ pub fn profitability_index(
total_annualised_surplus += activity_surplus * *activity;
}

total_annualised_surplus / annualised_fixed_cost
ProfitabilityIndex {
total_annualised_surplus,
annualised_fixed_cost,
}
}

/// Calculates annual LCOX based on capacity and activity.
Expand Down Expand Up @@ -171,7 +190,7 @@ mod tests {
&activity_surpluses,
);

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

#[test]
Expand All @@ -183,7 +202,7 @@ mod tests {

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

#[rstest]
Expand Down
1 change: 1 addition & 0 deletions src/fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutpu
activity,
demand,
unmet_demand,
metric_precedence: 0,
metric: 4.14,
}
}
4 changes: 4 additions & 0 deletions src/simulation/investment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::model::Model;
use crate::output::DataWriter;
use crate::region::RegionID;
use crate::simulation::CommodityPrices;
use crate::simulation::investment::appraisal::filter_for_minimum_precedence;
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
use anyhow::{Context, Result, bail, ensure};
Expand Down Expand Up @@ -708,6 +709,9 @@ fn select_best_assets(
outputs_for_opts.push(output);
}

// discard any appraisals with non-minimal metric precedence
outputs_for_opts = filter_for_minimum_precedence(outputs_for_opts);

// Save appraisal results
writer.write_appraisal_debug_info(
year,
Expand Down
88 changes: 85 additions & 3 deletions src/simulation/investment/appraisal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::finance::{lcox, profitability_index};
use crate::model::Model;
use crate::time_slice::TimeSliceID;
use crate::units::{Activity, Capacity};
use anyhow::Result;
use anyhow::{Result, bail};
use costs::annual_fixed_cost;
use indexmap::IndexMap;
use std::cmp::Ordering;
Expand All @@ -30,6 +30,10 @@ pub struct AppraisalOutput {
pub activity: IndexMap<TimeSliceID, Activity>,
/// The hypothetical unmet demand following investment in this asset
pub unmet_demand: DemandMap,
/// Where there is more than one possible metric for comparing appraisals, this integer
/// indicates the precedence of the metric (lower values have higher precedence).
/// Only metrics with the same precedence should be compared.
pub metric_precedence: u8,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably make this a u32 instead. I know we won't ever need more than 256 different values here (if we did, that would be horrible!), but I think it's best to use 32-bit integers as a default, unless there's a good reason not to.

/// The comparison metric to compare investment decisions (lower is better)
pub metric: f64,
/// Capacity and activity coefficients used in the appraisal
Expand Down Expand Up @@ -112,6 +116,14 @@ pub fn classify_appraisal_comparison_method(
}
}

/// Filter mixed-precedence appraisal outputs to only those with the minimum metric precedence
pub fn filter_for_minimum_precedence(mut outputs: Vec<AppraisalOutput>) -> Vec<AppraisalOutput> {
if let Some(min_precedence) = outputs.iter().map(|o| o.metric_precedence).min() {
outputs.retain(|o| o.metric_precedence == min_precedence);
}
outputs
}

/// Calculate LCOX for a hypothetical investment in the given asset.
///
/// This is more commonly referred to as Levelised Cost of *Electricity*, but as the model can
Expand Down Expand Up @@ -151,6 +163,7 @@ fn calculate_lcox(
capacity: results.capacity,
activity: results.activity,
unmet_demand: results.unmet_demand,
metric_precedence: 0,
metric: cost_index.value(),
coefficients: coefficients.clone(),
demand: demand.clone(),
Expand Down Expand Up @@ -179,6 +192,9 @@ fn calculate_npv(

// Calculate profitability index for the hypothetical investment
let annual_fixed_cost = annual_fixed_cost(asset);
if annual_fixed_cost.value() < 0.0 {
bail!("The current NPV calculation does not support negative annual fixed costs");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this actually happen? I'm struggling to think... @tsmbland?

If it's more of a sanity check instead (still worth doing!) then I'd change this to an assert! instead.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is then something has gone badly wrong! Agree, better to change to assert!

}
let activity_surpluses = &coefficients.activity_coefficients;
let profitability_index = profitability_index(
results.capacity,
Expand All @@ -187,14 +203,23 @@ fn calculate_npv(
activity_surpluses,
);

// calculate metric and precedence depending on asset parameters
// note that metric will be minimised so if larger is better, we negate the value
let (metric_precedence, metric) = match annual_fixed_cost.value() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking again on this, I think this logic (whatever it becomes, see my other comment) should be within the ProfitabilityIndex.value, also adding a ProfitabilityIndex.precedence method that returns 0 or 1 depending on the value of AFC.

// If AFC is zero, use total surplus as the metric (strictly better than nonzero AFC)
0.0 => (0, -profitability_index.total_annualised_surplus.value()),
// If AFC is non-zero, use profitability index as the metric
_ => (1, -profitability_index.value().value()),
Comment on lines +208 to +212
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not fully convinced by this. the profitability index is dimensionless, but the annualised surplus is Money. Even though it does not matter from the typing perspective since you are getting the underlying value in both cases, which is float, I wonder if this choice makes sense from a logic perspective.

Not that I've a better suggestion.

};

// Return appraisal output
// Higher profitability index is better, so we make it negative for comparison
Ok(AppraisalOutput {
asset: asset.clone(),
capacity: results.capacity,
activity: results.activity,
unmet_demand: results.unmet_demand,
metric: -profitability_index.value(),
metric_precedence,
metric,
coefficients: coefficients.clone(),
demand: demand.clone(),
})
Expand All @@ -216,3 +241,60 @@ pub fn appraise_investment(
};
appraisal_method(model, asset, max_capacity, commodity, coefficients, demand)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::asset::Asset;
use crate::fixture::{asset, time_slice};
use crate::units::{
Activity, Capacity, Flow, MoneyPerActivity, MoneyPerCapacity, MoneyPerFlow,
};
use indexmap::indexmap;
use rstest::rstest;

/// Create an AppraisalOutput with customisable metric precedence
fn appraisal_output(
asset: Asset,
time_slice: TimeSliceID,
metric_precedence: u8,
) -> AppraisalOutput {
let activity_coefficients = indexmap! { time_slice.clone() => MoneyPerActivity(0.5) };
let activity = indexmap! { time_slice.clone() => Activity(10.0) };
let demand = indexmap! { time_slice.clone() => Flow(100.0) };
let unmet_demand = indexmap! { time_slice.clone() => Flow(5.0) };

AppraisalOutput {
asset: AssetRef::from(asset),
capacity: Capacity(42.0),
coefficients: ObjectiveCoefficients {
capacity_coefficient: MoneyPerCapacity(3.14),
activity_coefficients,
unmet_demand_coefficient: MoneyPerFlow(10000.0),
},
activity,
demand,
unmet_demand,
metric_precedence,
metric: 4.14,
}
}

#[rstest]
fn test_filter_for_minimum_precedence(asset: Asset, time_slice: TimeSliceID) {
let outputs = vec![
appraisal_output(asset.clone(), time_slice.clone(), 1),
appraisal_output(asset.clone(), time_slice.clone(), 0),
appraisal_output(asset.clone(), time_slice.clone(), 2),
appraisal_output(asset.clone(), time_slice.clone(), 0),
appraisal_output(asset.clone(), time_slice.clone(), 1),
appraisal_output(asset, time_slice, 1),
];

let filtered = filter_for_minimum_precedence(outputs);

assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].metric_precedence, 0);
assert_eq!(filtered[1].metric_precedence, 0);
}
}