Skip to content

Handle LCOX errors correctly#1129

Open
alexdewar wants to merge 8 commits intomainfrom
handle-lcox-errors
Open

Handle LCOX errors correctly#1129
alexdewar wants to merge 8 commits intomainfrom
handle-lcox-errors

Conversation

@alexdewar
Copy link
Collaborator

@alexdewar alexdewar commented Feb 13, 2026

Description

In the course of a discussion about something else (#1115), it transpired that it is possible to end up with a NaN value for LCOX, if the total activity is zero and the denominator is zero, which we don't want. We should panic if any of the metrics is zero, but in this case, the offending asset was filtered out because its capacity was zero anyway. We shouldn't rely on this though 😉.

I've refactored things so that appraise_investment now returns Result<Option<AppraisalOutput>> instead of Result<AppraisalOutput>. It returns Ok(None) in the case that the investment is not a viable option, currently either because the returned capacity is zero or, for LCOX, the total activity was zero. In these cases, the appraisal result will no longer be written to file, but I'm not sure that's a great loss, because we are only skipping results with invalid results anyway. It seems worth it not to have inconsistent AppraisalOutputs floating around.

Unrelated change: While I was refactoring things, I noticed we are storing deep copies of the remaining demand and objective coefficients in AppraisalOutput, which is less than ideal. If there are large numbers of assets, this would mean a lot of unnecessary allocations and deallocations. For the demand, the value is the same for every AppraisalOutput anyway (it's the demand that the appraisal is trying to meet), so I just removed this field and changed things to manually pass this value to the one place it's used (writing output files). For objective coefficients, I took the easy option and wrapped it in an Rc, which seemed cleaner than either storing a reference and passing lifetime parameters all over the place or passing the coefficients again where they are used.

Fixes #1126.

Type of change

  • Bug fix (non-breaking change to fix an issue)
  • New feature (non-breaking change to add functionality)
  • Refactoring (non-breaking, non-functional change to improve maintainability)
  • Optimization (non-breaking change to speed up the code)
  • Breaking change (whatever its nature)
  • Documentation (improve or add documentation)

Key checklist

  • All tests pass: $ cargo test
  • The documentation builds and looks OK: $ cargo doc
  • Update release notes for the latest release if this PR adds a new feature or fixes a bug
    present in the previous release

Further checks

  • Code is commented, particularly in hard-to-understand areas
  • Tests added that prove fix is effective or that feature works

The demand map is the same for all `AppraisalOutput`s for a given appraisal iteration, so there's no need to store it. If many assets are being appraised, it could result in many clones.
Copilot AI review requested due to automatic review settings February 13, 2026 15:49
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a bug where LCOX calculations could result in NaN values when total activity is zero (related to issue #1115), and refactors the investment appraisal code to handle non-viable investment options more cleanly. The refactoring also includes performance optimizations by using reference counting (Rc) for shared data structures.

Changes:

  • Modified lcox() function to return Option<MoneyPerActivity> instead of MoneyPerActivity, returning None when total activity is zero to prevent NaN values
  • Refactored appraise_investment() to return Result<Option<AppraisalOutput>>, with Ok(None) indicating non-viable investment options (zero capacity or zero activity)
  • Optimized memory usage by wrapping ObjectiveCoefficients in Rc to avoid unnecessary deep copies across multiple AppraisalOutput instances, and removed the demand field from AppraisalOutput (passing it explicitly where needed instead)

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/finance.rs Modified lcox() to return Option and added test case for zero activity scenario
src/simulation/investment/appraisal.rs Added AppraisalOutput::new() constructor that filters zero-capacity results, updated calculate_lcox() and calculate_npv() to handle Option returns, removed redundant zero-capacity filtering from sort function, removed obsolete test, updated return types to Result<Option<AppraisalOutput>>
src/simulation/investment/appraisal/coefficients.rs Wrapped return type in Rc<ObjectiveCoefficients> for shared ownership
src/simulation/investment.rs Updated to handle Option return from appraise_investment(), passes demand explicitly to output writer
src/output.rs Added demand parameter to appraisal result writing functions
src/fixture.rs Updated test fixture to use Rc for coefficients and removed demand field

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codecov
Copy link

codecov bot commented Feb 13, 2026

Codecov Report

❌ Patch coverage is 98.24561% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.54%. Comparing base (d6c1332) to head (2a2a587).

Files with missing lines Patch % Lines
src/simulation/investment.rs 80.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1129      +/-   ##
==========================================
+ Coverage   87.52%   87.54%   +0.01%     
==========================================
  Files          55       55              
  Lines        7626     7638      +12     
  Branches     7626     7638      +12     
==========================================
+ Hits         6675     6687      +12     
+ Misses        649      648       -1     
- Partials      302      303       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI review requested due to automatic review settings February 13, 2026 15:58
@alexdewar alexdewar requested a review from Aurashk February 13, 2026 15:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +75 to +83
/// Returns `None` if the capacity is zero, otherwise `Some(AppraisalOutput)` with the specified
/// parameters.
pub fn new<T: MetricTrait>(
asset: AssetRef,
results: ResultsMap,
metric: T,
coefficients: Rc<ObjectiveCoefficients>,
) -> Option<Self> {
if results.capacity.total_capacity() == Capacity(0.0) {
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

AppraisalOutput::new treats capacity as “zero” only when it is exactly Capacity(0.0). Since total_capacity() is derived from solver output (floating point), this exact-equality check can miss very small/negative values and let effectively-zero-capacity options through. Consider using <= Capacity(0.0) (or an epsilon/approx check consistent with the rest of the module) to decide viability.

Suggested change
/// Returns `None` if the capacity is zero, otherwise `Some(AppraisalOutput)` with the specified
/// parameters.
pub fn new<T: MetricTrait>(
asset: AssetRef,
results: ResultsMap,
metric: T,
coefficients: Rc<ObjectiveCoefficients>,
) -> Option<Self> {
if results.capacity.total_capacity() == Capacity(0.0) {
/// Returns `None` if the capacity is zero (or effectively zero / non-positive),
/// otherwise `Some(AppraisalOutput)` with the specified parameters.
pub fn new<T: MetricTrait>(
asset: AssetRef,
results: ResultsMap,
metric: T,
coefficients: Rc<ObjectiveCoefficients>,
) -> Option<Self> {
if compare_approx(results.capacity.total_capacity(), Capacity(0.0)) != Ordering::Greater {

Copilot uses AI. Check for mistakes.
50.0, 100.0,
vec![("winter", "day", 0.0)],
vec![("winter", "day", 0.0)],
None // (50*0 + 25*0) / 0 = not feasible
Copy link

Copilot AI Feb 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 case’s inline explanation is mathematically incorrect: the fixed-cost term is still annual_fixed_cost * capacity (e.g. 100*50), so the numerator is not zero even though total activity is. Please update the comment to reflect the actual expression being avoided (division by zero leading to an invalid/infinite result).

Suggested change
None // (50*0 + 25*0) / 0 = not feasible
None // (50*100 + 0*0) / 0 -> division by zero (not feasible)

Copilot uses AI. Check for mistakes.
Comment on lines 72 to +96
impl AppraisalOutput {
/// Create a new `AppraisalOutput`.
///
/// Returns `None` if the capacity is zero, otherwise `Some(AppraisalOutput)` with the specified
/// parameters.
pub fn new<T: MetricTrait>(
asset: AssetRef,
results: ResultsMap,
metric: T,
coefficients: Rc<ObjectiveCoefficients>,
) -> Option<Self> {
if results.capacity.total_capacity() == Capacity(0.0) {
debug!("Skipping investment option with zero capacity");
return None;
}

Some(Self {
asset,
capacity: results.capacity,
activity: results.activity,
unmet_demand: results.unmet_demand,
metric: Box::new(metric),
coefficients,
})
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

New viability logic is centralized in AppraisalOutput::new (skipping zero-capacity options), but there’s no unit test exercising that constructor behavior. Adding a small test that builds a ResultsMap with Capacity(0.0) and asserts AppraisalOutput::new(...) returns None would help prevent regressions (and would replace the removed “filter zero capacity during sort” test with a more direct check).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LCOX returns NaN if activity is zero

1 participant