Skip to content

Second dispatch solve to spread activity - #1469

Closed
tsmbland wants to merge 22 commits into
mainfrom
lexico_optimisation
Closed

Second dispatch solve to spread activity#1469
tsmbland wants to merge 22 commits into
mainfrom
lexico_optimisation

Conversation

@tsmbland

@tsmbland tsmbland commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Description

Add's a second solve to encourage spreading of utilisation where there's no harm to the main objective:

  • spread utilisation uniformly over assets with the same primary output commodity in the same region
  • spread utilisation uniformly across time for each asset, according to the balance level of the primary output (e.g. within each season)

This hot starts off the back of the first solve, which is a bit more efficient than setting up a new solve from scratch (and I guess should be more efficient than a single-solve approach like in #1463, but I haven't done a direct comparison). There is a significant performance cost revealed by benchmarking - somewhere between 15-40% for the example models, but not the end of the world. I imagine this could be even worse for larger models though - ideally we'd do some targeted benchmarking for the dispatch optimisation, including much bigger problems.

Situations where the performance cost may be particularly big:

  • lots of agents serving the same commodity
  • lots of seasonally/annually balanced commodities
  • lots of timeslices
  • lots of years
  • lots of processes serving the same commodity
  • lots of assets in the input data

Added some tests to verify that this works. Due to the complexity of the input data for the dispatch function, it was easiest just to run integration tests on variants of the simple model and check the results.

Could be ways to make this more efficient, like only balancing variables that are truly degenerate in the first solve, which you can probably figure out based on reduced costs/duals (I think anything with a "column dual" > 0 will never be selected so can be safely excluded)

I've found that, while the results are qualitatively identical on different platforms (which is the important thing), I did have to increase the tolerance in the regression tests as we get larger floating point differences compared to before (1e-10 to 1e-6). This is still tiny, and I guess it's just because the problem is larger than before. The results are also ~identical with the new version of highs (see #1471) - just a few changes above the tolerance, which isn't too surprising, but no qualitative differences which we were certainly seeing before.

Fixes #1399
Fixes #1174 (No more qualitative differences, with the caveat that floating point differences are actually larger. It's possible that there may still be degeneracy in the problem that could cause results to differ qualitatively between platforms - difficult to rule this out entirely - but I think this is far less likely than before)

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 adds an optional second (lexicographic) HiGHS solve in the dispatch optimisation to reduce “corner solutions” by spreading utilisation more evenly (across identical assets and across time slices at the balance level), while keeping the primary cost objective near-optimal.

Changes:

  • Capture and reuse the original cost coefficients to add a cost-bound constraint for a second solve, then add L1 “equalisation” constraints/variables to minimise utilisation spread.
  • Introduce new model parameters to enable/disable the equalisation solve and configure its cost tolerance.
  • Add integration-style tests using the simple example model to validate equalised dispatch behaviour via debug output CSVs.

Reviewed changes

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

File Description
src/simulation/optimisation.rs Implements the second lexicographic dispatch solve and adds equalisation constraints plus integration tests.
src/model/parameters.rs Adds parameters controlling dispatch equalisation and its tolerance (with defaults).
schemas/input/model.yaml Exposes the new model parameters in the input schema with notes/defaults.
Suppressed comments (2)

src/simulation/optimisation.rs:1112

  • This test’s relative error uses diff = |rate - max| / max with only a max <= 0.0 guard. If max is tiny but positive, the division can amplify numerical noise and cause flaky failures. Consider using a small-denominator threshold (or abs+rel tolerance) before computing relative error.
            let max = rates.iter().copied().fold(0.0_f64, f64::max);
            if max <= 0.0 {
                continue;
            }
            for &rate in rates {
                let diff = (rate - max).abs() / max;
                assert!(
                    diff < 1e-6,
                    "GASDRV utilisation rate not equal across time slices in season {season}: {rates:?}"

src/model/parameters.rs:93

  • The docstring formula Z* * (1 + tolerance) has the same sign issue as the implementation: if Z* is negative, it tightens the bound. To match the intended “fractional tolerance” meaning, describe the bound in terms of |Z*| (e.g. Z* + |Z*| * tolerance).
    /// Fractional tolerance on the primary cost for the lexicographic second solve.
    ///
    /// The second solve constrains total cost to at most `Z* * (1 + tolerance)`, where `Z*` is
    /// the optimal cost from the first solve.
    #[serde(deserialize_with = "deserialise_finite_non_negative")]

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

Comment on lines +685 to +686
// Constrain total cost to be no worse than z_star * (1 + tolerance)
model.add_row(..=(z_star * (1.0 + tolerance)), cost_terms);

@tsmbland tsmbland Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think this is highly unlikely (/maybe impossible), unless the user is trying to do something stupid

Comment on lines +707 to +714
let solution = Solution {
solution: solved2.get_solution(),
variables,
time_slice_info: &self.model.time_slice_info,
constraint_keys,
objective_value: Money(solution.objective_value()),
})
// Report the primary cost objective, not the spreading objective value
objective_value: Money(z_star),
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I guess, but I don't think it's a huge deal. We don't use these objective values for anything, and in reality the tolerance is going to be effectively zero (really just enough to allow for floating point differences)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think ideally we'd report the objective value both before and after equalisation, to see the effect that it has on the primary cost. It might just be a bit cumbersome to compute this after the second solve

Comment on lines +764 to +771
for i in 0..terms.len() {
for j in (i + 1)..terms.len() {
let (act_a, inv_cap_a) = terms[i];
let (act_b, inv_cap_b) = terms[j];
let d = model.add_col(1.0, 0.0.., []);
model.add_row(0.0.., [(d, 1.0), (act_a, -inv_cap_a), (act_b, inv_cap_b)]);
model.add_row(0.0.., [(d, 1.0), (act_a, inv_cap_a), (act_b, -inv_cap_b)]);
}

@tsmbland tsmbland Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think performance needs to be investigated. The group utilisation variable idea is similar to what I tried in #1463, and it works ok IF the optimum solution is for all variables in the group to be equal, but that won't always necessarily be the case. If there's real variation in the group then this approach stops working because there's no longer an incentive for any variables that should equalise to do so.

Comment thread src/simulation/optimisation.rs
Comment thread src/simulation/optimisation.rs
Comment thread src/simulation/optimisation.rs
Comment thread schemas/input/model.yaml
Comment thread src/model/parameters.rs
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.00000% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.03%. Comparing base (5e906d4) to head (9dddcbb).

Files with missing lines Patch % Lines
src/simulation/optimisation.rs 92.92% 11 Missing and 3 partials ⚠️
Additional details and impacted files
@@                Coverage Diff                @@
##           dispatch_docs    #1469      +/-   ##
=================================================
+ Coverage          89.78%   90.03%   +0.25%     
=================================================
  Files                 60       60              
  Lines               8439     8631     +192     
  Branches            8439     8631     +192     
=================================================
+ Hits                7577     7771     +194     
+ Misses               545      541       -4     
- Partials             317      319       +2     

☔ View full report in Codecov by Harness.
📢 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.

@tsmbland tsmbland changed the title Second dispatch optimisation to spread activity Second dispatch solve to spread activity Aug 3, 2026
@tsmbland
tsmbland force-pushed the lexico_optimisation branch from 4e477bd to ac05803 Compare August 5, 2026 12:24
@tsmbland
tsmbland changed the base branch from main to dispatch_docs August 5, 2026 12:25
Base automatically changed from dispatch_docs to main August 5, 2026 15:38
@tsmbland

tsmbland commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

The performance cost is too big. Thinking of another approach (see #1477 and #1478)

@tsmbland tsmbland closed this Aug 7, 2026
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.

Price differences when calculated for individual assets in multi agent model Potential for different dispatch results on different platforms

2 participants