Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
701ab71
add profitability index struct
Aurashk Dec 8, 2025
f2ebcae
add metric_precedence for to allow for comparing appraisals with diff…
Aurashk Dec 10, 2025
d8dead1
add comment for clarity
Aurashk Dec 10, 2025
8baf828
add bail for negative AFC
Aurashk Dec 10, 2025
bdcf224
clearer comment
Aurashk Dec 10, 2025
e205cc9
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Dec 10, 2025
1f8a752
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 5, 2026
0d9a07a
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 5, 2026
5f5b59b
implement traits approach to comparing metrics
Aurashk Jan 6, 2026
c1736a8
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 6, 2026
d411957
move nan assertion to compare_metric
Aurashk Jan 6, 2026
5f66991
improve adherence to units system
Aurashk Jan 8, 2026
18034e4
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 8, 2026
9dc1028
add assertion against calculating an infinite profitability index
Aurashk Jan 8, 2026
5e8a65e
correct comments tying
Aurashk Jan 8, 2026
29e38a3
correct comments
Aurashk Jan 8, 2026
47056e0
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 12, 2026
9a7f6cd
simplify npv metric struct
Aurashk Jan 12, 2026
e5861b3
Add erased serialize dependency
Aurashk Jan 13, 2026
61a22e2
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 13, 2026
e17ecb9
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 13, 2026
534da28
add serde json and serialisation for metrics
Aurashk Jan 13, 2026
0e4185b
Revert "add serde json and serialisation for metrics"
Aurashk Jan 14, 2026
13f1af7
add compare_approx helper and simplify
Aurashk Jan 14, 2026
4da0155
add tests for metric compare methods
Aurashk Jan 14, 2026
a40bc6a
Merge branch 'main' into make-profitability-index-more-robust
Aurashk Jan 14, 2026
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
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ strum = {version = "0.27.2", features = ["derive"]}
documented = "0.9.2"
dirs = "6.0.0"
edit = "0.1.5"
erased-serde = "0.4.9"

[dev-dependencies]
map-macro = "0.3.0"
Expand Down
51 changes: 41 additions & 10 deletions src/finance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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 @@ -28,13 +29,34 @@ 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 {
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 wondering if we really need this now that we have NPVMetric. Could just have profitability_index return a tuple and store each value in NPVMetric. Not sure if that's better?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I generally prefer structs over tuples but don't feel strongly either way about this

/// 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."
);
Comment on lines +45 to +48
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

The assertion that checks annualised fixed cost is non-zero only runs in debug builds. In release builds, if this condition is violated, the division on line 49 would produce infinity or NaN, leading to incorrect behavior. Consider using a runtime check (like ensure!) that returns an error instead of assert! to enforce this constraint in all builds.

Copilot uses AI. Check for mistakes.
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 +67,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 @@ -126,12 +151,6 @@ mod tests {
vec![("q1", "peak", 40.0)],
0.04 // Expected PI: (5*40) / (50*100) = 200/5000 = 0.04
)]
#[case(
0.0, 100.0,
vec![("winter", "day", 10.0)],
vec![("winter", "day", 50.0)],
f64::INFINITY // Zero capacity case
)]
fn profitability_index_works(
#[case] capacity: f64,
#[case] annual_fixed_cost: f64,
Expand Down Expand Up @@ -172,7 +191,7 @@ mod tests {
&activity_surpluses,
);

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

#[test]
Expand All @@ -184,7 +203,19 @@ 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));
}

#[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();
Comment on lines +209 to +218
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

The new test profitability_index_panics_on_zero_cost tests that calling .value() on a ProfitabilityIndex with zero annualised fixed cost panics. However, this test uses Capacity(0.0) with MoneyPerCapacity(100.0), which results in zero annualised fixed cost. Consider adding a comment to clarify this calculation or restructure the test to use MoneyPerCapacity(0.0) with non-zero capacity to make the intent clearer.

Copilot uses AI. Check for mistakes.
}
Comment on lines +209 to 219
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

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

The removed test case for zero capacity with infinite profitability index has been replaced with a panic test, but there's no test coverage for the new NPVMetric behavior when annualised_fixed_cost is zero. Consider adding integration tests that verify the entire appraisal flow correctly handles assets with zero annual fixed costs and compares them properly against assets with non-zero fixed costs.

Copilot uses AI. Check for mistakes.

#[rstest]
Expand Down
3 changes: 2 additions & 1 deletion src/fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::process::{
ProcessInvestmentConstraintsMap, ProcessMap, ProcessParameter, ProcessParameterMap,
};
use crate::region::RegionID;
use crate::simulation::investment::appraisal::LCOXMetric;
use crate::simulation::investment::appraisal::{
AppraisalOutput, coefficients::ObjectiveCoefficients,
};
Expand Down Expand Up @@ -391,7 +392,7 @@ pub fn appraisal_output(asset: Asset, time_slice: TimeSliceID) -> AppraisalOutpu
activity,
demand,
unmet_demand,
metric: 4.14,
metric: Box::new(LCOXMetric::new(MoneyPerActivity(4.14))),
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ impl DebugDataWriter {
region_id: result.asset.region_id().clone(),
capacity: result.capacity,
capacity_coefficient: result.coefficients.capacity_coefficient,
metric: result.metric,
metric: result.metric.value(),
};
self.appraisal_results_writer.serialize(row)?;
}
Expand Down
Loading