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 821613de3..d8ddf6eaa 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; @@ -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; @@ -108,6 +109,12 @@ pub struct Asset { max_decommission_year: u32, } +/// 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() } +} + impl Asset { /// Create a new candidate asset pub fn new_candidate( @@ -272,7 +279,6 @@ impl Asset { max_decommission_year > commission_year, "Max decommission year must be greater than commission year" ); - Ok(Self { state, process, @@ -650,6 +656,83 @@ impl Asset { &self.process.id } + /// Whether two assets have identical properties for the purposes of dispatch optimisation. + /// + /// 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. + 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 to determine dispatch equivalence. + /// + /// It is used as a prefilter to avoid unnecessary, and potentially expensive, comparisons with + /// [`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 + /// 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. 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(); + + // 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 pub fn id(&self) -> Option { match &self.state { @@ -1278,6 +1361,92 @@ 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)); + assert_eq!( + asset.dispatch_equivalence_hash(), + other.dispatch_equivalence_hash() + ); + } + + #[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_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, 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! { commodity.id.clone() => flow }); + + 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(); + 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] fn process_with_activity_limits( mut process: Process, diff --git a/src/simulation/optimisation/constraints.rs b/src/simulation/optimisation/constraints.rs index 24f889a50..1e6317450 100644 --- a/src/simulation/optimisation/constraints.rs +++ b/src/simulation/optimisation/constraints.rs @@ -3,13 +3,12 @@ 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}; 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 { @@ -474,8 +473,49 @@ where ActivityKeys { offset, keys } } -/// Add constraints requiring assets of the same process in the same region to have equal -/// utilisation in each time slice. +/// Groups assets that have equivalent dispatch properties. +/// +/// 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, +{ + // 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(); + + // 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); + } 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. /// /// 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 +534,13 @@ 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(); - 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); - } + 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 // 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; }; @@ -536,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; @@ -581,4 +616,42 @@ 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_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( + "agent1".into(), + Arc::new(process), + "GBR".into(), + Capacity(2.0), + 2015, + ) + .unwrap(); + 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(), 1); + assert_eq!(groups[1].len(), 1); + } }