-
Notifications
You must be signed in to change notification settings - Fork 4
Smarter asset grouping #1492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: asset_equalisation
Are you sure you want to change the base?
Smarter asset grouping #1492
Changes from all commits
815b3ec
bf9f134
ab2fde7
f8a2157
33c9419
3e0f666
1f0cfeb
6157e50
cfb9717
4859044
83f0d5b
4a237d1
eec75d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<U: UnitType>(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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are you being here so specific about the scope of visibility?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why a new hasher here instead of the parent one?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| selection.hash(&mut hasher); | ||
| hash_unit(*limits.start()).hash(&mut hasher); | ||
| hash_unit(*limits.end()).hash(&mut hasher); | ||
| hasher.finish() | ||
| }) | ||
| .collect::<Vec<_>>(); | ||
| 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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As above |
||
| 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::<Vec<_>>(); | ||
| flow_hashes.sort_unstable(); | ||
| flow_hashes.hash(&mut hasher); | ||
|
|
||
| hasher.finish() | ||
| } | ||
|
|
||
| /// Get the ID for this asset | ||
| pub fn id(&self) -> Option<AssetID> { | ||
| 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, | ||
|
|
||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.