Skip to main content

pamoja_kit/
depletion.rs

1//! Warning before a falling level runs out.
2
3/// Predicts how soon a falling level will reach a threshold.
4///
5/// This is the primitive behind "warn before a tank runs dry". Feed it successive
6/// level readings and it estimates how many more samples remain before the level
7/// reaches a low mark, so an alert can fire with time to act on it. The technique
8/// one layer down is a linear extrapolation of the most recent rate of fall, so it
9/// reacts to noise and pairs well with a [`Smoother`](crate::Smoother) on the input.
10///
11/// # Examples
12///
13/// ```
14/// use pamoja_kit::Depletion;
15///
16/// let mut tank = Depletion::new(0.0);
17/// assert_eq!(tank.update(10.0), None); // first reading: no rate is known yet
18/// assert_eq!(tank.update(8.0), Some(4)); // falling 2 per sample, 4 until empty
19/// ```
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct Depletion {
22    threshold: f32,
23    last: Option<f32>,
24}
25
26impl Depletion {
27    /// Creates a predictor that warns as the level approaches `threshold`.
28    ///
29    /// # Arguments
30    ///
31    /// * `threshold` - the low level to predict reaching, such as an empty tank.
32    ///
33    /// # Returns
34    ///
35    /// A predictor awaiting its first two readings.
36    pub fn new(threshold: f32) -> Self {
37        Self {
38            threshold,
39            last: None,
40        }
41    }
42
43    /// Records a reading and estimates the samples until the threshold is reached.
44    ///
45    /// # Arguments
46    ///
47    /// * `level` - the latest measured level.
48    ///
49    /// # Returns
50    ///
51    /// `Some(0)` if the level is already at or below the threshold; `Some(n)` for
52    /// the estimated number of samples until it is reached at the current rate of
53    /// fall; or `None` if the level is steady or rising, or if this is the first
54    /// reading and no rate is known yet.
55    pub fn update(&mut self, level: f32) -> Option<u32> {
56        let estimate = if level <= self.threshold {
57            Some(0)
58        } else {
59            match self.last {
60                Some(previous) => {
61                    let rate = previous - level;
62                    if rate > 0.0 {
63                        Some(ceil_samples((level - self.threshold) / rate))
64                    } else {
65                        None
66                    }
67                }
68                None => None,
69            }
70        };
71        self.last = Some(level);
72        estimate
73    }
74}
75
76// Rounds a positive sample count up; `f32::ceil` lives in `std`. A value at or beyond
77// the `u32` range (including a tiny rate that makes the estimate enormous, or infinity)
78// saturates rather than overflowing.
79fn ceil_samples(value: f32) -> u32 {
80    if value >= u32::MAX as f32 {
81        return u32::MAX;
82    }
83    let whole = value as u32;
84    if (whole as f32) < value {
85        whole + 1
86    } else {
87        whole
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn the_first_reading_has_no_rate() {
97        let mut tank = Depletion::new(0.0);
98        assert_eq!(tank.update(10.0), None);
99    }
100
101    #[test]
102    fn counts_down_as_the_level_falls() {
103        let mut tank = Depletion::new(2.0);
104        tank.update(10.0);
105        assert_eq!(tank.update(8.0), Some(3)); // (8 - 2) / 2 = 3
106        assert_eq!(tank.update(6.0), Some(2)); // (6 - 2) / 2 = 2
107    }
108
109    #[test]
110    fn rounds_partial_samples_up() {
111        let mut tank = Depletion::new(0.0);
112        tank.update(10.0);
113        assert_eq!(tank.update(7.0), Some(3)); // (7 - 0) / 3 = 2.33, rounded up
114    }
115
116    #[test]
117    fn a_steady_or_rising_level_does_not_warn() {
118        let mut tank = Depletion::new(2.0);
119        tank.update(6.0);
120        assert_eq!(tank.update(6.0), None); // steady
121        assert_eq!(tank.update(7.0), None); // rising
122    }
123
124    #[test]
125    fn at_or_below_the_threshold_is_zero() {
126        let mut tank = Depletion::new(2.0);
127        assert_eq!(tank.update(2.0), Some(0));
128        assert_eq!(tank.update(1.0), Some(0));
129    }
130
131    #[test]
132    fn an_enormous_estimate_saturates_rather_than_overflowing() {
133        // A far-off threshold with a small rate makes the estimate exceed u32; it must
134        // saturate instead of overflowing the count.
135        let mut tank = Depletion::new(-1e10);
136        tank.update(2.0);
137        assert_eq!(tank.update(1.0), Some(u32::MAX));
138    }
139}