From 815b3eceac59011f8f733b331851d4c16e9a799b Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 13 Aug 2026 16:44:14 +0100 Subject: [PATCH 01/13] Smarter asset grouping --- src/asset.rs | 46 ++++++++++++++++++++++ src/simulation/optimisation/constraints.rs | 23 ++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 821613de3..13749d70b 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -650,6 +650,15 @@ impl Asset { &self.process.id } + /// Whether two assets have equivalent properties for the purposes of dispatch optimisation. + pub fn is_dispatch_equivalent(&self, other: &Self) -> bool { + self.region_id == other.region_id + && self.activity_limits == other.activity_limits + && self.flows == other.flows + && self.process_parameter.variable_operating_cost + == other.process_parameter.variable_operating_cost + } + /// Get the ID for this asset pub fn id(&self) -> Option { match &self.state { @@ -1278,6 +1287,43 @@ mod tests { assert_approx_eq!(MoneyPerActivity, cost, MoneyPerActivity(6.0)); } + #[rstest] + fn dispatch_equivalence_ignores_capacity(asset: Asset) { + let mut other = asset.clone(); + other.set_capacity(AssetCapacity::Continuous(Capacity(3.0))); + + assert!(asset.is_dispatch_equivalent(&other)); + } + + #[rstest] + fn dispatch_equivalence_fails_for_different_region(asset: Asset) { + let mut other = asset.clone(); + other.region_id = "FRA".into(); + + assert!(!asset.is_dispatch_equivalent(&other)); + } + + #[rstest] + fn dispatch_equivalence_fails_for_different_variable_operating_cost(asset: Asset) { + let mut other = asset.clone(); + Arc::make_mut(&mut other.process_parameter).variable_operating_cost = MoneyPerActivity(1.0); + + assert!(!asset.is_dispatch_equivalent(&other)); + } + + #[rstest] + fn dispatch_equivalence_ignores_state(asset: Asset) { + let mut other = asset.clone(); + other.commission(AssetID(1)); + + assert!(asset.is_dispatch_equivalent(&other)); + } + + #[rstest] + fn dispatch_equivalence_is_reflexive(asset: Asset) { + assert!(asset.is_dispatch_equivalent(&asset)); + } + #[fixture] fn process_with_activity_limits( mut process: Process, diff --git a/src/simulation/optimisation/constraints.rs b/src/simulation/optimisation/constraints.rs index 24f889a50..4acd7662a 100644 --- a/src/simulation/optimisation/constraints.rs +++ b/src/simulation/optimisation/constraints.rs @@ -3,7 +3,6 @@ use super::VariableMap; use crate::asset::{AssetCapacity, AssetIterator, AssetRef}; use crate::commodity::{CommodityID, CommodityType}; use crate::model::Model; -use crate::process::ProcessID; use crate::region::RegionID; use crate::time_slice::{Season, TimeSliceInfo, TimeSliceSelection}; use crate::units::{Flow, MoneyPerCapacityPerYear, UnitType, Year}; @@ -474,8 +473,8 @@ where ActivityKeys { offset, keys } } -/// Add constraints requiring assets of the same process in the same region to have equal -/// utilisation in each time slice. +/// Add constraints requiring dispatch-equivalent assets to have equal utilisation in each time +/// slice. /// /// Flexible-capacity assets are excluded because their maximum activity depends on a decision /// variable. The constraints added here are not included in [`ConstraintKeys`], as their duals @@ -494,19 +493,23 @@ fn add_equal_utilisation_constraints<'a, I>( .map(|(asset, _)| asset) .collect(); - // Group together assets with the same process and region - let mut assets_by_process: IndexMap<(RegionID, ProcessID), Vec<&AssetRef>> = IndexMap::new(); + // Group assets by comparing each one with the first asset in each group. + let mut asset_groups: Vec> = Vec::new(); for asset in assets.filter(|asset| !flexible_assets.contains(asset)) { - assets_by_process - .entry((asset.region_id().clone(), asset.process_id().clone())) - .or_default() - .push(asset); + if let Some(group) = asset_groups + .iter_mut() + .find(|group| group[0].is_dispatch_equivalent(asset)) + { + group.push(asset); + } else { + asset_groups.push(vec![asset]); + } } // For each group of assets, add constraints to force equal utilisation in each time slice // This is done by anchoring each asset to the first asset in the group (-> (n-1) constraints // for a group of n assets) - for assets in assets_by_process.into_values() { + for assets in asset_groups { let Some((reference_asset, others)) = assets.split_first() else { continue; }; From bf9f134ab741608c949af380e64fddfb1c86cad3 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 13:22:50 +0100 Subject: [PATCH 02/13] Hash fingerprint --- src/asset.rs | 43 ++++++++++++++++++++ src/simulation/optimisation/constraints.rs | 46 ++++++++++++++++------ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 13749d70b..0cee27d21 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -19,6 +19,7 @@ use map_macro::vec_deque; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::collections::VecDeque; +use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::ops::RangeInclusive; use std::sync::Arc; @@ -28,6 +29,10 @@ pub use capacity::AssetCapacity; mod pool; pub use pool::AssetPool; +fn hash_f64(value: f64) -> u64 { + if value == 0.0 { 0 } else { value.to_bits() } +} + /// A unique identifier for an asset #[derive( Clone, @@ -659,6 +664,44 @@ impl Asset { == other.process_parameter.variable_operating_cost } + /// Calculate a hash of the properties used by [`Self::is_dispatch_equivalent`]. + pub(crate) fn dispatch_equivalence_hash(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.region_id.hash(&mut hasher); + + let mut activity_limit_hashes = self + .activity_limits + .iter_limits() + .map(|(selection, limits)| { + let mut hasher = DefaultHasher::new(); + selection.hash(&mut hasher); + hash_f64(limits.start().value()).hash(&mut hasher); + hash_f64(limits.end().value()).hash(&mut hasher); + hasher.finish() + }) + .collect::>(); + activity_limit_hashes.sort_unstable(); + activity_limit_hashes.hash(&mut hasher); + + let mut flow_hashes = self + .flows + .values() + .map(|flow| { + let mut hasher = DefaultHasher::new(); + flow.commodity.id.hash(&mut hasher); + hash_f64(flow.coeff.value()).hash(&mut hasher); + std::mem::discriminant(&flow.kind).hash(&mut hasher); + hash_f64(flow.cost.value()).hash(&mut hasher); + hasher.finish() + }) + .collect::>(); + flow_hashes.sort_unstable(); + flow_hashes.hash(&mut hasher); + + hash_f64(self.process_parameter.variable_operating_cost.value()).hash(&mut hasher); + hasher.finish() + } + /// Get the ID for this asset pub fn id(&self) -> Option { match &self.state { diff --git a/src/simulation/optimisation/constraints.rs b/src/simulation/optimisation/constraints.rs index 4acd7662a..a8ae318b5 100644 --- a/src/simulation/optimisation/constraints.rs +++ b/src/simulation/optimisation/constraints.rs @@ -8,7 +8,7 @@ use crate::time_slice::{Season, TimeSliceInfo, TimeSliceSelection}; use crate::units::{Flow, MoneyPerCapacityPerYear, UnitType, Year}; use highs::RowProblem as Problem; use indexmap::IndexMap; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; /// Corresponding variables for a constraint along with the row offset in the solution pub struct KeysWithOffset { @@ -473,6 +473,36 @@ where ActivityKeys { offset, keys } } +fn group_dispatch_equivalent_assets<'a, I>(assets: I) -> Vec> +where + I: Iterator, +{ + // Group assets by comparing each one with the first asset in each group. The hash index + // avoids comparing an asset with groups that cannot contain an equivalent asset, while the + // exact comparison preserves correctness in the event of hash collisions. + let mut asset_groups: Vec> = Vec::new(); + let mut group_indices: HashMap> = HashMap::new(); + for asset in assets { + let hash = asset.dispatch_equivalence_hash(); + let matching_group = group_indices.get(&hash).and_then(|group_indices| { + group_indices + .iter() + .copied() + .find(|&group_index| asset_groups[group_index][0].is_dispatch_equivalent(asset)) + }); + + if let Some(group_index) = matching_group { + asset_groups[group_index].push(asset); + } else { + let group_index = asset_groups.len(); + asset_groups.push(vec![asset]); + group_indices.entry(hash).or_default().push(group_index); + } + } + + asset_groups +} + /// Add constraints requiring dispatch-equivalent assets to have equal utilisation in each time /// slice. /// @@ -493,18 +523,8 @@ fn add_equal_utilisation_constraints<'a, I>( .map(|(asset, _)| asset) .collect(); - // Group assets by comparing each one with the first asset in each group. - let mut asset_groups: Vec> = Vec::new(); - for asset in assets.filter(|asset| !flexible_assets.contains(asset)) { - if let Some(group) = asset_groups - .iter_mut() - .find(|group| group[0].is_dispatch_equivalent(asset)) - { - group.push(asset); - } else { - asset_groups.push(vec![asset]); - } - } + let asset_groups = + group_dispatch_equivalent_assets(assets.filter(|asset| !flexible_assets.contains(asset))); // For each group of assets, add constraints to force equal utilisation in each time slice // This is done by anchoring each asset to the first asset in the group (-> (n-1) constraints From ab2fde7ad0faad849791e41beb76179468b86b37 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 13:34:37 +0100 Subject: [PATCH 03/13] Cache hash --- src/asset.rs | 103 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 66 insertions(+), 37 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 0cee27d21..0f8cb981f 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -10,7 +10,7 @@ use crate::simulation::PriceMap; use crate::time_slice::{TimeSliceID, TimeSliceSelection}; use crate::units::{ Activity, ActivityPerCapacity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity, - MoneyPerCapacity, MoneyPerFlow, Year, + MoneyPerCapacity, MoneyPerFlow, UnitType, Year, }; use anyhow::{Context, Result, ensure}; use indexmap::IndexMap; @@ -29,7 +29,8 @@ pub use capacity::AssetCapacity; mod pool; pub use pool::AssetPool; -fn hash_f64(value: f64) -> u64 { +fn hash_unit(value: U) -> u64 { + let value = value.value(); if value == 0.0 { 0 } else { value.to_bits() } } @@ -111,6 +112,48 @@ pub struct Asset { commission_year: u32, /// The maximum year that the asset could be decommissioned max_decommission_year: u32, + /// Hash of the properties used to determine dispatch equivalence + dispatch_equivalence_hash: u64, +} + +fn compute_dispatch_equivalence_hash( + region_id: &RegionID, + activity_limits: &ActivityLimits, + flows: &IndexMap, + process_parameter: &ProcessParameter, +) -> u64 { + let mut hasher = DefaultHasher::new(); + region_id.hash(&mut hasher); + + let mut activity_limit_hashes = activity_limits + .iter_limits() + .map(|(selection, limits)| { + let mut hasher = DefaultHasher::new(); + selection.hash(&mut hasher); + hash_unit(*limits.start()).hash(&mut hasher); + hash_unit(*limits.end()).hash(&mut hasher); + hasher.finish() + }) + .collect::>(); + activity_limit_hashes.sort_unstable(); + activity_limit_hashes.hash(&mut hasher); + + let mut flow_hashes = flows + .values() + .map(|flow| { + let mut hasher = DefaultHasher::new(); + flow.commodity.id.hash(&mut hasher); + hash_unit(flow.coeff).hash(&mut hasher); + std::mem::discriminant(&flow.kind).hash(&mut hasher); + hash_unit(flow.cost).hash(&mut hasher); + hasher.finish() + }) + .collect::>(); + flow_hashes.sort_unstable(); + flow_hashes.hash(&mut hasher); + + hash_unit(process_parameter.variable_operating_cost).hash(&mut hasher); + hasher.finish() } impl Asset { @@ -277,6 +320,12 @@ impl Asset { max_decommission_year > commission_year, "Max decommission year must be greater than commission year" ); + let dispatch_equivalence_hash = compute_dispatch_equivalence_hash( + ®ion_id, + &activity_limits, + &flows, + &process_parameter, + ); Ok(Self { state, @@ -288,6 +337,7 @@ impl Asset { capacity, commission_year, max_decommission_year, + dispatch_equivalence_hash, }) } @@ -664,42 +714,9 @@ impl Asset { == other.process_parameter.variable_operating_cost } - /// Calculate a hash of the properties used by [`Self::is_dispatch_equivalent`]. + /// Get the hash of the properties used by [`Self::is_dispatch_equivalent`]. pub(crate) fn dispatch_equivalence_hash(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.region_id.hash(&mut hasher); - - let mut activity_limit_hashes = self - .activity_limits - .iter_limits() - .map(|(selection, limits)| { - let mut hasher = DefaultHasher::new(); - selection.hash(&mut hasher); - hash_f64(limits.start().value()).hash(&mut hasher); - hash_f64(limits.end().value()).hash(&mut hasher); - hasher.finish() - }) - .collect::>(); - activity_limit_hashes.sort_unstable(); - activity_limit_hashes.hash(&mut hasher); - - let mut flow_hashes = self - .flows - .values() - .map(|flow| { - let mut hasher = DefaultHasher::new(); - flow.commodity.id.hash(&mut hasher); - hash_f64(flow.coeff.value()).hash(&mut hasher); - std::mem::discriminant(&flow.kind).hash(&mut hasher); - hash_f64(flow.cost.value()).hash(&mut hasher); - hasher.finish() - }) - .collect::>(); - flow_hashes.sort_unstable(); - flow_hashes.hash(&mut hasher); - - hash_f64(self.process_parameter.variable_operating_cost.value()).hash(&mut hasher); - hasher.finish() + self.dispatch_equivalence_hash } /// Get the ID for this asset @@ -1336,6 +1353,10 @@ mod tests { other.set_capacity(AssetCapacity::Continuous(Capacity(3.0))); assert!(asset.is_dispatch_equivalent(&other)); + assert_eq!( + asset.dispatch_equivalence_hash(), + other.dispatch_equivalence_hash() + ); } #[rstest] @@ -1360,11 +1381,19 @@ mod tests { other.commission(AssetID(1)); assert!(asset.is_dispatch_equivalent(&other)); + assert_eq!( + asset.dispatch_equivalence_hash(), + other.dispatch_equivalence_hash() + ); } #[rstest] fn dispatch_equivalence_is_reflexive(asset: Asset) { assert!(asset.is_dispatch_equivalent(&asset)); + assert_eq!( + asset.dispatch_equivalence_hash(), + asset.dispatch_equivalence_hash() + ); } #[fixture] From f8a215747d84bd51fb626b999e54b162de320489 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 13:40:48 +0100 Subject: [PATCH 04/13] Add comments --- src/asset.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/asset.rs b/src/asset.rs index 0f8cb981f..60d1ccc98 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -123,8 +123,11 @@ fn compute_dispatch_equivalence_hash( process_parameter: &ProcessParameter, ) -> u64 { let mut hasher = DefaultHasher::new(); + + // Hash the region ID region_id.hash(&mut hasher); + // Hash activity limits based on ts selection, lower and upper limits let mut activity_limit_hashes = activity_limits .iter_limits() .map(|(selection, limits)| { @@ -138,13 +141,13 @@ fn compute_dispatch_equivalence_hash( activity_limit_hashes.sort_unstable(); activity_limit_hashes.hash(&mut hasher); + // Hash flows based on commodity ID, coefficient and cost let mut flow_hashes = flows .values() .map(|flow| { let mut hasher = DefaultHasher::new(); flow.commodity.id.hash(&mut hasher); hash_unit(flow.coeff).hash(&mut hasher); - std::mem::discriminant(&flow.kind).hash(&mut hasher); hash_unit(flow.cost).hash(&mut hasher); hasher.finish() }) @@ -152,7 +155,9 @@ fn compute_dispatch_equivalence_hash( flow_hashes.sort_unstable(); flow_hashes.hash(&mut hasher); + // Hash the variable operating cost hash_unit(process_parameter.variable_operating_cost).hash(&mut hasher); + hasher.finish() } From 33c9419829d58891497bf57cc8710137c452969a Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 13:46:43 +0100 Subject: [PATCH 05/13] Arc::ptr_eq to avoid deep comparisons --- src/asset.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 60d1ccc98..56c3077cb 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -713,8 +713,9 @@ impl Asset { /// Whether two assets have equivalent properties for the purposes of dispatch optimisation. pub fn is_dispatch_equivalent(&self, other: &Self) -> bool { self.region_id == other.region_id - && self.activity_limits == other.activity_limits - && self.flows == other.flows + && (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) && self.process_parameter.variable_operating_cost == other.process_parameter.variable_operating_cost } From 3e0f666422c50d649846330a24066cf344ef5aa4 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 14:14:37 +0100 Subject: [PATCH 06/13] Add more comments --- src/asset.rs | 12 +++++++++--- src/simulation/optimisation/constraints.rs | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 56c3077cb..a33ae0851 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -141,13 +141,14 @@ fn compute_dispatch_equivalence_hash( activity_limit_hashes.sort_unstable(); activity_limit_hashes.hash(&mut hasher); - // Hash flows based on commodity ID, coefficient and cost + // Hash flows based on commodity ID, coefficient, FlowType and cost let mut flow_hashes = flows .values() .map(|flow| { let mut hasher = DefaultHasher::new(); flow.commodity.id.hash(&mut hasher); hash_unit(flow.coeff).hash(&mut hasher); + std::mem::discriminant(&flow.kind).hash(&mut hasher); hash_unit(flow.cost).hash(&mut hasher); hasher.finish() }) @@ -711,13 +712,18 @@ impl Asset { } /// Whether two assets have equivalent properties for the purposes of dispatch optimisation. + /// + /// Capacity and asset state are deliberately ignored. The activity limits and flows are + /// compared by value, so separately allocated but identical `Arc`s are still equivalent. + /// This method is the authoritative equality check; the cached dispatch hash is only used to + /// narrow grouping candidates and may contain collisions. pub fn is_dispatch_equivalent(&self, other: &Self) -> bool { self.region_id == other.region_id + && self.process_parameter.variable_operating_cost + == other.process_parameter.variable_operating_cost && (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) - && self.process_parameter.variable_operating_cost - == other.process_parameter.variable_operating_cost } /// Get the hash of the properties used by [`Self::is_dispatch_equivalent`]. diff --git a/src/simulation/optimisation/constraints.rs b/src/simulation/optimisation/constraints.rs index a8ae318b5..ad005c766 100644 --- a/src/simulation/optimisation/constraints.rs +++ b/src/simulation/optimisation/constraints.rs @@ -473,6 +473,12 @@ where ActivityKeys { offset, keys } } +/// Group assets with equivalent dispatch properties. +/// +/// The dispatch hash is used only as a prefilter, so hash collisions cannot merge non-equivalent +/// assets: each candidate group is checked with `is_dispatch_equivalent`. The input is expected to +/// contain only assets eligible for equal-utilisation constraints; filtering flexible-capacity +/// assets is the caller's responsibility. fn group_dispatch_equivalent_assets<'a, I>(assets: I) -> Vec> where I: Iterator, From 1f0cfebbca54ecf92d6217efece7aff8a5dff5d0 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 15:25:24 +0100 Subject: [PATCH 07/13] Revert to lazy hashing --- src/asset.rs | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index a33ae0851..0efc61231 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -29,11 +29,6 @@ pub use capacity::AssetCapacity; mod pool; pub use pool::AssetPool; -fn hash_unit(value: U) -> u64 { - let value = value.value(); - if value == 0.0 { 0 } else { value.to_bits() } -} - /// A unique identifier for an asset #[derive( Clone, @@ -112,8 +107,12 @@ pub struct Asset { commission_year: u32, /// The maximum year that the asset could be decommissioned max_decommission_year: u32, - /// Hash of the properties used to determine dispatch equivalence - dispatch_equivalence_hash: u64, +} + +/// Hash a unit value while treating positive and negative zero as equal. +fn hash_unit(value: U) -> u64 { + let value = value.value(); + if value == 0.0 { 0 } else { value.to_bits() } } fn compute_dispatch_equivalence_hash( @@ -326,13 +325,6 @@ impl Asset { max_decommission_year > commission_year, "Max decommission year must be greater than commission year" ); - let dispatch_equivalence_hash = compute_dispatch_equivalence_hash( - ®ion_id, - &activity_limits, - &flows, - &process_parameter, - ); - Ok(Self { state, process, @@ -343,7 +335,6 @@ impl Asset { capacity, commission_year, max_decommission_year, - dispatch_equivalence_hash, }) } @@ -726,9 +717,19 @@ impl Asset { && (Arc::ptr_eq(&self.flows, &other.flows) || self.flows == other.flows) } - /// Get the hash of the properties used by [`Self::is_dispatch_equivalent`]. + /// Calculate a hash of the properties used by [`Self::is_dispatch_equivalent`]. + /// + /// This hash is calculated lazily because only assets used in dispatch and eligible for + /// equal-utilisation grouping need it. It is used as a prefilter; + /// [`Self::is_dispatch_equivalent`] remains the authoritative comparison when hash buckets are + /// checked. pub(crate) fn dispatch_equivalence_hash(&self) -> u64 { - self.dispatch_equivalence_hash + compute_dispatch_equivalence_hash( + &self.region_id, + &self.activity_limits, + &self.flows, + &self.process_parameter, + ) } /// Get the ID for this asset From 6157e507f1fdd56ec71c1dc42d278766872379c6 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 15:56:26 +0100 Subject: [PATCH 08/13] Move hashing function --- src/asset.rs | 110 +++++++++++++++++++++++++-------------------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 0efc61231..3f781f395 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -115,52 +115,6 @@ fn hash_unit(value: U) -> u64 { if value == 0.0 { 0 } else { value.to_bits() } } -fn compute_dispatch_equivalence_hash( - region_id: &RegionID, - activity_limits: &ActivityLimits, - flows: &IndexMap, - process_parameter: &ProcessParameter, -) -> u64 { - let mut hasher = DefaultHasher::new(); - - // Hash the region ID - region_id.hash(&mut hasher); - - // Hash activity limits based on ts selection, lower and upper limits - let mut activity_limit_hashes = activity_limits - .iter_limits() - .map(|(selection, limits)| { - let mut hasher = DefaultHasher::new(); - selection.hash(&mut hasher); - hash_unit(*limits.start()).hash(&mut hasher); - hash_unit(*limits.end()).hash(&mut hasher); - hasher.finish() - }) - .collect::>(); - activity_limit_hashes.sort_unstable(); - activity_limit_hashes.hash(&mut hasher); - - // Hash flows based on commodity ID, coefficient, FlowType and cost - let mut flow_hashes = flows - .values() - .map(|flow| { - let mut hasher = DefaultHasher::new(); - flow.commodity.id.hash(&mut hasher); - hash_unit(flow.coeff).hash(&mut hasher); - std::mem::discriminant(&flow.kind).hash(&mut hasher); - hash_unit(flow.cost).hash(&mut hasher); - hasher.finish() - }) - .collect::>(); - flow_hashes.sort_unstable(); - flow_hashes.hash(&mut hasher); - - // Hash the variable operating cost - hash_unit(process_parameter.variable_operating_cost).hash(&mut hasher); - - hasher.finish() -} - impl Asset { /// Create a new candidate asset pub fn new_candidate( @@ -708,28 +662,70 @@ impl Asset { /// compared by value, so separately allocated but identical `Arc`s are still equivalent. /// This method is the authoritative equality check; the cached dispatch hash is only used to /// narrow grouping candidates and may contain collisions. + /// + /// This is deliberately conservative: any difference in variable operating cost, flows, or + /// activity limits, however small, means the assets are considered not equivalent. pub fn is_dispatch_equivalent(&self, other: &Self) -> bool { self.region_id == other.region_id && self.process_parameter.variable_operating_cost == other.process_parameter.variable_operating_cost + // `IndexMap` equality is insensitive to entry order. + // If the assets share the same allocation, `Arc::ptr_eq` avoids having to compare the + // full contents of the `IndexMap`. && (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) } - /// Calculate a hash of the properties used by [`Self::is_dispatch_equivalent`]. + /// Calculate a hash of the properties used to determine dispatch equivalence. + /// + /// It is used as a prefilter to avoid unnecessary, and potentially expensive, comparisons with + /// [`Self::is_dispatch_equivalent`]. Equal hashes does not confirm equivalence, but unequal + /// hashes can rule out equivalence. /// - /// This hash is calculated lazily because only assets used in dispatch and eligible for - /// equal-utilisation grouping need it. It is used as a prefilter; - /// [`Self::is_dispatch_equivalent`] remains the authoritative comparison when hash buckets are - /// checked. + /// Hashes are calculated lazily, rather than caching at initialisation, because only assets + /// used in dispatch and eligible for equal-utilisation grouping need it. pub(crate) fn dispatch_equivalence_hash(&self) -> u64 { - compute_dispatch_equivalence_hash( - &self.region_id, - &self.activity_limits, - &self.flows, - &self.process_parameter, - ) + let mut hasher = DefaultHasher::new(); + + // Hash the region ID + self.region_id.hash(&mut hasher); + + // Hash the variable operating cost + hash_unit(self.process_parameter.variable_operating_cost).hash(&mut hasher); + + // Hash activity limits based on ts selection, lower and upper limits + let mut activity_limit_hashes = self + .activity_limits + .iter_limits() + .map(|(selection, limits)| { + let mut hasher = DefaultHasher::new(); + selection.hash(&mut hasher); + hash_unit(*limits.start()).hash(&mut hasher); + hash_unit(*limits.end()).hash(&mut hasher); + hasher.finish() + }) + .collect::>(); + activity_limit_hashes.sort_unstable(); + activity_limit_hashes.hash(&mut hasher); + + // Hash flows based on commodity ID, coefficient, FlowType and cost + let mut flow_hashes = self + .flows + .values() + .map(|flow| { + let mut hasher = DefaultHasher::new(); + flow.commodity.id.hash(&mut hasher); + hash_unit(flow.coeff).hash(&mut hasher); + std::mem::discriminant(&flow.kind).hash(&mut hasher); + hash_unit(flow.cost).hash(&mut hasher); + hasher.finish() + }) + .collect::>(); + flow_hashes.sort_unstable(); + flow_hashes.hash(&mut hasher); + + hasher.finish() } /// Get the ID for this asset From cfb9717158babfa488969d858a3239ce488a6dc1 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 20:34:15 +0100 Subject: [PATCH 09/13] Make code more readable --- src/asset.rs | 17 ++++++++----- src/simulation/optimisation/constraints.rs | 29 +++++++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 3f781f395..20cddd9d6 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -656,12 +656,12 @@ impl Asset { &self.process.id } - /// Whether two assets have equivalent properties for the purposes of dispatch optimisation. + /// Whether two assets have identical properties for the purposes of dispatch optimisation. /// - /// Capacity and asset state are deliberately ignored. The activity limits and flows are - /// compared by value, so separately allocated but identical `Arc`s are still equivalent. - /// This method is the authoritative equality check; the cached dispatch hash is only used to - /// narrow grouping candidates and may contain collisions. + /// Capacity, process identity and asset state are deliberately ignored. The activity limits + /// and flows are compared by value, so separately allocated but identical `Arc`s are still + /// equivalent. This method is the authoritative equality check; the dispatch hash is only used + /// to narrow grouping candidates and may contain collisions. /// /// This is deliberately conservative: any difference in variable operating cost, flows, or /// activity limits, however small, means the assets are considered not equivalent. @@ -683,8 +683,13 @@ impl Asset { /// [`Self::is_dispatch_equivalent`]. Equal hashes does not confirm equivalence, but unequal /// hashes can rule out equivalence. /// + /// This is deliberately conservative: any difference in variable operating cost, flows, or + /// activity limits, however small, may result in a different hash. + /// /// Hashes are calculated lazily, rather than caching at initialisation, because only assets - /// used in dispatch and eligible for equal-utilisation grouping need it. + /// 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 { let mut hasher = DefaultHasher::new(); diff --git a/src/simulation/optimisation/constraints.rs b/src/simulation/optimisation/constraints.rs index ad005c766..0728aa999 100644 --- a/src/simulation/optimisation/constraints.rs +++ b/src/simulation/optimisation/constraints.rs @@ -473,12 +473,14 @@ where ActivityKeys { offset, keys } } -/// Group assets with equivalent dispatch properties. +/// Groups assets that have equivalent dispatch properties. /// -/// The dispatch hash is used only as a prefilter, so hash collisions cannot merge non-equivalent -/// assets: each candidate group is checked with `is_dispatch_equivalent`. The input is expected to -/// contain only assets eligible for equal-utilisation constraints; filtering flexible-capacity -/// assets is the caller's responsibility. +/// Assets are first bucketed by `dispatch_equivalence_hash()` to avoid unnecessary pairwise +/// comparisons. Within each hash bucket, assets are compared using `is_dispatch_equivalent()`, +/// which is the authoritative check for equivalence. This also handles hash collisions correctly. +/// +/// The caller must ensure that `assets` contains only assets eligible for equal-utilisation +/// constraints (i.e. flexible-capacity assets have already been filtered out). fn group_dispatch_equivalent_assets<'a, I>(assets: I) -> Vec> where I: Iterator, @@ -490,15 +492,18 @@ where let mut group_indices: HashMap> = HashMap::new(); for asset in assets { let hash = asset.dispatch_equivalence_hash(); - let matching_group = group_indices.get(&hash).and_then(|group_indices| { - group_indices - .iter() - .copied() - .find(|&group_index| asset_groups[group_index][0].is_dispatch_equivalent(asset)) - }); + + // Only groups with the same hash can possibly match. + let candidate_groups = group_indices.get(&hash); + + // Find a group whose representative is actually equivalent. + let matching_group = candidate_groups + .into_iter() + .flatten() + .find(|&&group_index| asset_groups[group_index][0].is_dispatch_equivalent(asset)); if let Some(group_index) = matching_group { - asset_groups[group_index].push(asset); + asset_groups[*group_index].push(asset); } else { let group_index = asset_groups.len(); asset_groups.push(vec![asset]); From 4859044385b7b4aa886ff84b24c292075b3f6b8f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 20:57:36 +0100 Subject: [PATCH 10/13] Tests --- src/asset.rs | 29 +++++++++++++ src/simulation/optimisation/constraints.rs | 50 +++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/asset.rs b/src/asset.rs index 20cddd9d6..06053264e 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1389,6 +1389,35 @@ mod tests { assert!(!asset.is_dispatch_equivalent(&other)); } + #[rstest] + fn dispatch_equivalence_fails_for_different_activity_limits( + asset: Asset, + asset_with_activity_limits: Asset, + ) { + assert!(!asset.is_dispatch_equivalent(&asset_with_activity_limits)); + } + + #[rstest] + fn dispatch_equivalence_fails_for_different_flows(asset: Asset) { + let mut other = asset.clone(); + other.flows = Arc::new(IndexMap::new()); + + assert!(!asset.is_dispatch_equivalent(&other)); + } + + #[rstest] + fn dispatch_equivalence_handles_separately_allocated_values(asset: Asset) { + let mut other = asset.clone(); + other.activity_limits = Arc::new((*asset.activity_limits).clone()); + other.flows = Arc::new((*asset.flows).clone()); + + assert!(asset.is_dispatch_equivalent(&other)); + assert_eq!( + asset.dispatch_equivalence_hash(), + other.dispatch_equivalence_hash() + ); + } + #[rstest] fn dispatch_equivalence_ignores_state(asset: Asset) { let mut other = asset.clone(); diff --git a/src/simulation/optimisation/constraints.rs b/src/simulation/optimisation/constraints.rs index 0728aa999..8dca7e60a 100644 --- a/src/simulation/optimisation/constraints.rs +++ b/src/simulation/optimisation/constraints.rs @@ -570,12 +570,13 @@ fn add_equal_utilisation_constraints<'a, I>( #[cfg(test)] mod tests { use super::*; + use crate::asset::Asset; use crate::commodity::Commodity; use crate::fixture::{asset, process, process_flows_map, svd_commodity}; use crate::process::Process; use crate::process::{FlowType, ProcessFlow}; use crate::time_slice::TimeSliceSelection; - use crate::units::{FlowPerActivity, MoneyPerFlow}; + use crate::units::{Capacity, FlowPerActivity, MoneyPerFlow}; use indexmap::indexmap; use rstest::rstest; use std::sync::Arc; @@ -615,4 +616,51 @@ mod tests { ); assert_eq!(result, Flow(expected)); } + + #[test] + fn groups_no_assets() { + assert!(group_dispatch_equivalent_assets(std::iter::empty()).is_empty()); + } + + #[rstest] + fn groups_equivalent_assets(asset: Asset) { + let mut equivalent = asset.clone(); + equivalent.set_capacity(AssetCapacity::Continuous(Capacity(3.0))); + let assets = [AssetRef::from(asset), AssetRef::from(equivalent)]; + + let groups = group_dispatch_equivalent_assets(assets.iter()); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].len(), 2); + } + + #[rstest] + fn groups_equivalent_assets_separately_from_non_equivalent_assets( + asset: Asset, + mut process: Process, + ) { + let mut equivalent = asset.clone(); + equivalent.set_capacity(AssetCapacity::Continuous(Capacity(3.0))); + Arc::make_mut(process.parameters.get_mut(&("GBR".into(), 2015)).unwrap()) + .variable_operating_cost = crate::units::MoneyPerActivity(1.0); + let different = Asset::new_ready( + "agent1".into(), + Arc::new(process), + "GBR".into(), + Capacity(2.0), + 2015, + ) + .unwrap(); + let assets = [ + AssetRef::from(asset), + AssetRef::from(equivalent), + AssetRef::from(different), + ]; + + let groups = group_dispatch_equivalent_assets(assets.iter()); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].len(), 2); + assert_eq!(groups[1].len(), 1); + } } From 83f0d5b4325bf7a9cdcf3ecf74b1dbd4f6fcd3ae Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 20:57:53 +0100 Subject: [PATCH 11/13] Simplify test --- src/simulation/optimisation/constraints.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/simulation/optimisation/constraints.rs b/src/simulation/optimisation/constraints.rs index 8dca7e60a..1e6317450 100644 --- a/src/simulation/optimisation/constraints.rs +++ b/src/simulation/optimisation/constraints.rs @@ -635,12 +635,7 @@ mod tests { } #[rstest] - fn groups_equivalent_assets_separately_from_non_equivalent_assets( - asset: Asset, - mut process: Process, - ) { - let mut equivalent = asset.clone(); - equivalent.set_capacity(AssetCapacity::Continuous(Capacity(3.0))); + fn groups_non_equivalent_assets_are_separate(asset: Asset, mut process: Process) { Arc::make_mut(process.parameters.get_mut(&("GBR".into(), 2015)).unwrap()) .variable_operating_cost = crate::units::MoneyPerActivity(1.0); let different = Asset::new_ready( @@ -651,16 +646,12 @@ mod tests { 2015, ) .unwrap(); - let assets = [ - AssetRef::from(asset), - AssetRef::from(equivalent), - AssetRef::from(different), - ]; + let assets = [AssetRef::from(asset), AssetRef::from(different)]; let groups = group_dispatch_equivalent_assets(assets.iter()); assert_eq!(groups.len(), 2); - assert_eq!(groups[0].len(), 2); + assert_eq!(groups[0].len(), 1); assert_eq!(groups[1].len(), 1); } } From 4a237d1602f5894af52a0f91366f6863a604ac07 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 21:16:05 +0100 Subject: [PATCH 12/13] Update docs --- docs/model/dispatch_optimisation.md | 17 ++++++++++------- src/asset.rs | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/model/dispatch_optimisation.md b/docs/model/dispatch_optimisation.md index ad43aa484..6609d9dad 100644 --- a/docs/model/dispatch_optimisation.md +++ b/docs/model/dispatch_optimisation.md @@ -83,20 +83,23 @@ fraction of the year. lower and upper availability fractions from `process_activity_limits.csv`, defaulting to \\( 0 \\) and \\( 1 \\) respectively for any selection not explicitly defined. -### Equal Process Utilisation +### Equal Utilisation of Equivalent Assets -Assets representing the same process in the same region are effectively equivalent. To avoid -arbitrarily utilising one over another, the dispatch model adds additional constraints to equalise -the utilisation of equivalent assets. For an asset \\(a\\) in time slice \\(t\\), utilisation is -defined as +To avoid arbitrarily utilising one asset over another, the dispatch model adds additional +constraints to equalise the utilisation of assets with equivalent dispatch properties. Assets are +considered equivalent when they are in the same region and have the same variable operating cost, +activity limits, and commodity flows. Their capacity, state, and process identity are not considered +when determining equivalence. + +For an asset \\(a\\) in time slice \\(t\\), utilisation is defined as \\[ \\mathrm{Utilisation}\_{a,t} = \\frac{\\mathrm{Activity}\_{a,t}}{\\mathrm{Capacity}_a \\cdot \\mathrm{cap2act}_a} \\] -For every pair of assets \\( x \\) and \\( y \\) representing the same process in the same -region, and for every time slice \\( t \\), the optimisation model imposes: +For every pair of equivalent assets \\( x \\) and \\( y \\), and for every time slice \\( t \\), +the optimisation model imposes: \\[ \\mathrm{Utilisation}\_{x,t} = \\mathrm{Utilisation}\_{y,t} diff --git a/src/asset.rs b/src/asset.rs index 06053264e..0a52cb49d 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -680,7 +680,7 @@ impl Asset { /// Calculate a hash of the properties used to determine dispatch equivalence. /// /// It is used as a prefilter to avoid unnecessary, and potentially expensive, comparisons with - /// [`Self::is_dispatch_equivalent`]. Equal hashes does not confirm equivalence, but unequal + /// [`Self::is_dispatch_equivalent`]. Equal hashes do not confirm equivalence, but unequal /// hashes can rule out equivalence. /// /// This is deliberately conservative: any difference in variable operating cost, flows, or From eec75d11486adacfb5488a7cbf37d0c1e19fff0d Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 17 Aug 2026 22:35:42 +0100 Subject: [PATCH 13/13] Fix failing test --- src/asset.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 0a52cb49d..d8ddf6eaa 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1398,9 +1398,17 @@ mod tests { } #[rstest] - fn dispatch_equivalence_fails_for_different_flows(asset: Asset) { + fn dispatch_equivalence_fails_for_different_flows(asset: Asset, svd_commodity: Commodity) { + let commodity = Arc::new(svd_commodity); + let flow = ProcessFlow { + commodity: Arc::clone(&commodity), + coeff: FlowPerActivity(1.0), + kind: FlowType::Fixed, + cost: MoneyPerFlow(0.0), + }; + let mut other = asset.clone(); - other.flows = Arc::new(IndexMap::new()); + other.flows = Arc::new(indexmap! { commodity.id.clone() => flow }); assert!(!asset.is_dispatch_equivalent(&other)); }