Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/model/dispatch_optimisation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
173 changes: 171 additions & 2 deletions src/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -272,7 +279,6 @@ impl Asset {
max_decommission_year > commission_year,
"Max decommission year must be greater than commission year"
);

Ok(Self {
state,
process,
Expand Down Expand Up @@ -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.
Comment on lines +689 to +692

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.

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

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();

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

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();

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

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 {
Expand Down Expand Up @@ -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,
Expand Down
101 changes: 87 additions & 14 deletions src/simulation/optimisation/constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Expand Down Expand Up @@ -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<Vec<&'a AssetRef>>
where
I: Iterator<Item = &'a AssetRef>,
{
// 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<&AssetRef>> = Vec::new();
let mut group_indices: HashMap<u64, Vec<usize>> = 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
Expand All @@ -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;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Loading