From 80ff41a31755e823f61751b5623cbbe2189fe659 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 29 Oct 2026 10:35:40 +0000 Subject: [PATCH 1/6] Add back continuous capacities API --- src/asset.rs | 12 +-- src/asset/capacity.rs | 196 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 165 insertions(+), 43 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 143750998..8750da004 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -993,11 +993,9 @@ impl AssetRef { self.num_tranches() > 0, "Cannot convert an asset with zero tranches to a single tranche" ); - let tranche_size = self.capacity().tranche_size(); + let capacity = self.capacity().with_num_tranches(1); let mut asset = self.with_no_mothballed_tranches(); - asset - .make_mut() - .set_capacity(AssetCapacity::new(1, tranche_size)); + asset.make_mut().set_capacity(capacity); asset } @@ -1038,13 +1036,11 @@ impl AssetRef { // 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 capacity = self.capacity().with_num_tranches(new_num_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.make_mut().set_capacity(capacity); Some(asset) } diff --git a/src/asset/capacity.rs b/src/asset/capacity.rs index 64af8d7c5..5d8a8beb8 100644 --- a/src/asset/capacity.rs +++ b/src/asset/capacity.rs @@ -3,29 +3,43 @@ use crate::units::{Capacity, Dimensionless}; use std::cmp::Ordering; use std::ops::{Add, Sub}; -/// Capacity of an asset, expressed in terms of a number of discrete tranches of a given size. +/// Capacity of an asset, which may be continuous or a number of discrete tranches of a given size. #[derive(Clone, PartialEq, Copy, Debug)] -pub struct AssetCapacity { - /// Number of tranches - num_tranches: u32, - /// Size of each tranche - tranche_size: Capacity, +pub enum AssetCapacity { + /// Continuous capacity, which is treated as a single indivisible tranche + Continuous(Capacity), + /// Capacity expressed as a number of equally sized tranches + Discrete { + /// Number of tranches + num_tranches: u32, + /// Size of each tranche + tranche_size: Capacity, + }, } impl AssetCapacity { - /// Create a new `AssetCapacity` with the given number of tranches and tranche size + /// Create a new continuous `AssetCapacity` with the given capacity + pub fn continuous(capacity: Capacity) -> Self { + assert!( + capacity.is_finite() && capacity >= Capacity(0.0), + "Capacity must be a finite non-negative number" + ); + AssetCapacity::Continuous(capacity) + } + + /// Create a new discrete `AssetCapacity` with the given number of tranches and tranche size pub fn new(num_tranches: u32, tranche_size: Capacity) -> Self { assert!( tranche_size.is_finite() && tranche_size >= Capacity(0.0), "Tranche size must be a finite non-negative number" ); - AssetCapacity { + AssetCapacity::Discrete { num_tranches, tranche_size, } } - /// Create a new `AssetCapacity` with a single tranche of the given size + /// Create a new discrete `AssetCapacity` with a single tranche of the given size pub fn single(tranche_size: Capacity) -> Self { Self::new(1, tranche_size) } @@ -34,7 +48,9 @@ impl AssetCapacity { /// /// # Panics /// - /// Panics if the tranche size differs. + /// Panics if the comparison is not meaningful. This happens if either `AssetCapacity` contains + /// a NaN value, one is discrete and the other continuous or if both are discrete and the + /// tranche size differs. pub fn min(self, other: AssetCapacity) -> AssetCapacity { match self.partial_cmp(&other) { None => panic!("Comparing invalid AssetCapacity values ({self:?} and {other:?})"), @@ -44,27 +60,60 @@ impl AssetCapacity { } /// Returns the number of tranches in this `AssetCapacity`. + /// + /// A continuous capacity is a single tranche. pub fn num_tranches(&self) -> u32 { - self.num_tranches + match self { + AssetCapacity::Continuous(_) => 1, + AssetCapacity::Discrete { num_tranches, .. } => *num_tranches, + } } /// Returns the tranche size of this `AssetCapacity`. + /// + /// For a continuous capacity, this is the full capacity. pub fn tranche_size(&self) -> Capacity { - self.tranche_size + match self { + AssetCapacity::Continuous(capacity) => *capacity, + AssetCapacity::Discrete { tranche_size, .. } => *tranche_size, + } } - /// Validates that two capacities have the same tranche size. - fn check_same_tranche_size(&self, other: AssetCapacity) { + /// Returns a copy of this `AssetCapacity` with the given number of tranches. + /// + /// # Panics + /// + /// Panics if this capacity is continuous and `num_tranches` is not one. + pub fn with_num_tranches(self, num_tranches: u32) -> Self { + match self { + AssetCapacity::Continuous(_) => { + assert_eq!( + num_tranches, 1, + "Cannot change the number of tranches of a continuous capacity" + ); + self + } + AssetCapacity::Discrete { tranche_size, .. } => Self::new(num_tranches, tranche_size), + } + } + + /// Validates that two discrete capacities have the same tranche size. + fn check_same_tranche_size(size1: Capacity, size2: Capacity) { assert_eq!( - self.tranche_size, other.tranche_size, - "Can't perform operation on capacities with different tranche sizes ({} and {})", - self.tranche_size, other.tranche_size, + size1, size2, + "Can't perform operation on capacities with different tranche sizes ({size1} and {size2})", ); } /// Returns the total capacity represented by this `AssetCapacity`. pub fn total_capacity(&self) -> Capacity { - self.tranche_size * Dimensionless(self.num_tranches as f64) + match self { + AssetCapacity::Continuous(capacity) => *capacity, + AssetCapacity::Discrete { + num_tranches, + tranche_size, + } => *tranche_size * Dimensionless(*num_tranches as f64), + } } } @@ -73,10 +122,27 @@ impl Add for AssetCapacity { // Add two AssetCapacity values together fn add(self, rhs: AssetCapacity) -> Self { - self.check_same_tranche_size(rhs); - AssetCapacity { - num_tranches: self.num_tranches + rhs.num_tranches, - tranche_size: self.tranche_size, + match (self, rhs) { + (AssetCapacity::Continuous(cap1), AssetCapacity::Continuous(cap2)) => { + AssetCapacity::Continuous(cap1 + cap2) + } + ( + AssetCapacity::Discrete { + num_tranches: n1, + tranche_size: size1, + }, + AssetCapacity::Discrete { + num_tranches: n2, + tranche_size: size2, + }, + ) => { + Self::check_same_tranche_size(size1, size2); + AssetCapacity::Discrete { + num_tranches: n1 + n2, + tranche_size: size1, + } + } + _ => panic!("Cannot add different types of AssetCapacity ({self:?} and {rhs:?})"), } } } @@ -86,23 +152,51 @@ impl Sub for AssetCapacity { // Subtract rhs from self, ensuring that the result is non-negative fn sub(self, rhs: AssetCapacity) -> Self { - self.check_same_tranche_size(rhs); - assert!( - self.num_tranches >= rhs.num_tranches, - "Cannot subtract a larger AssetCapacity ({rhs:?}) from a smaller one ({self:?})" - ); - AssetCapacity { - num_tranches: self.num_tranches - rhs.num_tranches, - tranche_size: self.tranche_size, + match (self, rhs) { + (AssetCapacity::Continuous(cap1), AssetCapacity::Continuous(cap2)) => { + AssetCapacity::Continuous((cap1 - cap2).max(Capacity(0.0))) + } + ( + AssetCapacity::Discrete { + num_tranches: n1, + tranche_size: size1, + }, + AssetCapacity::Discrete { + num_tranches: n2, + tranche_size: size2, + }, + ) => { + Self::check_same_tranche_size(size1, size2); + assert!( + n1 >= n2, + "Cannot subtract a larger AssetCapacity ({rhs:?}) from a smaller one ({self:?})" + ); + AssetCapacity::Discrete { + num_tranches: n1 - n2, + tranche_size: size1, + } + } + _ => panic!("Cannot subtract different types of AssetCapacity ({self:?} and {rhs:?})"), } } } impl PartialOrd for AssetCapacity { fn partial_cmp(&self, other: &Self) -> Option { - let size1 = self.tranche_size; - let size2 = other.tranche_size; - (size1 == size2).then(|| self.num_tranches.cmp(&other.num_tranches)) + match (self, other) { + (AssetCapacity::Continuous(a), AssetCapacity::Continuous(b)) => a.partial_cmp(b), + ( + AssetCapacity::Discrete { + num_tranches: n1, + tranche_size: size1, + }, + AssetCapacity::Discrete { + num_tranches: n2, + tranche_size: size2, + }, + ) => (size1 == size2).then(|| n1.cmp(n2)), + _ => None, + } } } @@ -140,6 +234,21 @@ mod tests { } #[rstest] + #[case::continuous_less( + AssetCapacity::Continuous(Capacity(4.0)), + AssetCapacity::Continuous(Capacity(6.0)), + Some(Ordering::Less) + )] + #[case::continuous_equal( + AssetCapacity::Continuous(Capacity(4.0)), + AssetCapacity::Continuous(Capacity(4.0)), + Some(Ordering::Equal) + )] + #[case::continuous_greater( + AssetCapacity::Continuous(Capacity(6.0)), + AssetCapacity::Continuous(Capacity(4.0)), + Some(Ordering::Greater) + )] #[case::less( AssetCapacity::new(2, Capacity(3.0)), AssetCapacity::new(4, Capacity(3.0)), @@ -155,7 +264,7 @@ mod tests { AssetCapacity::new(4, Capacity(3.0)), Some(Ordering::Greater) )] - fn partial_cmp_with_matching_tranche_size( + fn partial_cmp_with_valid_comparisons( #[case] left: AssetCapacity, #[case] right: AssetCapacity, #[case] expected: Option, @@ -165,10 +274,18 @@ mod tests { } #[rstest] + #[case::mixed_types( + AssetCapacity::Continuous(Capacity(4.0)), + AssetCapacity::new(4, Capacity(1.0)) + )] #[case::different_tranche_sizes( AssetCapacity::new(4, Capacity(1.0)), AssetCapacity::new(4, Capacity(2.0)) )] + #[case::nan_continuous( + AssetCapacity::Continuous(Capacity(f64::NAN)), + AssetCapacity::Continuous(Capacity(4.0)) + )] fn partial_cmp_returns_none_for_invalid_comparisons( #[case] left: AssetCapacity, #[case] right: AssetCapacity, @@ -178,6 +295,11 @@ mod tests { } #[rstest] + #[case::continuous( + AssetCapacity::Continuous(Capacity(4.0)), + AssetCapacity::Continuous(Capacity(6.0)), + AssetCapacity::Continuous(Capacity(4.0)) + )] #[case::discrete( AssetCapacity::new(2, Capacity(3.0)), AssetCapacity::new(4, Capacity(3.0)), @@ -192,6 +314,10 @@ mod tests { } #[rstest] + #[case::mixed_types( + AssetCapacity::Continuous(Capacity(4.0)), + AssetCapacity::new(4, Capacity(1.0)) + )] #[case::different_tranche_sizes( AssetCapacity::new(4, Capacity(1.0)), AssetCapacity::new(4, Capacity(2.0)) @@ -210,7 +336,7 @@ mod tests { assert_eq!( capacity - capacity, - AssetCapacity { + AssetCapacity::Discrete { num_tranches: 0, tranche_size: Capacity(3.0), } From 86d623fd611c257d9470afd22a9b8a0844e37b57 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 29 Oct 2026 11:12:27 +0000 Subject: [PATCH 2/6] API for mothballing continuous capacities --- src/asset.rs | 325 ++++++++++++----------- src/asset/capacity.rs | 56 ++-- src/asset/pool.rs | 57 ++-- src/output.rs | 24 +- src/simulation/investment/competitive.rs | 4 +- 5 files changed, 251 insertions(+), 215 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 8750da004..5877f7a20 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -9,10 +9,11 @@ use crate::region::RegionID; use crate::simulation::PriceMap; use crate::time_slice::{TimeSliceID, TimeSliceSelection}; use crate::units::{ - Activity, ActivityPerCapacity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity, - MoneyPerCapacity, MoneyPerFlow, UnitType, Year, + Activity, ActivityPerCapacity, Capacity, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity, + MoneyPerFlow, UnitType, Year, }; use anyhow::{Context, Result, ensure}; +use float_cmp::approx_eq; use indexmap::IndexMap; use log::debug; use map_macro::vec_deque; @@ -45,11 +46,11 @@ pub use pool::AssetPool; )] pub struct AssetID(u32); -/// Indicates the year and number of tranches mothballed +/// Indicates the year and capacity mothballed #[derive(PartialEq, Debug, Clone)] pub struct MothballEvent { year: u32, - num_tranches: u32, + capacity: AssetCapacity, } /// The state of an asset @@ -737,9 +738,16 @@ impl Asset { capacity.total_capacity() >= Capacity(0.0), "Capacity must be >= 0" ); + let mothballed = self.mothballed_capacity().total_capacity(); assert!( - self.get_num_mothballed_tranches() <= capacity.num_tranches(), - "Cannot set capacity to a smaller number of tranches than are currently mothballed" + mothballed <= capacity.total_capacity() + || approx_eq!( + Capacity, + mothballed, + capacity.total_capacity(), + epsilon = 1e-9 + ), + "Cannot set capacity to less than the capacity that is currently mothballed" ); self.capacity = capacity; } @@ -817,43 +825,34 @@ impl Asset { } } - /// Whether this asset has any tranches mothballed - pub fn has_any_mothballed_tranches(&self) -> bool { + /// Whether this asset has any capacity mothballed + pub fn has_any_mothballed_capacity(&self) -> bool { self.get_mothball_events() .is_some_and(|events| !events.is_empty()) } - /// Get the number of tranches which are mothballed. - /// - /// For non-commissioned assets, this always returns zero. - pub fn get_num_mothballed_tranches(&self) -> u32 { - let Some(events) = self.get_mothball_events() else { - return 0; - }; - - events.iter().map(|event| event.num_tranches).sum() - } - /// Get the capacity which is mothballed. - pub fn mothballed_capacity(&self) -> Capacity { - self.capacity().tranche_size() * Dimensionless(self.get_num_mothballed_tranches() as f64) - } - - /// Get the remaining number of tranches that are not mothballed. /// - /// For non-commissioned assets, this always returns the total number of tranches. - pub fn get_num_nonmothballed_tranches(&self) -> u32 { - self.num_tranches() - self.get_num_mothballed_tranches() + /// For non-commissioned assets, this always returns zero. + pub fn mothballed_capacity(&self) -> AssetCapacity { + let zero = self.capacity().zero(); + self.get_mothball_events().map_or(zero, |events| { + events + .iter() + .fold(zero, |total, event| total + event.capacity) + }) } /// 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) + (self.capacity() - self.mothballed_capacity()).total_capacity() } /// The number of tranches this asset represents + /// + /// A continuous capacity is treated as a single indivisible tranche. pub fn num_tranches(&self) -> u32 { - self.capacity().num_tranches() + self.capacity().num_tranches().unwrap_or(1) } } @@ -942,16 +941,16 @@ pub fn check_capacity_valid_for_asset(capacity: Capacity) -> Result<()> { Ok(()) } -/// Log that the specified number of tranches has been decommissioned for the given asset -fn log_decommissioning(asset: &Asset, num_tranches: u32, reason: &str) { +/// Log that the specified capacity has been decommissioned for the given asset +fn log_decommissioning(asset: &Asset, capacity: AssetCapacity, reason: &str) { let (id, agent_id) = match &asset.state { AssetState::Commissioned { id, agent_id, .. } => (*id, agent_id.clone()), _ => panic!("Cannot decommission an asset that hasn't been commissioned"), }; debug!( - "Decommissioning {}/{} tranches of '{}' asset (ID: {}) for agent '{}' (reason: {})", - num_tranches, - asset.num_tranches(), + "Decommissioning {} of {} capacity of '{}' asset (ID: {}) for agent '{}' (reason: {})", + capacity.total_capacity(), + asset.total_capacity(), asset.process_id(), id, agent_id, @@ -993,32 +992,33 @@ impl AssetRef { self.num_tranches() > 0, "Cannot convert an asset with zero tranches to a single tranche" ); - let capacity = self.capacity().with_num_tranches(1); - let mut asset = self.with_no_mothballed_tranches(); + let capacity = self.capacity().single_tranche(); + let mut asset = self.with_no_mothballed_capacity(); asset.make_mut().set_capacity(capacity); asset } /// Decommission this asset fn decommission(self, reason: &str) { - log_decommissioning(&self, self.num_tranches(), reason); + log_decommissioning(&self, self.capacity(), reason); } - /// Decommission any tranches that were mothballed at least `mothball_years` ago. + /// Decommission any capacity that was mothballed at least `mothball_years` ago. /// - /// If the asset still has some tranches remaining, it is returned, else None. + /// If the asset still has some capacity remaining, it is returned, else None. fn with_decommission_mothballed(self, year: u32, mothball_years: u32) -> Option { let events = self .get_mothball_events() - .expect("Can only decommission mothballed tranches in commissioned assets"); + .expect("Can only decommission mothballed capacity in commissioned assets"); - // Mothball events are ordered oldest-first, so this sums the tranches mothballed longest ago - let tranches_to_remove: u32 = events + // Mothball events are ordered oldest-first, so this sums the capacity mothballed longest ago + let capacity_to_remove = events .iter() .take_while(|event| event.year <= year.saturating_sub(mothball_years)) - .map(|event| event.num_tranches) - .sum(); - if tranches_to_remove == 0 { + .fold(self.capacity().zero(), |total, event| { + total + event.capacity + }); + if capacity_to_remove.is_zero() { // Nothing to do. Return self unmodified. return Some(self); } @@ -1026,97 +1026,107 @@ impl AssetRef { let reason = format!( "The asset has not been used for the set mothball years ({mothball_years} years)." ); - let new_num_tranches = self.num_tranches() - tranches_to_remove; - if new_num_tranches == 0 { + let new_capacity = self.capacity() - capacity_to_remove; + if new_capacity.is_zero() { self.decommission(&reason); return None; } - log_decommissioning(&self, tranches_to_remove, &reason); + log_decommissioning(&self, capacity_to_remove, &reason); // Retain the newest mothball events, discarding the oldest events that have just been // decommissioned. The capacity is reduced after trimming the event history. - let capacity = self.capacity().with_num_tranches(new_num_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(capacity); + let new_mothballed = self.mothballed_capacity() - capacity_to_remove; + let mut asset = self.with_mothballed_capacity(new_mothballed, None); + asset.make_mut().set_capacity(new_capacity); Some(asset) } - /// Return a new [`AssetRef`] with the specified number of tranches mothballed. + /// Return a new [`AssetRef`] with the specified capacity mothballed. + /// + /// `mothballed` is the total capacity that should be mothballed after the call (not an + /// increment). It must be of the same kind as the asset's capacity: for discrete capacities, + /// whole tranches are mothballed, while continuous capacities can be partially mothballed. /// - /// If `num_tranches` equals the number of already mothballed tranches, the original asset is - /// returned. If additional tranches may be mothballed, a value must be provided for `year`. + /// If `mothballed` equals the capacity already mothballed, the original asset is returned. If + /// additional capacity is to be mothballed, a value must be provided for `year`. /// /// # Panics /// - /// Panics if attempting to mothball more tranches than the asset represents or if attempting to - /// change the number of mothballed tranches for a non-commissioned asset. - pub fn with_mothballed_tranches(mut self, num_tranches: u32, year: Option) -> Self { - if num_tranches == 0 { + /// Panics if attempting to mothball more capacity than the asset represents or if attempting to + /// change the mothballed capacity of a non-commissioned asset. + pub fn with_mothballed_capacity( + mut self, + mothballed: AssetCapacity, + year: Option, + ) -> Self { + if mothballed.is_zero() { // Small optimisation - return self.with_no_mothballed_tranches(); + return self.with_no_mothballed_capacity(); } - let num_already_mothballed = self.get_num_mothballed_tranches(); - if num_tranches == num_already_mothballed { + let already_mothballed = self.mothballed_capacity(); + if mothballed == already_mothballed { // Nothing to do. Return self unmodified. return self; } assert!( - num_tranches <= self.num_tranches(), - "Cannot mothball more tranches than asset represents" + mothballed <= self.capacity(), + "Cannot mothball more capacity than asset represents" ); // Now that we know we have to modify self, call make_mut(). - let events = self.make_mut().get_mothball_events_mut().expect( - "Cannot change number of mothballed tranches for an asset that hasn't been commissioned", - ); - if num_tranches < num_already_mothballed { - // Remove mothballing events until only required num of mothballed tranches remains, + let events = self + .make_mut() + .get_mothball_events_mut() + .expect("Cannot change mothballed capacity for an asset that hasn't been commissioned"); + if mothballed < already_mothballed { + // Remove mothballing events until only the required mothballed capacity remains, // starting with oldest - let mut remaining = num_already_mothballed - num_tranches; - while remaining > 0 + let mut remaining = already_mothballed - mothballed; + while !remaining.is_zero() && let Some(event) = events.front_mut() { - let to_remove = event.num_tranches.min(remaining); - event.num_tranches -= to_remove; - remaining -= to_remove; - if event.num_tranches == 0 { + if event.capacity <= remaining { + remaining = remaining - event.capacity; events.pop_front(); + } else { + event.capacity = event.capacity - remaining; + if event.capacity.is_zero() { + events.pop_front(); + } + break; } } } else { - let year = - year.expect("Cannot increase number of mothballed tranches without supplying year"); + let year = year.expect("Cannot increase mothballed capacity without supplying year"); if let Some(event) = events.back() { // Need to check this as adding events in the past breaks the expected invariant for // `mothball_events` assert!( event.year <= year, - "Attempting to mothball tranches in a year in the past" + "Attempting to mothball capacity in a year in the past" ); } - // Mothball extra tranches in specified year + // Mothball extra capacity in specified year events.push_back(MothballEvent { year, - num_tranches: num_tranches - num_already_mothballed, + capacity: mothballed - already_mothballed, }); } self } - /// Returns a new [`AssetRef`] with no mothballed tranches. + /// Returns a new [`AssetRef`] with no mothballed capacity. /// - /// If the asset has no mothballed tranches, the original asset is returned. - pub fn with_no_mothballed_tranches(mut self) -> Self { - if self.has_any_mothballed_tranches() { - // Only commissioned assets can have mothballed tranches, so this is safe + /// If the asset has no mothballed capacity, the original asset is returned. + pub fn with_no_mothballed_capacity(mut self) -> Self { + if self.has_any_mothballed_capacity() { + // Only commissioned assets can have mothballed capacity, so this is safe self.make_mut().get_mothball_events_mut().unwrap().clear(); } @@ -1236,6 +1246,11 @@ mod tests { AssetRef::from(multi_tranche_asset) } + /// Capacity made up of `num_tranches` tranches of the same size as the multi-tranche asset + fn tranches(num_tranches: u32) -> AssetCapacity { + AssetCapacity::new(num_tranches, Capacity(4.0)) + } + #[rstest] fn get_input_cost_from_prices_works( region_id: RegionID, @@ -1556,169 +1571,177 @@ mod tests { #[case::none(0)] #[case::some(2)] #[case::all(3)] - fn mothball_tranche_counts(commissioned_multi_tranche: AssetRef, #[case] num_mothballed: u32) { + fn mothball_capacity_amounts( + commissioned_multi_tranche: AssetRef, + #[case] num_mothballed: u32, + ) { assert_eq!(commissioned_multi_tranche.num_tranches(), 3); - let asset = commissioned_multi_tranche.with_mothballed_tranches(num_mothballed, Some(2020)); - assert_eq!(asset.get_num_mothballed_tranches(), num_mothballed); + let asset = commissioned_multi_tranche + .with_mothballed_capacity(tranches(num_mothballed), Some(2020)); + assert_eq!(asset.mothballed_capacity(), tranches(num_mothballed)); assert_eq!( - asset.mothballed_capacity(), + asset.mothballed_capacity().total_capacity(), Capacity(4.0 * num_mothballed as f64) ); - assert_eq!(asset.get_num_nonmothballed_tranches(), 3 - num_mothballed); - assert_eq!(asset.has_any_mothballed_tranches(), num_mothballed > 0); + assert_eq!( + asset.active_capacity(), + Capacity(4.0 * (3 - num_mothballed) as f64) + ); + assert_eq!(asset.has_any_mothballed_capacity(), num_mothballed > 0); } #[rstest] fn mothball_counts_non_commissioned(asset: Asset, process: Process) { - // Non-commissioned assets never have mothballed tranches, regardless of state + // Non-commissioned assets never have mothballed capacity, regardless of state let ready = AssetRef::from(asset); let candidate = AssetRef::from( Asset::new_candidate(process.into(), "GBR".into(), Capacity(1.0), 2020).unwrap(), ); for asset in [ready, candidate] { - assert!(!asset.has_any_mothballed_tranches()); - assert_eq!(asset.get_num_mothballed_tranches(), 0); - assert_eq!(asset.get_num_nonmothballed_tranches(), asset.num_tranches()); + assert!(!asset.has_any_mothballed_capacity()); + assert!(asset.mothballed_capacity().is_zero()); + assert_eq!(asset.active_capacity(), asset.total_capacity()); } } #[rstest] - fn with_mothballed_tranches_accumulates_events(commissioned_multi_tranche: AssetRef) { + fn with_mothballed_capacity_accumulates_events(commissioned_multi_tranche: AssetRef) { // Mothball one tranche in 2020 - let asset = commissioned_multi_tranche.with_mothballed_tranches(1, Some(2020)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(1), Some(2020)); assert_equal( asset.get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2020, - num_tranches: 1, + capacity: tranches(1), }], ); // Mothball a second tranche in 2022: events are retained in chronological order - let asset = asset.with_mothballed_tranches(2, Some(2022)); + let asset = asset.with_mothballed_capacity(tranches(2), Some(2022)); assert_equal( asset.get_mothball_events().unwrap().iter(), &[ MothballEvent { year: 2020, - num_tranches: 1, + capacity: tranches(1), }, MothballEvent { year: 2022, - num_tranches: 1, + capacity: tranches(1), }, ], ); } #[rstest] - fn with_mothballed_tranches_decrease_removes_oldest_first( + fn with_mothballed_capacity_decrease_removes_oldest_first( commissioned_multi_tranche: AssetRef, ) { // Mothball 1 tranche in 2020, then 2 more (3 total) in 2022 let asset = commissioned_multi_tranche - .with_mothballed_tranches(1, Some(2020)) - .with_mothballed_tranches(3, Some(2022)); + .with_mothballed_capacity(tranches(1), Some(2020)) + .with_mothballed_capacity(tranches(3), Some(2022)); assert_equal( asset.get_mothball_events().unwrap().iter(), &[ MothballEvent { year: 2020, - num_tranches: 1, + capacity: tranches(1), }, MothballEvent { year: 2022, - num_tranches: 2, + capacity: tranches(2), }, ], ); // Reduce to a single mothballed tranche: the oldest event is fully removed and the newer // event is partially reduced, leaving exactly one mothballed tranche - let asset = asset.with_mothballed_tranches(1, None); - assert_eq!(asset.get_num_mothballed_tranches(), 1); + let asset = asset.with_mothballed_capacity(tranches(1), None); + assert_eq!(asset.mothballed_capacity(), tranches(1)); assert_equal( asset.get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2022, - num_tranches: 1, + capacity: tranches(1), }], ); } #[rstest] - fn with_mothballed_tranches_noop_returns_same_rc(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_tranches(2, Some(2020)); - // Requesting the same number of mothballed tranches is a no-op (the year is ignored) - let same = asset.clone().with_mothballed_tranches(2, Some(2099)); + fn with_mothballed_capacity_noop_returns_same_rc(commissioned_multi_tranche: AssetRef) { + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), Some(2020)); + // Requesting the same mothballed capacity is a no-op (the year is ignored) + let same = asset + .clone() + .with_mothballed_capacity(tranches(2), Some(2099)); assert!(Arc::ptr_eq(&asset.0, &same.0)); } #[rstest] - fn with_mothballed_tranches_zero_unmothballs(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_tranches(2, Some(2020)); - assert!(asset.has_any_mothballed_tranches()); + fn with_mothballed_capacity_zero_unmothballs(commissioned_multi_tranche: AssetRef) { + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), Some(2020)); + assert!(asset.has_any_mothballed_capacity()); - let asset = asset.with_mothballed_tranches(0, None); - assert!(!asset.has_any_mothballed_tranches()); - assert_eq!(asset.get_num_mothballed_tranches(), 0); + let asset = asset.with_mothballed_capacity(tranches(0), None); + assert!(!asset.has_any_mothballed_capacity()); + assert!(asset.mothballed_capacity().is_zero()); } #[rstest] - #[should_panic(expected = "Cannot mothball more tranches than asset represents")] - fn with_mothballed_tranches_panics_for_too_many_tranches(commissioned_multi_tranche: AssetRef) { - commissioned_multi_tranche.with_mothballed_tranches(4, Some(2020)); + #[should_panic(expected = "Cannot mothball more capacity than asset represents")] + fn with_mothballed_capacity_panics_for_too_much_capacity(commissioned_multi_tranche: AssetRef) { + commissioned_multi_tranche.with_mothballed_capacity(tranches(4), Some(2020)); } #[rstest] #[should_panic( - expected = "Cannot change number of mothballed tranches for an asset that hasn't been commissioned" + expected = "Cannot change mothballed capacity for an asset that hasn't been commissioned" )] - fn with_mothballed_tranches_panics_for_non_commissioned(asset: Asset) { - AssetRef::from(asset).with_mothballed_tranches(1, Some(2020)); + fn with_mothballed_capacity_panics_for_non_commissioned(asset: Asset) { + let capacity = asset.capacity(); + AssetRef::from(asset).with_mothballed_capacity(capacity, Some(2020)); } #[rstest] - #[should_panic( - expected = "Cannot increase number of mothballed tranches without supplying year" - )] - fn with_mothballed_tranches_panics_when_increasing_without_year( + #[should_panic(expected = "Cannot increase mothballed capacity without supplying year")] + fn with_mothballed_capacity_panics_when_increasing_without_year( commissioned_multi_tranche: AssetRef, ) { - commissioned_multi_tranche.with_mothballed_tranches(1, None); + commissioned_multi_tranche.with_mothballed_capacity(tranches(1), None); } #[rstest] - #[should_panic(expected = "Attempting to mothball tranches in a year in the past")] - fn with_mothballed_tranches_panics_when_mothballing_in_the_past( + #[should_panic(expected = "Attempting to mothball capacity in a year in the past")] + fn with_mothballed_capacity_panics_when_mothballing_in_the_past( commissioned_multi_tranche: AssetRef, ) { // Mothball a tranche in 2020, then attempt to mothball another in an earlier year, which would // break the chronological ordering invariant of the mothball events commissioned_multi_tranche - .with_mothballed_tranches(1, Some(2020)) - .with_mothballed_tranches(2, Some(2019)); + .with_mothballed_capacity(tranches(1), Some(2020)) + .with_mothballed_capacity(tranches(2), Some(2019)); } #[rstest] - fn with_no_mothballed_tranches_clears_events(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_tranches(2, Some(2020)); - let asset = asset.with_no_mothballed_tranches(); - assert!(!asset.has_any_mothballed_tranches()); - assert_eq!(asset.get_num_mothballed_tranches(), 0); + fn with_no_mothballed_capacity_clears_events(commissioned_multi_tranche: AssetRef) { + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), Some(2020)); + let asset = asset.with_no_mothballed_capacity(); + assert!(!asset.has_any_mothballed_capacity()); + assert!(asset.mothballed_capacity().is_zero()); } #[rstest] - fn with_no_mothballed_tranches_noop_returns_same_rc(commissioned_multi_tranche: AssetRef) { - // `commissioned_multi_tranche` has no mothballed tranches, so the original Rc is returned unchanged + fn with_no_mothballed_capacity_noop_returns_same_rc(commissioned_multi_tranche: AssetRef) { + // `commissioned_multi_tranche` has no mothballed capacity, so the original Rc is returned unchanged let asset = commissioned_multi_tranche; - let same = asset.clone().with_no_mothballed_tranches(); + let same = asset.clone().with_no_mothballed_capacity(); assert!(Arc::ptr_eq(&asset.0, &same.0)); } #[rstest] fn with_decommission_mothballed_nothing_old_enough(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_tranches(1, Some(2020)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(1), Some(2020)); // Threshold is 2005, so the 2020 event is not old enough: the asset is returned unchanged let result = asset .clone() @@ -1731,8 +1754,8 @@ mod tests { fn with_decommission_mothballed_partial(commissioned_multi_tranche: AssetRef) { // Mothball 1 tranche in 2010 and 1 tranche in 2020 (leaving 1 tranche active) let asset = commissioned_multi_tranche - .with_mothballed_tranches(1, Some(2010)) - .with_mothballed_tranches(2, Some(2020)); + .with_mothballed_capacity(tranches(1), Some(2010)) + .with_mothballed_capacity(tranches(2), Some(2020)); // With a threshold of 2015, only the 2010 event is old enough to decommission let result = asset @@ -1740,23 +1763,23 @@ mod tests { .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.capacity(), tranches(2)); assert_eq!(result.id(), asset.id()); assert_eq!(result.agent_id(), asset.agent_id()); - assert_eq!(result.get_num_mothballed_tranches(), 1); + assert_eq!(result.mothballed_capacity(), tranches(1)); assert_equal( result.get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2020, - num_tranches: 1, + capacity: tranches(1), }], ); } #[rstest] fn with_decommission_mothballed_all(commissioned_multi_tranche: AssetRef) { - // All tranches mothballed long enough ago: the whole asset is decommissioned - let asset = commissioned_multi_tranche.with_mothballed_tranches(3, Some(2010)); + // All capacity mothballed long enough ago: the whole asset is decommissioned + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(3), Some(2010)); assert!(asset.with_decommission_mothballed(2025, 10).is_none()); } } diff --git a/src/asset/capacity.rs b/src/asset/capacity.rs index 5d8a8beb8..8243dca3a 100644 --- a/src/asset/capacity.rs +++ b/src/asset/capacity.rs @@ -3,6 +3,9 @@ use crate::units::{Capacity, Dimensionless}; use std::cmp::Ordering; use std::ops::{Add, Sub}; +/// Continuous capacities with an absolute value within this tolerance are considered to be zero +const CONTINUOUS_ZERO_TOLERANCE: Capacity = Capacity(1e-9); + /// Capacity of an asset, which may be continuous or a number of discrete tranches of a given size. #[derive(Clone, PartialEq, Copy, Debug)] pub enum AssetCapacity { @@ -59,41 +62,43 @@ impl AssetCapacity { } } - /// Returns the number of tranches in this `AssetCapacity`. - /// - /// A continuous capacity is a single tranche. - pub fn num_tranches(&self) -> u32 { + /// Returns the number of tranches if this capacity is discrete, or `None` if continuous. + pub fn num_tranches(&self) -> Option { match self { - AssetCapacity::Continuous(_) => 1, - AssetCapacity::Discrete { num_tranches, .. } => *num_tranches, + AssetCapacity::Continuous(_) => None, + AssetCapacity::Discrete { num_tranches, .. } => Some(*num_tranches), } } - /// Returns the tranche size of this `AssetCapacity`. + /// Returns a capacity consisting of a single tranche of the same size. /// - /// For a continuous capacity, this is the full capacity. - pub fn tranche_size(&self) -> Capacity { + /// A continuous capacity is already treated as a single indivisible tranche, so is returned + /// unchanged. + pub fn single_tranche(self) -> Self { match self { - AssetCapacity::Continuous(capacity) => *capacity, - AssetCapacity::Discrete { tranche_size, .. } => *tranche_size, + AssetCapacity::Continuous(_) => self, + AssetCapacity::Discrete { tranche_size, .. } => Self::single(tranche_size), + } + } + /// Returns a zero capacity of the same kind (and tranche size) as this one. + pub fn zero(&self) -> Self { + match self { + AssetCapacity::Continuous(_) => AssetCapacity::Continuous(Capacity(0.0)), + AssetCapacity::Discrete { tranche_size, .. } => AssetCapacity::Discrete { + num_tranches: 0, + tranche_size: *tranche_size, + }, } } - /// Returns a copy of this `AssetCapacity` with the given number of tranches. - /// - /// # Panics + /// Whether this capacity is zero. /// - /// Panics if this capacity is continuous and `num_tranches` is not one. - pub fn with_num_tranches(self, num_tranches: u32) -> Self { + /// Continuous capacities are treated as zero if they are within a small tolerance of zero, to + /// allow for floating-point error (e.g. after subtracting almost-equal capacities). + pub fn is_zero(&self) -> bool { match self { - AssetCapacity::Continuous(_) => { - assert_eq!( - num_tranches, 1, - "Cannot change the number of tranches of a continuous capacity" - ); - self - } - AssetCapacity::Discrete { tranche_size, .. } => Self::new(num_tranches, tranche_size), + AssetCapacity::Continuous(capacity) => capacity.abs() <= CONTINUOUS_ZERO_TOLERANCE, + AssetCapacity::Discrete { num_tranches, .. } => *num_tranches == 0, } } @@ -217,8 +222,7 @@ mod tests { ) { let capacity = AssetCapacity::new(num_tranches, tranche_size); - assert_eq!(capacity.num_tranches(), num_tranches); - assert_eq!(capacity.tranche_size(), tranche_size); + assert_eq!(capacity.num_tranches(), Some(num_tranches)); assert_eq!(capacity.total_capacity(), expected_total_capacity); } diff --git a/src/asset/pool.rs b/src/asset/pool.rs index 741897513..7584de111 100644 --- a/src/asset/pool.rs +++ b/src/asset/pool.rs @@ -99,19 +99,18 @@ impl AssetPool { .iter_mut() .find(|asset| asset.id().unwrap() == id) { - // At least some of the asset's tranches have made it back into the pool. Increase the - // capacity back to what it was before, with the unselected tranches set as mothballed. - let num_mothballed = old_asset - .num_tranches() - .checked_sub(new_asset.num_tranches()) - .expect("Number of tranches has increased"); - *new_asset = old_asset.with_mothballed_tranches(num_mothballed, Some(year)); + // At least some of the asset's capacity has made it back into the pool. Increase the + // capacity back to what it was before, with the unselected capacity set as mothballed. + let mothballed = old_asset.capacity() - new_asset.capacity(); + *new_asset = old_asset.with_mothballed_capacity(mothballed, Some(year)); } else { - // None of this asset's tranches were selected. We mothball _all_ tranches and return to + // None of this asset's capacity was selected. We mothball _all_ of it and return to // the pool. - let num_mothballed = old_asset.num_tranches(); - self.assets - .push(old_asset.with_mothballed_tranches(num_mothballed, Some(year))); + self.assets.push( + old_asset + .clone() + .with_mothballed_capacity(old_asset.capacity(), Some(year)), + ); } } self.assets.sort(); @@ -532,7 +531,7 @@ mod tests { asset_pool.assets[0].get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2025, - num_tranches: 1, + capacity: AssetCapacity::single(Capacity(1.0)), }], ); } @@ -546,15 +545,16 @@ mod tests { // Make an asset unused for a few years let mothball_years: u32 = 10; - asset_pool.assets[0] = asset_pool[0] - .clone() - .with_mothballed_tranches(1, Some(2025 - mothball_years)); + asset_pool.assets[0] = asset_pool[0].clone().with_mothballed_capacity( + AssetCapacity::single(Capacity(1.0)), + Some(2025 - mothball_years), + ); assert_equal( asset_pool.assets[0].get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2025 - mothball_years, - num_tranches: 1, + capacity: AssetCapacity::single(Capacity(1.0)), }], ); @@ -585,7 +585,7 @@ mod tests { asset_pool.assets[0].get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2025, - num_tranches: 1, + capacity: AssetCapacity::single(Capacity(1.0)), }], ); assert_eq!(asset_pool.assets[1].id(), all_assets[1].id()); @@ -593,7 +593,7 @@ mod tests { asset_pool.assets[1].get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2025, - num_tranches: 1, + capacity: AssetCapacity::single(Capacity(1.0)), }], ); } @@ -643,12 +643,15 @@ mod tests { assert_eq!(asset_pool.assets.len(), 1); let asset = &asset_pool.assets[0]; assert_eq!(asset.num_tranches(), 3); - assert_eq!(asset.get_num_mothballed_tranches(), 1); + assert_eq!( + asset.mothballed_capacity(), + AssetCapacity::new(1, Capacity(4.0)) + ); assert_equal( asset.get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2025, - num_tranches: 1, + capacity: AssetCapacity::new(1, Capacity(4.0)), }], ); } @@ -657,8 +660,8 @@ mod tests { fn asset_pool_decommission_mothballed_partial(commissioned_multi_tranche: AssetRef) { // Mothball one tranche in 2010 and one in 2020, leaving one active let asset = commissioned_multi_tranche - .with_mothballed_tranches(1, Some(2010)) - .with_mothballed_tranches(2, Some(2020)); + .with_mothballed_capacity(AssetCapacity::new(1, Capacity(4.0)), Some(2010)) + .with_mothballed_capacity(AssetCapacity::new(2, Capacity(4.0)), Some(2020)); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(asset); @@ -669,12 +672,15 @@ mod tests { assert_eq!(asset_pool.assets.len(), 1); let asset = &asset_pool.assets[0]; assert_eq!(asset.num_tranches(), 2); - assert_eq!(asset.get_num_mothballed_tranches(), 1); + assert_eq!( + asset.mothballed_capacity(), + AssetCapacity::new(1, Capacity(4.0)) + ); assert_equal( asset.get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2020, - num_tranches: 1, + capacity: AssetCapacity::new(1, Capacity(4.0)), }], ); } @@ -684,7 +690,8 @@ mod tests { commissioned_multi_tranche: AssetRef, ) { // All three tranches mothballed long enough ago: the whole asset is removed from the pool - let asset = commissioned_multi_tranche.with_mothballed_tranches(3, Some(2010)); + let asset = commissioned_multi_tranche + .with_mothballed_capacity(AssetCapacity::new(3, Capacity(4.0)), Some(2010)); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(asset); diff --git a/src/output.rs b/src/output.rs index d8aea2b79..819dfe15e 100644 --- a/src/output.rs +++ b/src/output.rs @@ -160,9 +160,11 @@ struct AssetCapacityRow { milestone_year: u32, asset_id: AssetID, capacity: Capacity, - num_tranches: u32, + /// Empty if the capacity is continuous + num_tranches: Option, mothballed_capacity: Capacity, - mothballed_tranches: u32, + /// Empty if the capacity is continuous + mothballed_tranches: Option, } /// Represents the flow-related data in a row of the commodity flows CSV file. @@ -586,8 +588,8 @@ impl DataWriter { asset_id: asset.id().unwrap(), capacity: asset.total_capacity(), num_tranches: asset.capacity().num_tranches(), - mothballed_capacity: asset.mothballed_capacity(), - mothballed_tranches: asset.get_num_mothballed_tranches(), + mothballed_capacity: asset.mothballed_capacity().total_capacity(), + mothballed_tranches: asset.mothballed_capacity().num_tranches(), }; self.asset_capacities.serialize(row)?; } @@ -658,7 +660,7 @@ impl DataWriter { #[cfg(test)] mod tests { use super::*; - use crate::asset::AssetPool; + use crate::asset::{AssetCapacity, AssetPool}; use crate::fixture::{assets, commodity_id, multi_tranche_asset, region_id, time_slice}; use crate::time_slice::TimeSliceID; use indexmap::indexmap; @@ -709,9 +711,9 @@ mod tests { milestone_year, asset_id: asset.id().unwrap(), capacity: asset.total_capacity(), - num_tranches: 1, + num_tranches: Some(1), mothballed_capacity: Capacity(0.0), - mothballed_tranches: 0, + mothballed_tranches: Some(0), }; let records: Vec = csv::Reader::from_path(dir.path().join(ASSET_CAPACITIES_FILE_NAME)) @@ -723,7 +725,7 @@ mod tests { } #[rstest] - fn write_asset_capacities_with_mothballed_tranches(multi_tranche_asset: Asset) { + fn write_asset_capacities_with_mothballed_capacity(multi_tranche_asset: Asset) { let milestone_year = 2020; let dir = tempdir().unwrap(); let mut assets = AssetPool::new(); @@ -733,7 +735,7 @@ mod tests { .next() .unwrap() .clone() - .with_mothballed_tranches(1, Some(milestone_year)); + .with_mothballed_capacity(AssetCapacity::new(1, Capacity(4.0)), Some(milestone_year)); { let mut writer = DataWriter::create(dir.path(), dir.path(), false).unwrap(); @@ -747,9 +749,9 @@ mod tests { milestone_year, asset_id: asset.id().unwrap(), capacity: Capacity(12.0), - num_tranches: 3, + num_tranches: Some(3), mothballed_capacity: Capacity(4.0), - mothballed_tranches: 1, + mothballed_tranches: Some(1), }; let records: Vec = csv::Reader::from_path(dir.path().join(ASSET_CAPACITIES_FILE_NAME)) diff --git a/src/simulation/investment/competitive.rs b/src/simulation/investment/competitive.rs index 2f3e470ed..866bdb367 100644 --- a/src/simulation/investment/competitive.rs +++ b/src/simulation/investment/competitive.rs @@ -507,7 +507,7 @@ fn record_asset_selection(best_asset: AssetRef, best_assets: &mut Vec) .increase_capacity(best_asset.capacity()); } else { // Otherwise add it to the list of best assets. Selected assets are unmothballed. - best_assets.push(best_asset.with_no_mothballed_tranches()); + best_assets.push(best_asset.with_no_mothballed_capacity()); } } @@ -595,7 +595,7 @@ mod tests { Asset::new_candidate( Arc::new(asset.process().clone()), asset.region_id().clone(), - asset.capacity().tranche_size(), + asset.total_capacity(), asset.commission_year(), ) .unwrap() From f2b3628e45adabbe8ce059fb3c46bf7cee76ce20 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 29 Oct 2026 13:36:43 +0000 Subject: [PATCH 3/6] Allow existing assets to have continuous capacities --- docs/model/investment.md | 9 ++-- schemas/input/assets.yaml | 4 +- schemas/input/processes.yaml | 2 +- src/input/asset.rs | 30 ++++++++++-- tests/data/circularity/asset_capacities.csv | 48 +++++++++---------- .../data/circularity_npv/asset_capacities.csv | 48 +++++++++---------- .../missing_commodity/asset_capacities.csv | 24 +++++----- tests/data/muse1_default/asset_capacities.csv | 4 +- tests/data/simple/asset_capacities.csv | 26 +++++----- tests/data/simple_full/asset_capacities.csv | 26 +++++----- .../simple_ironing_out/asset_capacities.csv | 26 +++++----- .../data/simple_marginal/asset_capacities.csv | 26 +++++----- .../asset_capacities.csv | 26 +++++----- .../data/simple_mothball/asset_capacities.csv | 30 ++++++------ tests/data/simple_npv/asset_capacities.csv | 26 +++++----- tests/data/simple_shadow/asset_capacities.csv | 26 +++++----- .../simple_tranche_size/asset_capacities.csv | 28 +++++------ tests/data/two_outputs/asset_capacities.csv | 46 +++++++++--------- tests/data/two_regions/asset_capacities.csv | 32 ++++++------- 19 files changed, 256 insertions(+), 231 deletions(-) diff --git a/docs/model/investment.md b/docs/model/investment.md index 36b991b72..193fb8959 100644 --- a/docs/model/investment.md +++ b/docs/model/investment.md @@ -123,13 +123,14 @@ The annualised fixed cost (AFC) per unit of capacity differs between the two cat ## Asset Capacities -Every asset consists of one or more equal-capacity tranches. A single-tranche asset is retained or -mothballed as a whole, while the tranches of a multi-tranche asset can be retained or mothballed -independently (see [Mothballing and Decommissioning](#mothballing-and-decommissioning)). +Every asset consists of either a continuous capacity or one or more equal-capacity tranches. A +continuous-capacity asset, like a single-tranche asset, is retained or mothballed as a whole, while +the tranches of a multi-tranche asset can be retained or mothballed independently (see +[Mothballing and Decommissioning](#mothballing-and-decommissioning)). - For assets defined in `assets.csv`, an explicitly supplied `num_tranches` determines the tranche size. Otherwise, a process `tranche_size` determines the tranche size. If neither is supplied, the asset - consists of one tranche with its full capacity. + has continuous capacity. - Assets invested in _by MUSE_ are commissioned one tranche at a time, using the process `tranche_size` if defined, or a tranche size inferred from demand at the time of investment (see "trial capacity" below). diff --git a/schemas/input/assets.yaml b/schemas/input/assets.yaml index 805a21c62..a394285e9 100644 --- a/schemas/input/assets.yaml +++ b/schemas/input/assets.yaml @@ -23,8 +23,8 @@ fields: `capacity / num_tranches` (taking precedence over the process's `tranche_size`). If omitted and the process has a `tranche_size`, the asset is divided into `ceil(capacity / tranche_size)` tranches (note that rounding up the number of tranches can result in a total capacity greater than the - specified capacity). If both values are omitted, the asset is represented as one tranche with - the full asset capacity. Must be >0 if provided. + specified capacity). If both values are omitted, the asset has continuous capacity (it is not + divided into tranches). Must be >0 if provided. - name: commission_year type: integer description: The year in which to commission this asset diff --git a/schemas/input/processes.yaml b/schemas/input/processes.yaml index 2fb31e1cc..7375858b3 100644 --- a/schemas/input/processes.yaml +++ b/schemas/input/processes.yaml @@ -39,4 +39,4 @@ fields: Optional (defaults to None). If present, must be >0. If no tranche size is specified, the tranche size for a MUSE2 investment is inferred from demand at the time of investment and `capacity_tranche_fraction`. For a user-provided asset, `num_tranches` in `assets.csv` can instead - determine the tranche size. + determine the tranche size; if neither is given, the asset has continuous capacity. diff --git a/src/input/asset.rs b/src/input/asset.rs index ee6884ec3..de3ae1062 100644 --- a/src/input/asset.rs +++ b/src/input/asset.rs @@ -139,9 +139,8 @@ where #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] AssetCapacity::new(num_tranches as u32, tranche_size) } else { - // Without a process tranche_size, lack of num_tranches implies the asset is indivisible - // (consists of a single tranche). - AssetCapacity::single(asset.capacity) + // Without a process tranche_size or num_tranches, capacity is continuous + AssetCapacity::continuous(asset.capacity) }; UserAsset::new( @@ -226,6 +225,31 @@ mod tests { assert_eq!(assets[0].capacity(), AssetCapacity::new(3, Capacity(2.0))); } + #[rstest] + fn missing_tranche_count_and_size_gives_continuous_capacity( + agent_ids: IndexSet, + processes: ProcessMap, + region_ids: IndexSet, + ) { + let asset = AssetRaw { + process_id: "process1".into(), + region_id: "GBR".into(), + agent_id: "agent1".into(), + capacity: Capacity(6.0), + num_tranches: None, + commission_year: 2010, + max_decommission_year: None, + }; + + let assets = + read_assets_from_iter(iter::once(asset), &agent_ids, &processes, ®ion_ids).unwrap(); + + assert_eq!( + assets[0].capacity(), + AssetCapacity::continuous(Capacity(6.0)) + ); + } + #[rstest] fn missing_tranche_count_uses_process_tranche_size( agent_ids: IndexSet, diff --git a/tests/data/circularity/asset_capacities.csv b/tests/data/circularity/asset_capacities.csv index 4439155ec..3f8e2af23 100644 --- a/tests/data/circularity/asset_capacities.csv +++ b/tests/data/circularity/asset_capacities.csv @@ -1,26 +1,26 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4010.2,1,0.0,0 -2020,1,1738.05,1,0.0,0 -2020,2,3789.64,1,0.0,0 -2020,3,716.5,1,0.0,0 -2020,4,573.9,1,0.0,0 -2020,5,3.964844,1,0.0,0 -2020,6,2.999,1,0.0,0 -2020,7,1.0,1,0.0,0 -2020,8,31.54,1,0.0,0 -2020,9,451.91,1,0.0,0 -2020,10,176.58,1,0.0,0 -2020,11,20.99,1,0.0,0 -2020,12,15.0,1,0.0,0 -2020,13,2900.0,1,0.0,0 -2020,14,399.98,1,0.0,0 -2030,0,4010.2,1,0.0,0 -2030,1,1738.05,1,0.0,0 -2030,2,3789.64,1,0.0,0 -2030,5,3.964844,1,0.0,0 -2030,6,2.999,1,0.0,0 -2030,13,2900.0,1,0.0,0 -2030,14,399.98,1,0.0,0 +2020,0,4010.2,,0.0, +2020,1,1738.05,,0.0, +2020,2,3789.64,,0.0, +2020,3,716.5,,0.0, +2020,4,573.9,,0.0, +2020,5,3.964844,,0.0, +2020,6,2.999,,0.0, +2020,7,1.0,,0.0, +2020,8,31.54,,0.0, +2020,9,451.91,,0.0, +2020,10,176.58,,0.0, +2020,11,20.99,,0.0, +2020,12,15.0,,0.0, +2020,13,2900.0,,0.0, +2020,14,399.98,,0.0, +2030,0,4010.2,,0.0, +2030,1,1738.05,,0.0, +2030,2,3789.64,,0.0, +2030,5,3.964844,,0.0, +2030,6,2.999,,0.0, +2030,13,2900.0,,0.0, +2030,14,399.98,,0.0, 2030,15,773.9000519043998,27,0.0,0 2030,16,28.662964885348142,1,0.0,0 2030,17,1849.28399075358,36,0.0,0 @@ -31,8 +31,8 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tra 2030,22,866.580539109003,35,0.0,0 2030,23,2796.9391905078573,16,0.0,0 2030,24,56.74510556013457,27,0.0,0 -2040,5,3.964844,1,0.0,0 -2040,6,2.999,1,0.0,0 +2040,5,3.964844,,0.0, +2040,6,2.999,,0.0, 2040,17,1849.28399075358,36,0.0,0 2040,18,5.071225947945551,13,0.0,0 2040,19,181.91149330325948,8,0.0,0 diff --git a/tests/data/circularity_npv/asset_capacities.csv b/tests/data/circularity_npv/asset_capacities.csv index 4439155ec..3f8e2af23 100644 --- a/tests/data/circularity_npv/asset_capacities.csv +++ b/tests/data/circularity_npv/asset_capacities.csv @@ -1,26 +1,26 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4010.2,1,0.0,0 -2020,1,1738.05,1,0.0,0 -2020,2,3789.64,1,0.0,0 -2020,3,716.5,1,0.0,0 -2020,4,573.9,1,0.0,0 -2020,5,3.964844,1,0.0,0 -2020,6,2.999,1,0.0,0 -2020,7,1.0,1,0.0,0 -2020,8,31.54,1,0.0,0 -2020,9,451.91,1,0.0,0 -2020,10,176.58,1,0.0,0 -2020,11,20.99,1,0.0,0 -2020,12,15.0,1,0.0,0 -2020,13,2900.0,1,0.0,0 -2020,14,399.98,1,0.0,0 -2030,0,4010.2,1,0.0,0 -2030,1,1738.05,1,0.0,0 -2030,2,3789.64,1,0.0,0 -2030,5,3.964844,1,0.0,0 -2030,6,2.999,1,0.0,0 -2030,13,2900.0,1,0.0,0 -2030,14,399.98,1,0.0,0 +2020,0,4010.2,,0.0, +2020,1,1738.05,,0.0, +2020,2,3789.64,,0.0, +2020,3,716.5,,0.0, +2020,4,573.9,,0.0, +2020,5,3.964844,,0.0, +2020,6,2.999,,0.0, +2020,7,1.0,,0.0, +2020,8,31.54,,0.0, +2020,9,451.91,,0.0, +2020,10,176.58,,0.0, +2020,11,20.99,,0.0, +2020,12,15.0,,0.0, +2020,13,2900.0,,0.0, +2020,14,399.98,,0.0, +2030,0,4010.2,,0.0, +2030,1,1738.05,,0.0, +2030,2,3789.64,,0.0, +2030,5,3.964844,,0.0, +2030,6,2.999,,0.0, +2030,13,2900.0,,0.0, +2030,14,399.98,,0.0, 2030,15,773.9000519043998,27,0.0,0 2030,16,28.662964885348142,1,0.0,0 2030,17,1849.28399075358,36,0.0,0 @@ -31,8 +31,8 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tra 2030,22,866.580539109003,35,0.0,0 2030,23,2796.9391905078573,16,0.0,0 2030,24,56.74510556013457,27,0.0,0 -2040,5,3.964844,1,0.0,0 -2040,6,2.999,1,0.0,0 +2040,5,3.964844,,0.0, +2040,6,2.999,,0.0, 2040,17,1849.28399075358,36,0.0,0 2040,18,5.071225947945551,13,0.0,0 2040,19,181.91149330325948,8,0.0,0 diff --git a/tests/data/missing_commodity/asset_capacities.csv b/tests/data/missing_commodity/asset_capacities.csv index 88aa4959b..e69b65945 100644 --- a/tests/data/missing_commodity/asset_capacities.csv +++ b/tests/data/missing_commodity/asset_capacities.csv @@ -1,16 +1,16 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,3,2.43,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,3,2.43,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,1849.28399075358,36,0.0,0 2030,7,4.582296913025608,13,0.0,0 2030,8,178.91233238712087,66,0.0,0 diff --git a/tests/data/muse1_default/asset_capacities.csv b/tests/data/muse1_default/asset_capacities.csv index 1c1a659db..7549de2c7 100644 --- a/tests/data/muse1_default/asset_capacities.csv +++ b/tests/data/muse1_default/asset_capacities.csv @@ -1,6 +1,6 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,24.0,1,0.0,0 -2020,1,19.0,1,0.0,0 +2020,0,24.0,,0.0, +2020,1,19.0,,0.0, 2025,2,23.93995212009576,36,0.0,0 2025,3,13.2999734000532,20,0.0,0 2025,4,0.5911099288912534,2,0.0,0 diff --git a/tests/data/simple/asset_capacities.csv b/tests/data/simple/asset_capacities.csv index 6916ed0c0..a0b1f003d 100644 --- a/tests/data/simple/asset_capacities.csv +++ b/tests/data/simple/asset_capacities.csv @@ -1,19 +1,19 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, 2040,6,359.58299820208504,7,0.0,0 2040,9,3663.984981680075,65,0.0,0 diff --git a/tests/data/simple_full/asset_capacities.csv b/tests/data/simple_full/asset_capacities.csv index 6916ed0c0..a0b1f003d 100644 --- a/tests/data/simple_full/asset_capacities.csv +++ b/tests/data/simple_full/asset_capacities.csv @@ -1,19 +1,19 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, 2040,6,359.58299820208504,7,0.0,0 2040,9,3663.984981680075,65,0.0,0 diff --git a/tests/data/simple_ironing_out/asset_capacities.csv b/tests/data/simple_ironing_out/asset_capacities.csv index 6916ed0c0..a0b1f003d 100644 --- a/tests/data/simple_ironing_out/asset_capacities.csv +++ b/tests/data/simple_ironing_out/asset_capacities.csv @@ -1,19 +1,19 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, 2040,6,359.58299820208504,7,0.0,0 2040,9,3663.984981680075,65,0.0,0 diff --git a/tests/data/simple_marginal/asset_capacities.csv b/tests/data/simple_marginal/asset_capacities.csv index 6916ed0c0..a0b1f003d 100644 --- a/tests/data/simple_marginal/asset_capacities.csv +++ b/tests/data/simple_marginal/asset_capacities.csv @@ -1,19 +1,19 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, 2040,6,359.58299820208504,7,0.0,0 2040,9,3663.984981680075,65,0.0,0 diff --git a/tests/data/simple_marginal_average/asset_capacities.csv b/tests/data/simple_marginal_average/asset_capacities.csv index 6916ed0c0..a0b1f003d 100644 --- a/tests/data/simple_marginal_average/asset_capacities.csv +++ b/tests/data/simple_marginal_average/asset_capacities.csv @@ -1,19 +1,19 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, 2040,6,359.58299820208504,7,0.0,0 2040,9,3663.984981680075,65,0.0,0 diff --git a/tests/data/simple_mothball/asset_capacities.csv b/tests/data/simple_mothball/asset_capacities.csv index 6a7d54773..978cbabcd 100644 --- a/tests/data/simple_mothball/asset_capacities.csv +++ b/tests/data/simple_mothball/asset_capacities.csv @@ -1,22 +1,22 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,3,2.43,1,2.43,1 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,3,2.43,,2.43, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 -2040,2,3.964844,1,3.964844,1 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, +2040,2,3.964844,,3.964844, 2040,6,359.58299820208504,7,0.0,0 2040,7,4.541646703508871,16,4.541646703508871,16 2040,8,0.3784705586007786,3,0.3784705586007786,3 diff --git a/tests/data/simple_npv/asset_capacities.csv b/tests/data/simple_npv/asset_capacities.csv index 6916ed0c0..a0b1f003d 100644 --- a/tests/data/simple_npv/asset_capacities.csv +++ b/tests/data/simple_npv/asset_capacities.csv @@ -1,19 +1,19 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, 2040,6,359.58299820208504,7,0.0,0 2040,9,3663.984981680075,65,0.0,0 diff --git a/tests/data/simple_shadow/asset_capacities.csv b/tests/data/simple_shadow/asset_capacities.csv index 6916ed0c0..a0b1f003d 100644 --- a/tests/data/simple_shadow/asset_capacities.csv +++ b/tests/data/simple_shadow/asset_capacities.csv @@ -1,19 +1,19 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 -2020,4,2900.0,1,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,4,2900.0,1,0.0,0 -2030,5,399.98,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, +2020,4,2900.0,,0.0, +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,4,2900.0,,0.0, +2030,5,399.98,,0.0, 2030,6,359.58299820208504,7,0.0,0 2030,7,4.541646703508871,16,0.0,0 2030,8,0.3784705586007786,3,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, 2040,6,359.58299820208504,7,0.0,0 2040,9,3663.984981680075,65,0.0,0 diff --git a/tests/data/simple_tranche_size/asset_capacities.csv b/tests/data/simple_tranche_size/asset_capacities.csv index 313171c64..8843489fb 100644 --- a/tests/data/simple_tranche_size/asset_capacities.csv +++ b/tests/data/simple_tranche_size/asset_capacities.csv @@ -1,22 +1,22 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4002.26,1,0.0,0 -2020,1,3782.13,1,0.0,0 -2020,2,3.964844,1,0.0,0 -2020,3,2.43,1,0.0,0 +2020,0,4002.26,,0.0, +2020,1,3782.13,,0.0, +2020,2,3.964844,,0.0, +2020,3,2.43,,0.0, 2020,4,3000.0,3,0.0,0 -2020,5,399.98,1,0.0,0 -2030,0,4002.26,1,0.0,0 -2030,1,3782.13,1,0.0,0 -2030,2,3.964844,1,0.0,0 -2030,3,2.43,1,0.0,0 +2020,5,399.98,,0.0, +2030,0,4002.26,,0.0, +2030,1,3782.13,,0.0, +2030,2,3.964844,,0.0, +2030,3,2.43,,0.0, 2030,4,3000.0,3,0.0,0 -2030,5,399.98,1,0.0,0 +2030,5,399.98,,0.0, 2030,6,256.844998715775,5,0.0,0 2030,7,9.977075345826824,17,0.0,0 -2040,0,4002.26,1,0.0,0 -2040,1,3782.13,1,0.0,0 -2040,2,3.964844,1,0.0,0 -2040,3,2.43,1,0.0,0 +2040,0,4002.26,,0.0, +2040,1,3782.13,,0.0, +2040,2,3.964844,,0.0, +2040,3,2.43,,0.0, 2040,6,256.844998715775,5,0.0,0 2040,7,9.977075345826824,17,0.0,0 2040,8,845.534995772325,15,0.0,0 diff --git a/tests/data/two_outputs/asset_capacities.csv b/tests/data/two_outputs/asset_capacities.csv index 96972a077..1d54a24c8 100644 --- a/tests/data/two_outputs/asset_capacities.csv +++ b/tests/data/two_outputs/asset_capacities.csv @@ -1,24 +1,24 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,4010.2,1,0.0,0 -2020,1,1738.05,1,0.0,0 -2020,2,3789.64,1,0.0,0 -2020,3,716.5,1,0.0,0 -2020,4,573.9,1,0.0,0 -2020,5,3.964844,1,0.0,0 -2020,6,2.999,1,0.0,0 -2020,7,451.91,1,0.0,0 -2020,8,176.58,1,0.0,0 -2020,9,20.99,1,0.0,0 -2020,10,15.0,1,0.0,0 -2020,11,2900.0,1,0.0,0 -2020,12,399.98,1,0.0,0 -2030,0,4010.2,1,0.0,0 -2030,1,1738.05,1,0.0,0 -2030,2,3789.64,1,0.0,0 -2030,5,3.964844,1,0.0,0 -2030,6,2.999,1,0.0,0 -2030,11,2900.0,1,0.0,0 -2030,12,399.98,1,0.0,0 +2020,0,4010.2,,0.0, +2020,1,1738.05,,0.0, +2020,2,3789.64,,0.0, +2020,3,716.5,,0.0, +2020,4,573.9,,0.0, +2020,5,3.964844,,0.0, +2020,6,2.999,,0.0, +2020,7,451.91,,0.0, +2020,8,176.58,,0.0, +2020,9,20.99,,0.0, +2020,10,15.0,,0.0, +2020,11,2900.0,,0.0, +2020,12,399.98,,0.0, +2030,0,4010.2,,0.0, +2030,1,1738.05,,0.0, +2030,2,3789.64,,0.0, +2030,5,3.964844,,0.0, +2030,6,2.999,,0.0, +2030,11,2900.0,,0.0, +2030,12,399.98,,0.0, 2030,13,802.563016789748,28,0.0,0 2030,14,1849.28399075358,36,0.0,0 2030,15,40.09093125430712,19,0.0,0 @@ -27,9 +27,9 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tra 2030,18,828.0412077989463,26,0.0,0 2030,19,298.6475820926018,57,0.0,0 2030,20,317.91569722947594,72,0.0,0 -2040,1,1738.05,1,0.0,0 -2040,5,3.964844,1,0.0,0 -2040,6,2.999,1,0.0,0 +2040,1,1738.05,,0.0, +2040,5,3.964844,,0.0, +2040,6,2.999,,0.0, 2040,14,1849.28399075358,36,0.0,0 2040,15,40.09093125430712,19,0.0,0 2040,16,9.377995614424108,10,0.0,0 diff --git a/tests/data/two_regions/asset_capacities.csv b/tests/data/two_regions/asset_capacities.csv index 33ed007fa..325889d7b 100644 --- a/tests/data/two_regions/asset_capacities.csv +++ b/tests/data/two_regions/asset_capacities.csv @@ -1,17 +1,17 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tranches -2020,0,24.0,1,0.0,0 -2020,1,19.0,1,0.0,0 -2020,2,24.0,1,0.0,0 -2020,3,19.0,1,0.0,0 -2025,2,24.0,1,0.0,0 -2025,3,19.0,1,0.0,0 +2020,0,24.0,,0.0, +2020,1,19.0,,0.0, +2020,2,24.0,,0.0, +2020,3,19.0,,0.0, +2025,2,24.0,,0.0, +2025,3,19.0,,0.0, 2025,4,23.93995212009576,36,0.0,0 2025,5,5.31998936002128,8,0.0,0 2025,6,13.2999734000532,20,0.0,0 2025,7,0.5911099288912534,2,0.0,0 2025,8,4.285546984461586,5,0.0,0 -2030,2,24.0,1,0.0,0 -2030,3,19.0,1,0.0,0 +2030,2,24.0,,0.0, +2030,3,19.0,,0.0, 2030,4,23.93995212009576,36,0.0,0 2030,5,5.31998936002128,8,0.0,0 2030,6,13.2999734000532,20,0.0,0 @@ -20,8 +20,8 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tra 2030,10,5.80998838002324,7,0.0,0 2030,11,4.1499917000166,5,0.0,0 2030,12,7.48842946758551,7,0.0,0 -2035,2,24.0,1,0.0,0 -2035,3,19.0,1,0.0,0 +2035,2,24.0,,0.0, +2035,3,19.0,,0.0, 2035,4,23.93995212009576,36,0.0,0 2035,5,5.31998936002128,8,0.0,0 2035,6,13.2999734000532,20,0.0,0 @@ -34,8 +34,8 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tra 2035,14,5.999988000024,6,0.0,0 2035,15,3.999992000016,4,0.0,0 2035,16,7.733317866697599,6,0.0,0 -2040,2,24.0,1,0.0,0 -2040,3,19.0,1,0.0,0 +2040,2,24.0,,0.0, +2040,3,19.0,,0.0, 2040,4,23.93995212009576,36,0.0,0 2040,5,5.31998936002128,8,0.0,0 2040,6,13.2999734000532,20,0.0,0 @@ -52,8 +52,8 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tra 2040,18,5.8249883500233,5,0.0,0 2040,19,3.49499301001398,3,0.0,0 2040,20,7.507762762252252,5,0.0,0 -2045,2,24.0,1,0.0,0 -2045,3,19.0,1,0.0,0 +2045,2,24.0,,0.0, +2045,3,19.0,,0.0, 2045,4,23.93995212009576,36,0.0,0 2045,5,5.31998936002128,8,0.0,0 2045,6,13.2999734000532,20,0.0,0 @@ -74,8 +74,8 @@ milestone_year,asset_id,capacity,num_tranches,mothballed_capacity,mothballed_tra 2045,22,6.6499867000266,5,0.0,0 2045,23,3.98999202001596,3,0.0,0 2045,24,6.856875175138538,4,0.0,0 -2050,2,24.0,1,0.0,0 -2050,3,19.0,1,0.0,0 +2050,2,24.0,,0.0, +2050,3,19.0,,0.0, 2050,4,23.93995212009576,36,0.0,0 2050,5,5.31998936002128,8,0.0,0 2050,6,13.2999734000532,20,0.0,0 From cb03154a0a0117a2c487d0d89a79466de2ae8dbb Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 9 Nov 2026 16:00:46 +0000 Subject: [PATCH 4/6] Small improvements from self-review --- src/asset.rs | 2 +- src/asset/capacity.rs | 46 +++++++++++++++++++++---------------------- src/asset/pool.rs | 16 +++++++-------- src/fixture.rs | 2 +- src/input/asset.rs | 14 +++++++++---- src/output.rs | 5 ++++- 6 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 5877f7a20..1238eaddc 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1248,7 +1248,7 @@ mod tests { /// Capacity made up of `num_tranches` tranches of the same size as the multi-tranche asset fn tranches(num_tranches: u32) -> AssetCapacity { - AssetCapacity::new(num_tranches, Capacity(4.0)) + AssetCapacity::discrete(num_tranches, Capacity(4.0)) } #[rstest] diff --git a/src/asset/capacity.rs b/src/asset/capacity.rs index 8243dca3a..c3474c334 100644 --- a/src/asset/capacity.rs +++ b/src/asset/capacity.rs @@ -3,9 +3,6 @@ use crate::units::{Capacity, Dimensionless}; use std::cmp::Ordering; use std::ops::{Add, Sub}; -/// Continuous capacities with an absolute value within this tolerance are considered to be zero -const CONTINUOUS_ZERO_TOLERANCE: Capacity = Capacity(1e-9); - /// Capacity of an asset, which may be continuous or a number of discrete tranches of a given size. #[derive(Clone, PartialEq, Copy, Debug)] pub enum AssetCapacity { @@ -31,7 +28,7 @@ impl AssetCapacity { } /// Create a new discrete `AssetCapacity` with the given number of tranches and tranche size - pub fn new(num_tranches: u32, tranche_size: Capacity) -> Self { + pub fn discrete(num_tranches: u32, tranche_size: Capacity) -> Self { assert!( tranche_size.is_finite() && tranche_size >= Capacity(0.0), "Tranche size must be a finite non-negative number" @@ -44,7 +41,7 @@ impl AssetCapacity { /// Create a new discrete `AssetCapacity` with a single tranche of the given size pub fn single(tranche_size: Capacity) -> Self { - Self::new(1, tranche_size) + Self::discrete(1, tranche_size) } /// Return the smaller of `self` or `other`. @@ -80,6 +77,7 @@ impl AssetCapacity { AssetCapacity::Discrete { tranche_size, .. } => Self::single(tranche_size), } } + /// Returns a zero capacity of the same kind (and tranche size) as this one. pub fn zero(&self) -> Self { match self { @@ -97,7 +95,7 @@ impl AssetCapacity { /// allow for floating-point error (e.g. after subtracting almost-equal capacities). pub fn is_zero(&self) -> bool { match self { - AssetCapacity::Continuous(capacity) => capacity.abs() <= CONTINUOUS_ZERO_TOLERANCE, + AssetCapacity::Continuous(capacity) => capacity.abs() <= Capacity(1e-9), AssetCapacity::Discrete { num_tranches, .. } => *num_tranches == 0, } } @@ -220,7 +218,7 @@ mod tests { #[case] tranche_size: Capacity, #[case] expected_total_capacity: Capacity, ) { - let capacity = AssetCapacity::new(num_tranches, tranche_size); + let capacity = AssetCapacity::discrete(num_tranches, tranche_size); assert_eq!(capacity.num_tranches(), Some(num_tranches)); assert_eq!(capacity.total_capacity(), expected_total_capacity); @@ -234,7 +232,7 @@ mod tests { #[should_panic(expected = "Tranche size must be a finite non-negative number")] fn new_rejects_non_finite_tranche_size(#[case] tranche_size: Capacity) { let num_tranches = 1; - let _ = AssetCapacity::new(num_tranches, tranche_size); + let _ = AssetCapacity::discrete(num_tranches, tranche_size); } #[rstest] @@ -254,18 +252,18 @@ mod tests { Some(Ordering::Greater) )] #[case::less( - AssetCapacity::new(2, Capacity(3.0)), - AssetCapacity::new(4, Capacity(3.0)), + AssetCapacity::discrete(2, Capacity(3.0)), + AssetCapacity::discrete(4, Capacity(3.0)), Some(Ordering::Less) )] #[case::equal( - AssetCapacity::new(4, Capacity(3.0)), - AssetCapacity::new(4, Capacity(3.0)), + AssetCapacity::discrete(4, Capacity(3.0)), + AssetCapacity::discrete(4, Capacity(3.0)), Some(Ordering::Equal) )] #[case::greater( - AssetCapacity::new(5, Capacity(3.0)), - AssetCapacity::new(4, Capacity(3.0)), + AssetCapacity::discrete(5, Capacity(3.0)), + AssetCapacity::discrete(4, Capacity(3.0)), Some(Ordering::Greater) )] fn partial_cmp_with_valid_comparisons( @@ -280,11 +278,11 @@ mod tests { #[rstest] #[case::mixed_types( AssetCapacity::Continuous(Capacity(4.0)), - AssetCapacity::new(4, Capacity(1.0)) + AssetCapacity::discrete(4, Capacity(1.0)) )] #[case::different_tranche_sizes( - AssetCapacity::new(4, Capacity(1.0)), - AssetCapacity::new(4, Capacity(2.0)) + AssetCapacity::discrete(4, Capacity(1.0)), + AssetCapacity::discrete(4, Capacity(2.0)) )] #[case::nan_continuous( AssetCapacity::Continuous(Capacity(f64::NAN)), @@ -305,9 +303,9 @@ mod tests { AssetCapacity::Continuous(Capacity(4.0)) )] #[case::discrete( - AssetCapacity::new(2, Capacity(3.0)), - AssetCapacity::new(4, Capacity(3.0)), - AssetCapacity::new(2, Capacity(3.0)) + AssetCapacity::discrete(2, Capacity(3.0)), + AssetCapacity::discrete(4, Capacity(3.0)), + AssetCapacity::discrete(2, Capacity(3.0)) )] fn min_returns_smaller_capacity( #[case] left: AssetCapacity, @@ -320,11 +318,11 @@ mod tests { #[rstest] #[case::mixed_types( AssetCapacity::Continuous(Capacity(4.0)), - AssetCapacity::new(4, Capacity(1.0)) + AssetCapacity::discrete(4, Capacity(1.0)) )] #[case::different_tranche_sizes( - AssetCapacity::new(4, Capacity(1.0)), - AssetCapacity::new(4, Capacity(2.0)) + AssetCapacity::discrete(4, Capacity(1.0)), + AssetCapacity::discrete(4, Capacity(2.0)) )] #[should_panic(expected = "Comparing invalid AssetCapacity values")] fn min_panics_for_invalid_comparisons( @@ -336,7 +334,7 @@ mod tests { #[test] fn subtracting_equal_capacities_returns_zero_tranches() { - let capacity = AssetCapacity::new(2, Capacity(3.0)); + let capacity = AssetCapacity::discrete(2, Capacity(3.0)); assert_eq!( capacity - capacity, diff --git a/src/asset/pool.rs b/src/asset/pool.rs index 7584de111..71f20c9e7 100644 --- a/src/asset/pool.rs +++ b/src/asset/pool.rs @@ -632,7 +632,7 @@ mod tests { let mut retained = full.clone(); retained .make_mut() - .set_capacity(AssetCapacity::new(2, Capacity(4.0))); + .set_capacity(AssetCapacity::discrete(2, Capacity(4.0))); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(retained); @@ -645,13 +645,13 @@ mod tests { assert_eq!(asset.num_tranches(), 3); assert_eq!( asset.mothballed_capacity(), - AssetCapacity::new(1, Capacity(4.0)) + AssetCapacity::discrete(1, Capacity(4.0)) ); assert_equal( asset.get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2025, - capacity: AssetCapacity::new(1, Capacity(4.0)), + capacity: AssetCapacity::discrete(1, Capacity(4.0)), }], ); } @@ -660,8 +660,8 @@ mod tests { fn asset_pool_decommission_mothballed_partial(commissioned_multi_tranche: AssetRef) { // Mothball one tranche in 2010 and one in 2020, leaving one active let asset = commissioned_multi_tranche - .with_mothballed_capacity(AssetCapacity::new(1, Capacity(4.0)), Some(2010)) - .with_mothballed_capacity(AssetCapacity::new(2, Capacity(4.0)), Some(2020)); + .with_mothballed_capacity(AssetCapacity::discrete(1, Capacity(4.0)), Some(2010)) + .with_mothballed_capacity(AssetCapacity::discrete(2, Capacity(4.0)), Some(2020)); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(asset); @@ -674,13 +674,13 @@ mod tests { assert_eq!(asset.num_tranches(), 2); assert_eq!( asset.mothballed_capacity(), - AssetCapacity::new(1, Capacity(4.0)) + AssetCapacity::discrete(1, Capacity(4.0)) ); assert_equal( asset.get_mothball_events().unwrap().iter(), &[MothballEvent { year: 2020, - capacity: AssetCapacity::new(1, Capacity(4.0)), + capacity: AssetCapacity::discrete(1, Capacity(4.0)), }], ); } @@ -691,7 +691,7 @@ mod tests { ) { // All three tranches mothballed long enough ago: the whole asset is removed from the pool let asset = commissioned_multi_tranche - .with_mothballed_capacity(AssetCapacity::new(3, Capacity(4.0)), Some(2010)); + .with_mothballed_capacity(AssetCapacity::discrete(3, Capacity(4.0)), Some(2010)); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(asset); diff --git a/src/fixture.rs b/src/fixture.rs index f179bdde5..8923b77d8 100644 --- a/src/fixture.rs +++ b/src/fixture.rs @@ -216,7 +216,7 @@ pub fn multi_tranche_asset(process: Process) -> Asset { "agent1".into(), Arc::new(process), "GBR".into(), - AssetCapacity::new(3, Capacity(4.0)), + AssetCapacity::discrete(3, Capacity(4.0)), 2010, ) .unwrap() diff --git a/src/input/asset.rs b/src/input/asset.rs index de3ae1062..ee06d44da 100644 --- a/src/input/asset.rs +++ b/src/input/asset.rs @@ -116,7 +116,7 @@ where ensure!(num_tranches > 0, "num_tranches must be positive"); let tranche_size = Capacity(asset.capacity.value() / num_tranches as f64); - AssetCapacity::new(num_tranches, tranche_size) + AssetCapacity::discrete(num_tranches, tranche_size) } else if let Some(tranche_size) = process.tranche_size { // No tranche count was provided, so use the process tranche_size to determine // how many tranches are needed to cover the asset's capacity. @@ -137,7 +137,7 @@ where } #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - AssetCapacity::new(num_tranches as u32, tranche_size) + AssetCapacity::discrete(num_tranches as u32, tranche_size) } else { // Without a process tranche_size or num_tranches, capacity is continuous AssetCapacity::continuous(asset.capacity) @@ -222,7 +222,10 @@ mod tests { let assets = read_assets_from_iter(iter::once(asset), &agent_ids, &processes, ®ion_ids).unwrap(); - assert_eq!(assets[0].capacity(), AssetCapacity::new(3, Capacity(2.0))); + assert_eq!( + assets[0].capacity(), + AssetCapacity::discrete(3, Capacity(2.0)) + ); } #[rstest] @@ -272,7 +275,10 @@ mod tests { let assets = read_assets_from_iter(iter::once(asset), &agent_ids, &processes, ®ion_ids).unwrap(); - assert_eq!(assets[0].capacity(), AssetCapacity::new(3, Capacity(4.0))); + assert_eq!( + assets[0].capacity(), + AssetCapacity::discrete(3, Capacity(4.0)) + ); } #[rstest] diff --git a/src/output.rs b/src/output.rs index 819dfe15e..e8742d856 100644 --- a/src/output.rs +++ b/src/output.rs @@ -735,7 +735,10 @@ mod tests { .next() .unwrap() .clone() - .with_mothballed_capacity(AssetCapacity::new(1, Capacity(4.0)), Some(milestone_year)); + .with_mothballed_capacity( + AssetCapacity::discrete(1, Capacity(4.0)), + Some(milestone_year), + ); { let mut writer = DataWriter::create(dir.path(), dir.path(), false).unwrap(); From d3b218a532afd863d5174155f885920616846b3c Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 29 Oct 2026 16:12:21 +0000 Subject: [PATCH 5/6] Tidy mothballing API --- src/asset.rs | 161 ++++++++++++++++++++-------------------------- src/asset/pool.rs | 17 +++-- src/output.rs | 5 +- 3 files changed, 77 insertions(+), 106 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 1238eaddc..c8a376755 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -826,7 +826,7 @@ impl Asset { } /// Whether this asset has any capacity mothballed - pub fn has_any_mothballed_capacity(&self) -> bool { + fn has_any_mothballed_capacity(&self) -> bool { self.get_mothball_events() .is_some_and(|events| !events.is_empty()) } @@ -1006,40 +1006,40 @@ impl AssetRef { /// Decommission any capacity that was mothballed at least `mothball_years` ago. /// /// If the asset still has some capacity remaining, it is returned, else None. - fn with_decommission_mothballed(self, year: u32, mothball_years: u32) -> Option { - let events = self + fn with_decommission_mothballed(mut self, year: u32, mothball_years: u32) -> Option { + // Mothball events are ordered oldest-first + let cutoff = year.saturating_sub(mothball_years); + let num_expired = self .get_mothball_events() - .expect("Can only decommission mothballed capacity in commissioned assets"); - - // Mothball events are ordered oldest-first, so this sums the capacity mothballed longest ago - let capacity_to_remove = events + .expect("Can only decommission mothballed capacity in commissioned assets") .iter() - .take_while(|event| event.year <= year.saturating_sub(mothball_years)) - .fold(self.capacity().zero(), |total, event| { - total + event.capacity - }); - if capacity_to_remove.is_zero() { + .take_while(|event| event.year <= cutoff) + .count(); + if num_expired == 0 { // Nothing to do. Return self unmodified. return Some(self); } + let zero = self.capacity().zero(); + let removed = self + .make_mut() + .get_mothball_events_mut() + .unwrap() + .drain(..num_expired) + .fold(zero, |total, event| total + event.capacity); + let reason = format!( "The asset has not been used for the set mothball years ({mothball_years} years)." ); - let new_capacity = self.capacity() - capacity_to_remove; + let new_capacity = self.capacity() - removed; if new_capacity.is_zero() { self.decommission(&reason); return None; } - log_decommissioning(&self, capacity_to_remove, &reason); - - // Retain the newest mothball events, discarding the oldest events that have just been - // decommissioned. The capacity is reduced after trimming the event history. - let new_mothballed = self.mothballed_capacity() - capacity_to_remove; - let mut asset = self.with_mothballed_capacity(new_mothballed, None); - asset.make_mut().set_capacity(new_capacity); - Some(asset) + log_decommissioning(&self, removed, &reason); + self.make_mut().set_capacity(new_capacity); + Some(self) } /// Return a new [`AssetRef`] with the specified capacity mothballed. @@ -1047,75 +1047,60 @@ impl AssetRef { /// `mothballed` is the total capacity that should be mothballed after the call (not an /// increment). It must be of the same kind as the asset's capacity: for discrete capacities, /// whole tranches are mothballed, while continuous capacities can be partially mothballed. + /// Any additional capacity is recorded as mothballed in `year`; if the mothballed capacity is + /// reduced, the oldest mothballed capacity is released first. /// - /// If `mothballed` equals the capacity already mothballed, the original asset is returned. If - /// additional capacity is to be mothballed, a value must be provided for `year`. + /// If `mothballed` equals the capacity already mothballed, the original asset is returned. /// /// # Panics /// - /// Panics if attempting to mothball more capacity than the asset represents or if attempting to - /// change the mothballed capacity of a non-commissioned asset. - pub fn with_mothballed_capacity( - mut self, - mothballed: AssetCapacity, - year: Option, - ) -> Self { + /// Panics if attempting to mothball more capacity than the asset represents, if attempting to + /// change the mothballed capacity of a non-commissioned asset or if attempting to mothball + /// capacity in a year earlier than an existing mothball event. + pub fn with_mothballed_capacity(mut self, mothballed: AssetCapacity, year: u32) -> Self { if mothballed.is_zero() { - // Small optimisation return self.with_no_mothballed_capacity(); } + assert!( + mothballed <= self.capacity(), + "Cannot mothball more capacity than asset represents" + ); let already_mothballed = self.mothballed_capacity(); if mothballed == already_mothballed { - // Nothing to do. Return self unmodified. return self; } - assert!( - mothballed <= self.capacity(), - "Cannot mothball more capacity than asset represents" - ); - - // Now that we know we have to modify self, call make_mut(). let events = self .make_mut() .get_mothball_events_mut() .expect("Cannot change mothballed capacity for an asset that hasn't been commissioned"); - if mothballed < already_mothballed { - // Remove mothballing events until only the required mothballed capacity remains, - // starting with oldest - let mut remaining = already_mothballed - mothballed; - while !remaining.is_zero() + if mothballed > already_mothballed { + // Adding events in the past would break the ordering invariant of `mothball_events` + assert!( + events.back().is_none_or(|event| event.year <= year), + "Attempting to mothball capacity in a year in the past" + ); + events.push_back(MothballEvent { + year, + capacity: mothballed - already_mothballed, + }); + } else { + let mut excess = already_mothballed - mothballed; + while !excess.is_zero() && let Some(event) = events.front_mut() { - if event.capacity <= remaining { - remaining = remaining - event.capacity; + if event.capacity <= excess { + excess = excess - event.capacity; events.pop_front(); } else { - event.capacity = event.capacity - remaining; + event.capacity = event.capacity - excess; if event.capacity.is_zero() { events.pop_front(); } break; } } - } else { - let year = year.expect("Cannot increase mothballed capacity without supplying year"); - - if let Some(event) = events.back() { - // Need to check this as adding events in the past breaks the expected invariant for - // `mothball_events` - assert!( - event.year <= year, - "Attempting to mothball capacity in a year in the past" - ); - } - - // Mothball extra capacity in specified year - events.push_back(MothballEvent { - year, - capacity: mothballed - already_mothballed, - }); } self @@ -1576,8 +1561,8 @@ mod tests { #[case] num_mothballed: u32, ) { assert_eq!(commissioned_multi_tranche.num_tranches(), 3); - let asset = commissioned_multi_tranche - .with_mothballed_capacity(tranches(num_mothballed), Some(2020)); + let asset = + commissioned_multi_tranche.with_mothballed_capacity(tranches(num_mothballed), 2020); assert_eq!(asset.mothballed_capacity(), tranches(num_mothballed)); assert_eq!( asset.mothballed_capacity().total_capacity(), @@ -1607,7 +1592,7 @@ mod tests { #[rstest] fn with_mothballed_capacity_accumulates_events(commissioned_multi_tranche: AssetRef) { // Mothball one tranche in 2020 - let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(1), Some(2020)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(1), 2020); assert_equal( asset.get_mothball_events().unwrap().iter(), &[MothballEvent { @@ -1617,7 +1602,7 @@ mod tests { ); // Mothball a second tranche in 2022: events are retained in chronological order - let asset = asset.with_mothballed_capacity(tranches(2), Some(2022)); + let asset = asset.with_mothballed_capacity(tranches(2), 2022); assert_equal( asset.get_mothball_events().unwrap().iter(), &[ @@ -1639,8 +1624,8 @@ mod tests { ) { // Mothball 1 tranche in 2020, then 2 more (3 total) in 2022 let asset = commissioned_multi_tranche - .with_mothballed_capacity(tranches(1), Some(2020)) - .with_mothballed_capacity(tranches(3), Some(2022)); + .with_mothballed_capacity(tranches(1), 2020) + .with_mothballed_capacity(tranches(3), 2022); assert_equal( asset.get_mothball_events().unwrap().iter(), &[ @@ -1657,7 +1642,7 @@ mod tests { // Reduce to a single mothballed tranche: the oldest event is fully removed and the newer // event is partially reduced, leaving exactly one mothballed tranche - let asset = asset.with_mothballed_capacity(tranches(1), None); + let asset = asset.with_mothballed_capacity(tranches(1), 2020); assert_eq!(asset.mothballed_capacity(), tranches(1)); assert_equal( asset.get_mothball_events().unwrap().iter(), @@ -1670,20 +1655,18 @@ mod tests { #[rstest] fn with_mothballed_capacity_noop_returns_same_rc(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), Some(2020)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), 2020); // Requesting the same mothballed capacity is a no-op (the year is ignored) - let same = asset - .clone() - .with_mothballed_capacity(tranches(2), Some(2099)); + let same = asset.clone().with_mothballed_capacity(tranches(2), 2099); assert!(Arc::ptr_eq(&asset.0, &same.0)); } #[rstest] fn with_mothballed_capacity_zero_unmothballs(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), Some(2020)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), 2020); assert!(asset.has_any_mothballed_capacity()); - let asset = asset.with_mothballed_capacity(tranches(0), None); + let asset = asset.with_mothballed_capacity(tranches(0), 2020); assert!(!asset.has_any_mothballed_capacity()); assert!(asset.mothballed_capacity().is_zero()); } @@ -1691,7 +1674,7 @@ mod tests { #[rstest] #[should_panic(expected = "Cannot mothball more capacity than asset represents")] fn with_mothballed_capacity_panics_for_too_much_capacity(commissioned_multi_tranche: AssetRef) { - commissioned_multi_tranche.with_mothballed_capacity(tranches(4), Some(2020)); + commissioned_multi_tranche.with_mothballed_capacity(tranches(4), 2020); } #[rstest] @@ -1700,15 +1683,7 @@ mod tests { )] fn with_mothballed_capacity_panics_for_non_commissioned(asset: Asset) { let capacity = asset.capacity(); - AssetRef::from(asset).with_mothballed_capacity(capacity, Some(2020)); - } - - #[rstest] - #[should_panic(expected = "Cannot increase mothballed capacity without supplying year")] - fn with_mothballed_capacity_panics_when_increasing_without_year( - commissioned_multi_tranche: AssetRef, - ) { - commissioned_multi_tranche.with_mothballed_capacity(tranches(1), None); + AssetRef::from(asset).with_mothballed_capacity(capacity, 2020); } #[rstest] @@ -1719,13 +1694,13 @@ mod tests { // Mothball a tranche in 2020, then attempt to mothball another in an earlier year, which would // break the chronological ordering invariant of the mothball events commissioned_multi_tranche - .with_mothballed_capacity(tranches(1), Some(2020)) - .with_mothballed_capacity(tranches(2), Some(2019)); + .with_mothballed_capacity(tranches(1), 2020) + .with_mothballed_capacity(tranches(2), 2019); } #[rstest] fn with_no_mothballed_capacity_clears_events(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), Some(2020)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(2), 2020); let asset = asset.with_no_mothballed_capacity(); assert!(!asset.has_any_mothballed_capacity()); assert!(asset.mothballed_capacity().is_zero()); @@ -1741,7 +1716,7 @@ mod tests { #[rstest] fn with_decommission_mothballed_nothing_old_enough(commissioned_multi_tranche: AssetRef) { - let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(1), Some(2020)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(1), 2020); // Threshold is 2005, so the 2020 event is not old enough: the asset is returned unchanged let result = asset .clone() @@ -1754,8 +1729,8 @@ mod tests { fn with_decommission_mothballed_partial(commissioned_multi_tranche: AssetRef) { // Mothball 1 tranche in 2010 and 1 tranche in 2020 (leaving 1 tranche active) let asset = commissioned_multi_tranche - .with_mothballed_capacity(tranches(1), Some(2010)) - .with_mothballed_capacity(tranches(2), Some(2020)); + .with_mothballed_capacity(tranches(1), 2010) + .with_mothballed_capacity(tranches(2), 2020); // With a threshold of 2015, only the 2010 event is old enough to decommission let result = asset @@ -1779,7 +1754,7 @@ mod tests { #[rstest] fn with_decommission_mothballed_all(commissioned_multi_tranche: AssetRef) { // All capacity mothballed long enough ago: the whole asset is decommissioned - let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(3), Some(2010)); + let asset = commissioned_multi_tranche.with_mothballed_capacity(tranches(3), 2010); assert!(asset.with_decommission_mothballed(2025, 10).is_none()); } } diff --git a/src/asset/pool.rs b/src/asset/pool.rs index 71f20c9e7..3759362f1 100644 --- a/src/asset/pool.rs +++ b/src/asset/pool.rs @@ -102,14 +102,14 @@ impl AssetPool { // At least some of the asset's capacity has made it back into the pool. Increase the // capacity back to what it was before, with the unselected capacity set as mothballed. let mothballed = old_asset.capacity() - new_asset.capacity(); - *new_asset = old_asset.with_mothballed_capacity(mothballed, Some(year)); + *new_asset = old_asset.with_mothballed_capacity(mothballed, year); } else { // None of this asset's capacity was selected. We mothball _all_ of it and return to // the pool. self.assets.push( old_asset .clone() - .with_mothballed_capacity(old_asset.capacity(), Some(year)), + .with_mothballed_capacity(old_asset.capacity(), year), ); } } @@ -545,10 +545,9 @@ mod tests { // Make an asset unused for a few years let mothball_years: u32 = 10; - asset_pool.assets[0] = asset_pool[0].clone().with_mothballed_capacity( - AssetCapacity::single(Capacity(1.0)), - Some(2025 - mothball_years), - ); + asset_pool.assets[0] = asset_pool[0] + .clone() + .with_mothballed_capacity(AssetCapacity::single(Capacity(1.0)), 2025 - mothball_years); assert_equal( asset_pool.assets[0].get_mothball_events().unwrap().iter(), @@ -660,8 +659,8 @@ mod tests { fn asset_pool_decommission_mothballed_partial(commissioned_multi_tranche: AssetRef) { // Mothball one tranche in 2010 and one in 2020, leaving one active let asset = commissioned_multi_tranche - .with_mothballed_capacity(AssetCapacity::discrete(1, Capacity(4.0)), Some(2010)) - .with_mothballed_capacity(AssetCapacity::discrete(2, Capacity(4.0)), Some(2020)); + .with_mothballed_capacity(AssetCapacity::discrete(1, Capacity(4.0)), 2010) + .with_mothballed_capacity(AssetCapacity::discrete(2, Capacity(4.0)), 2020); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(asset); @@ -691,7 +690,7 @@ mod tests { ) { // All three tranches mothballed long enough ago: the whole asset is removed from the pool let asset = commissioned_multi_tranche - .with_mothballed_capacity(AssetCapacity::discrete(3, Capacity(4.0)), Some(2010)); + .with_mothballed_capacity(AssetCapacity::discrete(3, Capacity(4.0)), 2010); let mut asset_pool = AssetPool::new(); asset_pool.assets.push(asset); diff --git a/src/output.rs b/src/output.rs index e8742d856..9082d9368 100644 --- a/src/output.rs +++ b/src/output.rs @@ -735,10 +735,7 @@ mod tests { .next() .unwrap() .clone() - .with_mothballed_capacity( - AssetCapacity::discrete(1, Capacity(4.0)), - Some(milestone_year), - ); + .with_mothballed_capacity(AssetCapacity::discrete(1, Capacity(4.0)), milestone_year); { let mut writer = DataWriter::create(dir.path(), dir.path(), false).unwrap(); From d850aceb2abf3d53e05a988013092c387d4c8a9f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 9 Nov 2026 16:14:20 +0000 Subject: [PATCH 6/6] Update output schema --- schemas/output/asset_capacities.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/schemas/output/asset_capacities.yaml b/schemas/output/asset_capacities.yaml index 3d29c5fe5..d20ac111f 100644 --- a/schemas/output/asset_capacities.yaml +++ b/schemas/output/asset_capacities.yaml @@ -13,10 +13,14 @@ fields: description: The total capacity of the asset in this year - name: num_tranches type: integer - description: The total number of tranches making up the asset in this year + description: | + The total number of tranches making up the asset in this year. Empty for assets with + continuous capacities - name: mothballed_capacity type: number description: The capacity of the asset that is mothballed in this year - name: mothballed_tranches type: integer - description: The number of tranches that are mothballed in this year + description: | + The number of tranches that are mothballed in this year. Empty for assets with continuous + capacities