muse2/asset/
capacity.rs

1//! Represents the capacity of an asset
2use crate::units::{Capacity, Dimensionless};
3use std::cmp::Ordering;
4use std::ops::{Add, Sub};
5
6/// Capacity of an asset, which may be continuous or a discrete number of indivisible units
7#[derive(Clone, PartialEq, Copy, Debug)]
8pub enum AssetCapacity {
9    /// Continuous capacity
10    Continuous(Capacity),
11    /// Discrete capacity represented by a number of indivisible units
12    /// Stores: (number of units, unit size)
13    Discrete(u32, Capacity),
14}
15
16impl AssetCapacity {
17    /// Return the smaller of `self` or `other`.
18    ///
19    /// # Panics
20    ///
21    /// Panics if the comparison is not meaningful. This happens if either `AssetCapacity` contains
22    /// a NaN value, one is discrete and the other continuous or if both are discrete and the unit
23    /// size differs.
24    pub fn min(self, other: AssetCapacity) -> AssetCapacity {
25        match self.partial_cmp(&other) {
26            None => panic!("Comparing invalid AssetCapacity values ({self:?} and {other:?})"),
27            Some(Ordering::Greater) => other,
28            _ => self,
29        }
30    }
31}
32
33impl Add for AssetCapacity {
34    type Output = Self;
35
36    // Add two AssetCapacity values together
37    fn add(self, rhs: AssetCapacity) -> Self {
38        match (self, rhs) {
39            (AssetCapacity::Continuous(cap1), AssetCapacity::Continuous(cap2)) => {
40                AssetCapacity::Continuous(cap1 + cap2)
41            }
42            (AssetCapacity::Discrete(units1, size1), AssetCapacity::Discrete(units2, size2)) => {
43                Self::check_same_unit_size(size1, size2);
44                AssetCapacity::Discrete(units1 + units2, size1)
45            }
46            _ => panic!("Cannot add different types of AssetCapacity ({self:?} and {rhs:?})"),
47        }
48    }
49}
50
51impl Sub for AssetCapacity {
52    type Output = Self;
53
54    // Subtract rhs from self, ensuring that the result is non-negative
55    fn sub(self, rhs: AssetCapacity) -> Self {
56        match (self, rhs) {
57            (AssetCapacity::Continuous(cap1), AssetCapacity::Continuous(cap2)) => {
58                AssetCapacity::Continuous((cap1 - cap2).max(Capacity(0.0)))
59            }
60            (AssetCapacity::Discrete(units1, size1), AssetCapacity::Discrete(units2, size2)) => {
61                Self::check_same_unit_size(size1, size2);
62                AssetCapacity::Discrete(units1 - units2.min(units1), size1)
63            }
64            _ => panic!("Cannot subtract different types of AssetCapacity ({self:?} and {rhs:?})"),
65        }
66    }
67}
68
69impl PartialOrd for AssetCapacity {
70    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
71        match (self, other) {
72            (AssetCapacity::Continuous(a), AssetCapacity::Continuous(b)) => a.partial_cmp(b),
73            (AssetCapacity::Discrete(units1, size1), AssetCapacity::Discrete(units2, size2)) => {
74                // NB: Also returns `None` if either is NaN
75                (*size1 == *size2).then(|| units1.cmp(units2))
76            }
77            _ => None,
78        }
79    }
80}
81
82impl AssetCapacity {
83    /// Validates that two discrete capacities have the same unit size.
84    fn check_same_unit_size(size1: Capacity, size2: Capacity) {
85        assert_eq!(
86            size1, size2,
87            "Can't perform operation on capacities with different unit sizes ({size1} and {size2})",
88        );
89    }
90
91    /// Create an `AssetCapacity` from a total capacity and optional unit size
92    ///
93    /// If a unit size is provided, the capacity is represented as a discrete number of units,
94    /// calculated as the ceiling of (capacity / `unit_size`). If no unit size is provided, the
95    /// capacity is represented as continuous.
96    pub fn from_capacity(capacity: Capacity, unit_size: Option<Capacity>) -> Self {
97        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
98        match unit_size {
99            Some(size) => {
100                let num_units = (capacity / size).value().ceil() as u32;
101                AssetCapacity::Discrete(num_units, size)
102            }
103            None => AssetCapacity::Continuous(capacity),
104        }
105    }
106
107    /// Create an `AssetCapacity` from a total capacity and optional unit size
108    ///
109    /// If a unit size is provided, the capacity is represented as a discrete number of units,
110    /// calculated as the floor of (capacity / `unit_size`). If no unit size is provided, the
111    /// capacity is represented as continuous.
112    pub fn from_capacity_floor(capacity: Capacity, unit_size: Option<Capacity>) -> Self {
113        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
114        match unit_size {
115            Some(size) => {
116                let num_units = (capacity / size).value().floor() as u32;
117                AssetCapacity::Discrete(num_units, size)
118            }
119            None => AssetCapacity::Continuous(capacity),
120        }
121    }
122
123    /// Returns the total capacity represented by this `AssetCapacity`.
124    pub fn total_capacity(&self) -> Capacity {
125        match self {
126            AssetCapacity::Continuous(cap) => *cap,
127            AssetCapacity::Discrete(units, size) => *size * Dimensionless(*units as f64),
128        }
129    }
130
131    /// Returns the number of units if this is a discrete capacity, or `None` if continuous.
132    pub fn n_units(&self) -> Option<u32> {
133        match self {
134            AssetCapacity::Continuous(_) => None,
135            AssetCapacity::Discrete(units, _) => Some(*units),
136        }
137    }
138
139    /// Asserts that both capacities are the same type (both continuous or both discrete).
140    pub fn assert_same_type(&self, other: AssetCapacity) {
141        assert!(
142            matches!(self, AssetCapacity::Continuous(_))
143                == matches!(other, AssetCapacity::Continuous(_)),
144            "Cannot change capacity type"
145        );
146    }
147
148    /// Applies a limit factor to the capacity, scaling it accordingly.
149    ///
150    /// For discrete capacities, the number of units is scaled by the limit factor and rounded up to
151    /// the nearest integer.
152    pub fn apply_limit_factor(self, limit_factor: Dimensionless) -> Self {
153        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
154        match self {
155            AssetCapacity::Continuous(cap) => AssetCapacity::Continuous(cap * limit_factor),
156            AssetCapacity::Discrete(units, size) => {
157                let new_units = (units as f64 * limit_factor.value()).ceil() as u32;
158                AssetCapacity::Discrete(new_units, size)
159            }
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::units::{Capacity, Dimensionless};
168    use rstest::rstest;
169
170    #[rstest]
171    #[case::exact_multiple(Capacity(12.0), Some(Capacity(4.0)), Some(3), Capacity(12.0))]
172    #[case::rounded_up(Capacity(11.0), Some(Capacity(4.0)), Some(3), Capacity(12.0))]
173    #[case::unit_size_greater_than_capacity(
174        Capacity(3.0),
175        Some(Capacity(4.0)),
176        Some(1),
177        Capacity(4.0)
178    )]
179    #[case::continuous(Capacity(5.5), None, None, Capacity(5.5))]
180    fn from_capacity(
181        #[case] capacity: Capacity,
182        #[case] unit_size: Option<Capacity>,
183        #[case] expected_n: Option<u32>,
184        #[case] expected_total: Capacity,
185    ) {
186        let got = AssetCapacity::from_capacity(capacity, unit_size);
187        assert_eq!(got.n_units(), expected_n);
188        assert_eq!(got.total_capacity(), expected_total);
189    }
190
191    #[rstest]
192    #[case::exact_multiple(Capacity(12.0), Some(Capacity(4.0)), Some(3), Capacity(12.0))]
193    #[case::rounded_down(Capacity(11.0), Some(Capacity(4.0)), Some(2), Capacity(8.0))]
194    #[case::unit_size_greater_than_capacity(
195        Capacity(3.0),
196        Some(Capacity(4.0)),
197        Some(0),
198        Capacity(0.0)
199    )]
200    #[case::continuous(Capacity(5.5), None, None, Capacity(5.5))]
201    fn from_capacity_floor(
202        #[case] capacity: Capacity,
203        #[case] unit_size: Option<Capacity>,
204        #[case] expected_n: Option<u32>,
205        #[case] expected_total: Capacity,
206    ) {
207        let got = AssetCapacity::from_capacity_floor(capacity, unit_size);
208        assert_eq!(got.n_units(), expected_n);
209        assert_eq!(got.total_capacity(), expected_total);
210    }
211
212    #[rstest]
213    #[case::round_up(3u32, Capacity(4.0), Dimensionless(0.5), 2u32)]
214    #[case::exact(3u32, Capacity(4.0), Dimensionless(0.33), 1u32)]
215    fn apply_limit_factor(
216        #[case] start_units: u32,
217        #[case] unit_size: Capacity,
218        #[case] factor: Dimensionless,
219        #[case] expected_units: u32,
220    ) {
221        let orig = AssetCapacity::Discrete(start_units, unit_size);
222        let got = orig.apply_limit_factor(factor);
223        assert_eq!(got, AssetCapacity::Discrete(expected_units, unit_size));
224    }
225
226    #[rstest]
227    #[case::less(
228        AssetCapacity::Continuous(Capacity(4.0)),
229        AssetCapacity::Continuous(Capacity(6.0)),
230        Some(Ordering::Less)
231    )]
232    #[case::equal(
233        AssetCapacity::Continuous(Capacity(4.0)),
234        AssetCapacity::Continuous(Capacity(4.0)),
235        Some(Ordering::Equal)
236    )]
237    #[case::greater(
238        AssetCapacity::Continuous(Capacity(6.0)),
239        AssetCapacity::Continuous(Capacity(4.0)),
240        Some(Ordering::Greater)
241    )]
242    fn partial_cmp_continuous(
243        #[case] left: AssetCapacity,
244        #[case] right: AssetCapacity,
245        #[case] expected: Option<Ordering>,
246    ) {
247        assert_eq!(left.partial_cmp(&right), expected);
248        assert_eq!(left == right, expected == Some(Ordering::Equal));
249    }
250
251    #[rstest]
252    #[case::less(
253        AssetCapacity::Discrete(2, Capacity(3.0)),
254        AssetCapacity::Discrete(4, Capacity(3.0)),
255        Some(Ordering::Less)
256    )]
257    #[case::equal(
258        AssetCapacity::Discrete(4, Capacity(3.0)),
259        AssetCapacity::Discrete(4, Capacity(3.0)),
260        Some(Ordering::Equal)
261    )]
262    #[case::greater(
263        AssetCapacity::Discrete(5, Capacity(3.0)),
264        AssetCapacity::Discrete(4, Capacity(3.0)),
265        Some(Ordering::Greater)
266    )]
267    fn partial_cmp_discrete_with_matching_unit_size(
268        #[case] left: AssetCapacity,
269        #[case] right: AssetCapacity,
270        #[case] expected: Option<Ordering>,
271    ) {
272        assert_eq!(left.partial_cmp(&right), expected);
273        assert_eq!(left == right, expected == Some(Ordering::Equal));
274    }
275
276    #[rstest]
277    #[case::mixed_types(
278        AssetCapacity::Continuous(Capacity(4.0)),
279        AssetCapacity::Discrete(4, Capacity(1.0))
280    )]
281    #[case::different_unit_sizes(
282        AssetCapacity::Discrete(4, Capacity(1.0)),
283        AssetCapacity::Discrete(4, Capacity(2.0))
284    )]
285    #[case::nan_continuous(
286        AssetCapacity::Continuous(Capacity(f64::NAN)),
287        AssetCapacity::Continuous(Capacity(4.0))
288    )]
289    fn partial_cmp_returns_none_for_invalid_comparisons(
290        #[case] left: AssetCapacity,
291        #[case] right: AssetCapacity,
292    ) {
293        assert_eq!(left.partial_cmp(&right), None);
294        assert!(left != right);
295    }
296
297    #[rstest]
298    #[case::continuous(
299        AssetCapacity::Continuous(Capacity(4.0)),
300        AssetCapacity::Continuous(Capacity(6.0)),
301        AssetCapacity::Continuous(Capacity(4.0))
302    )]
303    #[case::discrete(
304        AssetCapacity::Discrete(2, Capacity(3.0)),
305        AssetCapacity::Discrete(4, Capacity(3.0)),
306        AssetCapacity::Discrete(2, Capacity(3.0))
307    )]
308    fn min_returns_smaller_capacity(
309        #[case] left: AssetCapacity,
310        #[case] right: AssetCapacity,
311        #[case] expected: AssetCapacity,
312    ) {
313        assert_eq!(left.min(right), expected);
314    }
315
316    #[rstest]
317    #[case::mixed_types(
318        AssetCapacity::Continuous(Capacity(4.0)),
319        AssetCapacity::Discrete(4, Capacity(1.0))
320    )]
321    #[case::different_unit_sizes(
322        AssetCapacity::Discrete(4, Capacity(1.0)),
323        AssetCapacity::Discrete(4, Capacity(2.0))
324    )]
325    #[should_panic(expected = "Comparing invalid AssetCapacity values")]
326    fn min_panics_for_invalid_comparisons(
327        #[case] left: AssetCapacity,
328        #[case] right: AssetCapacity,
329    ) {
330        let _ = left.min(right);
331    }
332}