From dd1d72f5ce8bc79591a75872927bbbd6b4b663d3 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 8 Sep 2026 10:31:29 +0100 Subject: [PATCH 1/4] Use only active capacity in dispatch --- src/asset.rs | 9 +- src/simulation.rs | 21 --- .../investment/appraisal/constraints.rs | 4 +- .../data/simple_mothball/commodity_flows.csv | 128 ++++++++++++++++++ 4 files changed, 137 insertions(+), 25 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 09843223d..1238c4f7b 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -292,7 +292,7 @@ impl Asset { ) -> RangeInclusive { let activity_per_capacity_limits = self.activity_limits.get_limit(time_slice_selection); let cap2act = self.process.capacity_to_activity; - let max_activity = self.total_capacity() * cap2act; + let max_activity = self.active_capacity() * cap2act; let lb = max_activity * *activity_per_capacity_limits.start(); let ub = max_activity * *activity_per_capacity_limits.end(); lb..=ub @@ -557,7 +557,7 @@ impl Asset { /// Maximum activity for this asset pub fn max_activity(&self) -> Activity { - self.total_capacity() * self.process.capacity_to_activity + self.active_capacity() * self.process.capacity_to_activity } /// Get a specific process flow @@ -846,6 +846,11 @@ impl Asset { self.num_tranches() - self.get_num_mothballed_tranches() } + /// Get the active (non-mothballed) capacity for this asset + pub fn active_capacity(&self) -> Capacity { + self.capacity().tranche_size() * Dimensionless(self.get_num_nonmothballed_tranches() as f64) + } + /// The number of tranches this asset represents pub fn num_tranches(&self) -> u32 { self.capacity().num_tranches() diff --git a/src/simulation.rs b/src/simulation.rs index 78a6fef18..914a8a328 100644 --- a/src/simulation.rs +++ b/src/simulation.rs @@ -178,27 +178,6 @@ fn run_dispatch_for_year( debug_assert!(assets.iter().all(|asset| !asset.is_candidate())); debug_assert!(candidates.iter().all(|asset| asset.is_candidate())); - // Only include non-mothballed tranches - let assets_vec: Vec; - let assets = if assets - .iter() - .any(|asset| asset.has_any_mothballed_tranches()) - { - assets_vec = assets - .iter() - .cloned() - .filter_map(|asset| { - // Exclude fully mothballed assets entirely. If assets are partially mothballed, - // get a new asset without the mothballed tranches. - let num_tranches = asset.get_num_nonmothballed_tranches(); - (num_tranches > 0).then(|| asset.with_subset_of_tranches(num_tranches)) - }) - .collect(); - &assets_vec - } else { - assets - }; - // Run dispatch optimisation with existing assets only, if there are any. If not, then assume no // flows (i.e. all are zero) let (solution_existing, flow_map) = if assets.is_empty() { diff --git a/src/simulation/investment/appraisal/constraints.rs b/src/simulation/investment/appraisal/constraints.rs index 0f13e6070..68b4cc341 100644 --- a/src/simulation/investment/appraisal/constraints.rs +++ b/src/simulation/investment/appraisal/constraints.rs @@ -21,9 +21,9 @@ pub fn add_activity_constraints( activity_vars: &IndexMap, time_slice_info: &TimeSliceInfo, ) { - let capacity = asset.total_capacity(); + let active_capacity = asset.active_capacity(); for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() { - let limits = (capacity * *limits.start()).value()..=(capacity * *limits.end()).value(); + let limits = (active_capacity * *limits.start()).value()..=(active_capacity * *limits.end()).value(); // Collect activity terms for the time slices in this selection let terms = ts_selection diff --git a/tests/data/simple_mothball/commodity_flows.csv b/tests/data/simple_mothball/commodity_flows.csv index df1c0947d..902b4a5b0 100644 --- a/tests/data/simple_mothball/commodity_flows.csv +++ b/tests/data/simple_mothball/commodity_flows.csv @@ -319,6 +319,54 @@ milestone_year,asset_id,commodity_id,time_slice,flow 2030,2,ELCTRI,autumn.day,6.001752635595889 2030,2,ELCTRI,autumn.peak,1.5488393825638174 2030,2,ELCTRI,autumn.evening,1.9008483513729917 +2030,3,GASNAT,winter.night,0.0 +2030,3,ELCTRI,winter.night,-0.0 +2030,3,CO2EMT,winter.night,-0.0 +2030,3,GASNAT,winter.day,-0.0 +2030,3,ELCTRI,winter.day,0.0 +2030,3,CO2EMT,winter.day,0.0 +2030,3,GASNAT,winter.peak,-0.0 +2030,3,ELCTRI,winter.peak,0.0 +2030,3,CO2EMT,winter.peak,0.0 +2030,3,GASNAT,winter.evening,-0.0 +2030,3,ELCTRI,winter.evening,0.0 +2030,3,CO2EMT,winter.evening,0.0 +2030,3,GASNAT,peak.night,0.0 +2030,3,ELCTRI,peak.night,-0.0 +2030,3,CO2EMT,peak.night,-0.0 +2030,3,GASNAT,peak.day,-0.0 +2030,3,ELCTRI,peak.day,0.0 +2030,3,CO2EMT,peak.day,0.0 +2030,3,GASNAT,peak.peak,-0.0 +2030,3,ELCTRI,peak.peak,0.0 +2030,3,CO2EMT,peak.peak,0.0 +2030,3,GASNAT,peak.evening,0.0 +2030,3,ELCTRI,peak.evening,-0.0 +2030,3,CO2EMT,peak.evening,-0.0 +2030,3,GASNAT,summer.night,-0.0 +2030,3,ELCTRI,summer.night,0.0 +2030,3,CO2EMT,summer.night,0.0 +2030,3,GASNAT,summer.day,-0.0 +2030,3,ELCTRI,summer.day,0.0 +2030,3,CO2EMT,summer.day,0.0 +2030,3,GASNAT,summer.peak,-0.0 +2030,3,ELCTRI,summer.peak,0.0 +2030,3,CO2EMT,summer.peak,0.0 +2030,3,GASNAT,summer.evening,-0.0 +2030,3,ELCTRI,summer.evening,0.0 +2030,3,CO2EMT,summer.evening,0.0 +2030,3,GASNAT,autumn.night,0.0 +2030,3,ELCTRI,autumn.night,-0.0 +2030,3,CO2EMT,autumn.night,-0.0 +2030,3,GASNAT,autumn.day,-0.0 +2030,3,ELCTRI,autumn.day,0.0 +2030,3,CO2EMT,autumn.day,0.0 +2030,3,GASNAT,autumn.peak,0.0 +2030,3,ELCTRI,autumn.peak,-0.0 +2030,3,CO2EMT,autumn.peak,-0.0 +2030,3,GASNAT,autumn.evening,0.0 +2030,3,ELCTRI,autumn.evening,-0.0 +2030,3,CO2EMT,autumn.evening,-0.0 2030,4,GASNAT,winter.night,-39.00834276911883 2030,4,RSHEAT,winter.night,33.920298060103335 2030,4,CO2EMT,winter.night,1994.4965657850462 @@ -591,6 +639,22 @@ milestone_year,asset_id,commodity_id,time_slice,flow 2040,1,GASPRD,autumn.evening,-50.47700532224177 2040,1,GASNAT,autumn.evening,48.07333840213502 2040,1,CO2EMT,autumn.evening,122.8994896250582 +2040,2,ELCTRI,winter.night,0.0 +2040,2,ELCTRI,winter.day,0.0 +2040,2,ELCTRI,winter.peak,0.0 +2040,2,ELCTRI,winter.evening,0.0 +2040,2,ELCTRI,peak.night,0.0 +2040,2,ELCTRI,peak.day,0.0 +2040,2,ELCTRI,peak.peak,0.0 +2040,2,ELCTRI,peak.evening,0.0 +2040,2,ELCTRI,summer.night,0.0 +2040,2,ELCTRI,summer.day,0.0 +2040,2,ELCTRI,summer.peak,0.0 +2040,2,ELCTRI,summer.evening,0.0 +2040,2,ELCTRI,autumn.night,0.0 +2040,2,ELCTRI,autumn.day,0.0 +2040,2,ELCTRI,autumn.peak,0.0 +2040,2,ELCTRI,autumn.evening,0.0 2040,6,GASNAT,winter.night,-7.589000616341825 2040,6,RSHEAT,winter.night,6.5991309707320225 2040,6,CO2EMT,winter.night,388.02560151355755 @@ -639,6 +703,70 @@ milestone_year,asset_id,commodity_id,time_slice,flow 2040,6,GASNAT,autumn.evening,-3.7797267573329014 2040,6,RSHEAT,autumn.evening,3.2867189194199145 2040,6,CO2EMT,autumn.evening,193.25742910243127 +2040,7,ELCTRI,winter.night,0.0 +2040,7,ELCTRI,winter.day,0.0 +2040,7,ELCTRI,winter.peak,0.0 +2040,7,ELCTRI,winter.evening,0.0 +2040,7,ELCTRI,peak.night,0.0 +2040,7,ELCTRI,peak.day,0.0 +2040,7,ELCTRI,peak.peak,0.0 +2040,7,ELCTRI,peak.evening,0.0 +2040,7,ELCTRI,summer.night,0.0 +2040,7,ELCTRI,summer.day,0.0 +2040,7,ELCTRI,summer.peak,0.0 +2040,7,ELCTRI,summer.evening,0.0 +2040,7,ELCTRI,autumn.night,0.0 +2040,7,ELCTRI,autumn.day,0.0 +2040,7,ELCTRI,autumn.peak,0.0 +2040,7,ELCTRI,autumn.evening,0.0 +2040,8,GASNAT,winter.night,-0.0 +2040,8,ELCTRI,winter.night,0.0 +2040,8,CO2EMT,winter.night,0.0 +2040,8,GASNAT,winter.day,-0.0 +2040,8,ELCTRI,winter.day,0.0 +2040,8,CO2EMT,winter.day,0.0 +2040,8,GASNAT,winter.peak,-0.0 +2040,8,ELCTRI,winter.peak,0.0 +2040,8,CO2EMT,winter.peak,0.0 +2040,8,GASNAT,winter.evening,-0.0 +2040,8,ELCTRI,winter.evening,0.0 +2040,8,CO2EMT,winter.evening,0.0 +2040,8,GASNAT,peak.night,-0.0 +2040,8,ELCTRI,peak.night,0.0 +2040,8,CO2EMT,peak.night,0.0 +2040,8,GASNAT,peak.day,-0.0 +2040,8,ELCTRI,peak.day,0.0 +2040,8,CO2EMT,peak.day,0.0 +2040,8,GASNAT,peak.peak,-0.0 +2040,8,ELCTRI,peak.peak,0.0 +2040,8,CO2EMT,peak.peak,0.0 +2040,8,GASNAT,peak.evening,-0.0 +2040,8,ELCTRI,peak.evening,0.0 +2040,8,CO2EMT,peak.evening,0.0 +2040,8,GASNAT,summer.night,-0.0 +2040,8,ELCTRI,summer.night,0.0 +2040,8,CO2EMT,summer.night,0.0 +2040,8,GASNAT,summer.day,-0.0 +2040,8,ELCTRI,summer.day,0.0 +2040,8,CO2EMT,summer.day,0.0 +2040,8,GASNAT,summer.peak,-0.0 +2040,8,ELCTRI,summer.peak,0.0 +2040,8,CO2EMT,summer.peak,0.0 +2040,8,GASNAT,summer.evening,-0.0 +2040,8,ELCTRI,summer.evening,0.0 +2040,8,CO2EMT,summer.evening,0.0 +2040,8,GASNAT,autumn.night,-0.0 +2040,8,ELCTRI,autumn.night,0.0 +2040,8,CO2EMT,autumn.night,0.0 +2040,8,GASNAT,autumn.day,-0.0 +2040,8,ELCTRI,autumn.day,0.0 +2040,8,CO2EMT,autumn.day,0.0 +2040,8,GASNAT,autumn.peak,-0.0 +2040,8,ELCTRI,autumn.peak,0.0 +2040,8,CO2EMT,autumn.peak,0.0 +2040,8,GASNAT,autumn.evening,-0.0 +2040,8,ELCTRI,autumn.evening,0.0 +2040,8,CO2EMT,autumn.evening,0.0 2040,9,GASNAT,winter.night,-77.32841770402716 2040,9,RSHEAT,winter.night,67.24210235132797 2040,9,CO2EMT,winter.night,3953.801997206909 From 4ebab6d72c745202437c2df6da8e5b0b9dd43bbd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:32:51 +0000 Subject: [PATCH 2/4] [pre-commit.ci lite] apply automatic fixes --- src/simulation/investment/appraisal/constraints.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/simulation/investment/appraisal/constraints.rs b/src/simulation/investment/appraisal/constraints.rs index 68b4cc341..dc5b6903b 100644 --- a/src/simulation/investment/appraisal/constraints.rs +++ b/src/simulation/investment/appraisal/constraints.rs @@ -23,7 +23,8 @@ pub fn add_activity_constraints( ) { let active_capacity = asset.active_capacity(); for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() { - let limits = (active_capacity * *limits.start()).value()..=(active_capacity * *limits.end()).value(); + let limits = + (active_capacity * *limits.start()).value()..=(active_capacity * *limits.end()).value(); // Collect activity terms for the time slices in this selection let terms = ts_selection From de1991fbf06e28108a975771368c10ad0eb43753 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 8 Sep 2026 10:57:34 +0100 Subject: [PATCH 3/4] Remove with_subset_of_tranches --- src/asset.rs | 110 ++++++++-------------------------------------- src/asset/pool.rs | 5 ++- 2 files changed, 23 insertions(+), 92 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 1238c4f7b..331199d5f 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -987,54 +987,13 @@ impl AssetRef { } } - /// Get an [`AssetRef`] representing a subset of this asset's tranches. - /// - /// If some of the asset's tranches are mothballed, these are discarded before non-mothballed - /// tranches. For example, if an asset has seven tranches of which four are mothballed and we are - /// reducing the number of tranches to four, the new asset will have one mothballed tranche. - /// - /// # Panics - /// - /// Panics if `new_num_tranches` is zero or exceeds the total capacity of this asset. - pub fn with_subset_of_tranches(self, new_num_tranches: u32) -> Self { - if new_num_tranches == self.num_tranches() { - return self; - } - - assert!( - new_num_tranches > 0, - "Cannot make an asset with zero tranches" - ); - - let max_num_tranches = self.capacity().num_tranches(); - let tranche_size = self.capacity().tranche_size(); - - assert!( - new_num_tranches <= max_num_tranches, - "Cannot make an asset with more tranches than original" - ); - - // Make a new Asset with fewer tranches. If there are more mothballed tranches than the new asset - // will have, we reduce this number to avoid there being more mothballed tranches than the new - // asset has, which would be a logic error. We discard mothballed before non-mothballed - // tranches. - let new_num_mothballed = - new_num_tranches.saturating_sub(self.get_num_nonmothballed_tranches()); - let mut asset = self.with_mothballed_tranches(new_num_mothballed, None); - asset - .make_mut() - .set_capacity(AssetCapacity::new(new_num_tranches, tranche_size)); - asset - } - /// Get an [`AssetRef`] representing a single tranche of this asset. pub fn as_single_tranche(self) -> Self { - let new_num_tranches = 1; let tranche_size = self.capacity().tranche_size(); let mut asset = self.with_no_mothballed_tranches(); asset .make_mut() - .set_capacity(AssetCapacity::new(new_num_tranches, tranche_size)); + .set_capacity(AssetCapacity::new(1, tranche_size)); asset } @@ -1071,10 +1030,18 @@ impl AssetRef { return None; } - // `with_subset_of_tranches` discards the oldest mothballed tranches first, which are exactly the - // ones being decommissioned here. log_decommissioning(&self, tranches_to_remove, &reason); - Some(self.with_subset_of_tranches(new_num_tranches)) + + // Retain the newest mothball events, discarding the oldest events that have just been + // decommissioned. The capacity is reduced after trimming the event history. + let tranche_size = self.capacity().tranche_size(); + let new_num_mothballed = + new_num_tranches.saturating_sub(self.get_num_nonmothballed_tranches()); + let mut asset = self.with_mothballed_tranches(new_num_mothballed, None); + asset + .make_mut() + .set_capacity(AssetCapacity::new(new_num_tranches, tranche_size)); + Some(asset) } /// Return a new [`AssetRef`] with the specified number of tranches mothballed. @@ -1512,39 +1479,6 @@ mod tests { ); } - #[rstest] - #[case::subset(2, false)] - #[case::all_tranches(3, true)] - fn with_subset_of_tranches( - multi_tranche_asset: Asset, - #[case] num_tranches: u32, - #[case] expect_same_asset: bool, - ) { - let asset = AssetRef::from(multi_tranche_asset); - let asset_subset = asset.clone().with_subset_of_tranches(num_tranches); - - assert_eq!( - asset_subset.capacity(), - AssetCapacity::new(num_tranches, Capacity(4.0)) - ); - assert_eq!(asset_subset.id(), asset.id()); - assert_eq!(asset_subset.agent_id(), asset.agent_id()); - assert_eq!(Arc::ptr_eq(&asset_subset.0, &asset.0), expect_same_asset); - assert_eq!(asset.capacity(), AssetCapacity::new(3, Capacity(4.0))); - } - - #[rstest] - #[should_panic(expected = "Cannot make an asset with zero tranches")] - fn with_subset_of_tranches_panics_for_zero_tranches(commissioned_multi_tranche: AssetRef) { - commissioned_multi_tranche.with_subset_of_tranches(0); - } - - #[rstest] - #[should_panic(expected = "Cannot make an asset with more tranches than original")] - fn with_subset_of_tranches_panics_for_too_many_tranches(commissioned_multi_tranche: AssetRef) { - commissioned_multi_tranche.with_subset_of_tranches(4); - } - #[rstest] fn asset_commission(process: Process) { // Test successful commissioning of Ready asset @@ -1782,18 +1716,6 @@ mod tests { assert!(Arc::ptr_eq(&asset.0, &same.0)); } - #[rstest] - fn with_subset_of_tranches_caps_mothballed(commissioned_multi_tranche: AssetRef) { - // Mothball all 3 tranches - let asset = commissioned_multi_tranche.with_mothballed_tranches(3, Some(2020)); - assert_eq!(asset.get_num_mothballed_tranches(), 3); - - // Taking a subset of 2 tranches caps the mothballed count at the new number of tranches - let subset = asset.with_subset_of_tranches(2); - assert_eq!(subset.num_tranches(), 2); - assert_eq!(subset.get_num_mothballed_tranches(), 2); - } - #[rstest] fn with_decommission_mothballed_nothing_old_enough(commissioned_multi_tranche: AssetRef) { let asset = commissioned_multi_tranche.with_mothballed_tranches(1, Some(2020)); @@ -1813,8 +1735,14 @@ mod tests { .with_mothballed_tranches(2, Some(2020)); // With a threshold of 2015, only the 2010 event is old enough to decommission - let result = asset.with_decommission_mothballed(2025, 10).unwrap(); + let result = asset + .clone() + .with_decommission_mothballed(2025, 10) + .unwrap(); assert_eq!(result.num_tranches(), 2); + assert_eq!(result.capacity(), AssetCapacity::new(2, Capacity(4.0))); + assert_eq!(result.id(), asset.id()); + assert_eq!(result.agent_id(), asset.agent_id()); assert_eq!(result.get_num_mothballed_tranches(), 1); assert_equal( result.get_mothball_events().unwrap().iter(), diff --git a/src/asset/pool.rs b/src/asset/pool.rs index d5aa469dc..5b0fd9f18 100644 --- a/src/asset/pool.rs +++ b/src/asset/pool.rs @@ -629,7 +629,10 @@ mod tests { fn asset_pool_mothball_unretained_partial(commissioned_multi_tranche: AssetRef) { // The full asset has three tranches; only two of them were retained in the pool let full = commissioned_multi_tranche; - let retained = full.clone().with_subset_of_tranches(2); + let mut retained = full.clone(); + retained + .make_mut() + .set_capacity(AssetCapacity::new(2, Capacity(4.0))); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(retained); From 21c7599186cd819b33d62647fbdf7f49e304fab5 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 9 Sep 2026 16:02:02 +0100 Subject: [PATCH 4/4] Simplify `add_activity_constraints` --- src/simulation/investment/appraisal/constraints.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/simulation/investment/appraisal/constraints.rs b/src/simulation/investment/appraisal/constraints.rs index dc5b6903b..9d3901769 100644 --- a/src/simulation/investment/appraisal/constraints.rs +++ b/src/simulation/investment/appraisal/constraints.rs @@ -21,10 +21,8 @@ pub fn add_activity_constraints( activity_vars: &IndexMap, time_slice_info: &TimeSliceInfo, ) { - let active_capacity = asset.active_capacity(); - for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() { - let limits = - (active_capacity * *limits.start()).value()..=(active_capacity * *limits.end()).value(); + for (ts_selection, limits) in asset.iter_activity_limits() { + let limits = limits.start().value()..=limits.end().value(); // Collect activity terms for the time slices in this selection let terms = ts_selection