Skip to content

Smarter asset grouping - #1492

Merged
tsmbland merged 14 commits into
mainfrom
asset_grouping
Aug 19, 2026
Merged

Smarter asset grouping#1492
tsmbland merged 14 commits into
mainfrom
asset_grouping

Conversation

@tsmbland

@tsmbland tsmbland commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Description

#1477 equalises utilisation of equivalent assets in dispatch, to avoid the solver arbitrarily utilising one over another. There's some ambiguity over what "equivalent" means. For the purposes of that PR, equivalent assets are assets of the same process in the same region. However, that isn't particularly robust:

  • Assets from different processes may have identical parameters for the purposes of dispatch, so should be equalised. (e.g. two processes that differ only in capital cost, which isn't relevant for dispatch)
  • Similarly, assets from the same process may have different parameters (i.e. if the commission year is different), so should not be equalised.

#1488, now discarded, addressed the second point (at a significant cost to usability), but did not address the first point.

This PR tries to address both points by comparing the relevant parameters between assets. Assets in the same region are considered identical if their variable operating costs, flows and availability limits are identical. Comparisons are deliberately conservative such that any difference in any of these, however small, means the assets are considered not equivalent. Adding any kind of tolerance would be much more fiddly. We shouldn't have to worry about floating point differences here as the parameters we're comparing come (almost) directly from the input data.

To avoid lots of potentially expensive pairwise comparisons, the approach is to first create a hash for each asset using the relevant parameters, then only do full comparisons on assets with the same hash (as comparing based on hash alone may create false positives, however unlikely).

Overall, compared with the parent branch, this doesn't seem to have any detectable impact on performance in the models I've tested. Nor does it change any of the example model results, as none of these suffer from process parameters changing over time or redundant processes.

Fixes # (issue)

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

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.12%. Comparing base (061fbeb) to head (f0f4063).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1492      +/-   ##
==========================================
+ Coverage   90.04%   90.12%   +0.07%     
==========================================
  Files          60       60              
  Lines        8601     8665      +64     
  Branches     8601     8665      +64     
==========================================
+ Hits         7745     7809      +64     
  Misses        537      537              
  Partials      319      319              

☔ 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.

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 refines the “equal utilisation” dispatch constraints by introducing a more robust notion of dispatch-equivalence between assets, so the optimiser equalises utilisation across assets that behave the same in dispatch (even across different processes), while avoiding equalisation when key dispatch-driving parameters differ.

Changes:

  • Update equal-utilisation constraints to group assets by dispatch-equivalence rather than (region, process) identity.
  • Introduce Asset::is_dispatch_equivalent to centralise the equivalence definition (and add unit tests for it).

Reviewed changes

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

File Description
src/simulation/optimisation/constraints.rs Switch asset grouping strategy for equal-utilisation constraints to use dispatch-equivalence comparisons.
src/asset.rs Add is_dispatch_equivalent helper and tests to define/validate dispatch-equivalence semantics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/simulation/optimisation/constraints.rs Outdated
Comment thread src/asset.rs

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/asset.rs:721

  • is_dispatch_equivalent relies on == for ActivityLimits and the IndexMap of flows. Both are order-sensitive (because they contain/are IndexMaps), so two assets with identical limits/flows but inserted in a different order (e.g. different CSV row ordering, or ActivityLimits::new_from_limits iterating a HashMap for seasonal limits) will be treated as not dispatch-equivalent. That undermines the goal of grouping “equivalent” assets across processes/years.

Consider making the comparisons order-insensitive (while keeping the existing Arc::ptr_eq fast path).

    pub fn is_dispatch_equivalent(&self, other: &Self) -> bool {
        self.region_id == other.region_id
            && (Arc::ptr_eq(&self.activity_limits, &other.activity_limits)
                || self.activity_limits == other.activity_limits)
            && (Arc::ptr_eq(&self.flows, &other.flows) || self.flows == other.flows)

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/asset.rs:683

  • Doc comment grammar: "Equal hashes does not confirm equivalence" should be "Equal hashes do not confirm equivalence".
    /// [`Self::is_dispatch_equivalent`]. Equal hashes does not confirm equivalence, but unequal

@tsmbland

Copy link
Copy Markdown
Collaborator Author

Hi @ahawkes,

I've gone for a more rigorous approach for grouping assets in the dispatch by checking all the relevant properties. With this approach, assets in the same region are considered equivalent if their variable operating cost, flows and availabilities are identical. The first two mean that there should be no impact on system cost from switching between these assets, and the latter means that equalising utilisation should always be permitted under the availability constraints. This is deliberately conservative - any differences in any of these parameters, however small, means that the assets are considered not equivalent.

I've written the code in such a way that it shouldn't have a great impact on performance

@tsmbland
tsmbland marked this pull request as ready for review August 18, 2026 08:14
@tsmbland tsmbland added this to MUSE Aug 18, 2026
@tsmbland tsmbland self-assigned this Aug 18, 2026
@tsmbland tsmbland moved this to 👀 In review in MUSE Aug 18, 2026
@ahawkes

ahawkes commented Aug 18, 2026 via email

Copy link
Copy Markdown
Contributor

@tsmbland

tsmbland commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Sounds good, thanks. I guess you thought about allowing epsilon small differences in the parameters? Probably usually fine, but not when users for some reason choose really small values for things.

Yes I considered this, but any kind of tolerance would complicate things. The current approach uses hashing which is very efficient, but even the smallest differences in parameters will produce a different hash. Any kind of tolerance will mean that we have to do full pairwise comparison between all asset pairs which could be slow (probably minor for most models, but if you have 1000000 assets then that's a lot of comparisons).

So, yes, if a user created otherwise identical assets with variable operating cost 1 and 1.000001, then these would not be grouped together, even though switching between them would have a negligible impact on the objective value. I think that would be a strange thing to do though.

@ahawkes

ahawkes commented Aug 18, 2026 via email

Copy link
Copy Markdown
Contributor

@dalonsoa dalonsoa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've a few comments, but otherwise the approach looks solid, so approving.

Comment thread src/asset.rs
Comment on lines +689 to +692
/// Hashes are calculated lazily, rather than caching at initialisation, because only assets
/// used in dispatch and eligible for equal-utilisation grouping need it. The trade-off is that
/// some assets may end up being hashed multiple times if they take part in multiple dispatch
/// runs, although the performance cost of this is likely not massive.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there any disadvantage on pre-calculating these as, I think, there are not changing over the course of the simulation?

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 thought about computing the hash on asset creation (ab2fde7), but decided against this as there are many assets created that never make it into dispatch (e.g. candidate assets), so this would be wasted computation for them.

I'm sure there's a smart middle ground.

Comment thread src/asset.rs
/// used in dispatch and eligible for equal-utilisation grouping need it. The trade-off is that
/// some assets may end up being hashed multiple times if they take part in multiple dispatch
/// runs, although the performance cost of this is likely not massive.
pub(crate) fn dispatch_equivalence_hash(&self) -> u64 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why are you being here so specific about the scope of visibility?

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.

Not that important. In principle because it's an implementation detail so doesn't really belong in the public API, but we haven't exactly been intentional about this elsewhere

Comment thread src/asset.rs
.activity_limits
.iter_limits()
.map(|(selection, limits)| {
let mut hasher = DefaultHasher::new();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why a new hasher here instead of the parent one?

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.

This is to keep it insensitive to order in the activity limits map. We hash each entry with a new hasher, sort the vec of hashes, then hash the sorted hashes with the original hasher

Comment thread src/asset.rs
.flows
.values()
.map(|flow| {
let mut hasher = DefaultHasher::new();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same here.

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.

As above

Base automatically changed from asset_equalisation to main August 19, 2026 07:06
@tsmbland
tsmbland enabled auto-merge August 19, 2026 07:07
@tsmbland
tsmbland merged commit 8691aa8 into main Aug 19, 2026
8 checks passed
@tsmbland
tsmbland deleted the asset_grouping branch August 19, 2026 07:09
@github-project-automation github-project-automation Bot moved this from 👀 In review to ✅ Done in MUSE Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

4 participants