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. One that is
32    ///   not a number never reports.
33    ///
34    /// # Returns
35    ///
36    /// A predictor awaiting its first two readings.
37    pub fn new(threshold: f32) -> Self {
38        Self {
39            threshold,
40            last: None,
41        }
42    }
43
44    /// Records a reading and estimates the samples until the threshold is reached.
45    ///
46    /// A level that is not a finite number, such as the NaN a failed sensor reports, is
47    /// ignored and reports nothing.
48    ///
49    /// # Arguments
50    ///
51    /// * `level` - the latest measured level.
52    ///
53    /// # Returns
54    ///
55    /// `Some(0)` if the level is already at or below the threshold; `Some(n)` for
56    /// the estimated number of samples until it is reached at the current rate of
57    /// fall; or `None` if the level is steady or rising, or if this is the first
58    /// reading and no rate is known yet.
59    pub fn update(&mut self, level: f32) -> Option<u32> {
60        if !level.is_finite() {
61            return None;
62        }
63        if self.threshold.is_nan() {
64            self.last = Some(level);
65            return None;
66        }
67        let estimate = if level <= self.threshold {
68            Some(0)
69        } else {
70            match self.last {
71                Some(previous) => {
72                    let rate = previous - level;
73                    if rate > 0.0 {
74                        Some(ceil_samples((level - self.threshold) / rate))
75                    } else {
76                        None
77                    }
78                }
79                None => None,
80            }
81        };
82        self.last = Some(level);
83        estimate
84    }
85}
86
87// Rounds a positive sample count up; `f32::ceil` lives in `std`. A value at or beyond
88// the `u32` range (including a tiny rate that makes the estimate enormous, or infinity)
89// saturates rather than overflowing.
90fn ceil_samples(value: f32) -> u32 {
91    if value >= u32::MAX as f32 {
92        return u32::MAX;
93    }
94    let whole = value as u32;
95    if (whole as f32) < value {
96        whole + 1
97    } else {
98        whole
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn the_first_reading_has_no_rate() {
108        let mut tank = Depletion::new(0.0);
109        assert_eq!(tank.update(10.0), None);
110    }
111
112    #[test]
113    fn counts_down_as_the_level_falls() {
114        let mut tank = Depletion::new(2.0);
115        tank.update(10.0);
116        assert_eq!(tank.update(8.0), Some(3)); // (8 - 2) / 2 = 3
117        assert_eq!(tank.update(6.0), Some(2)); // (6 - 2) / 2 = 2
118    }
119
120    #[test]
121    fn rounds_partial_samples_up() {
122        let mut tank = Depletion::new(0.0);
123        tank.update(10.0);
124        assert_eq!(tank.update(7.0), Some(3)); // (7 - 0) / 3 = 2.33, rounded up
125    }
126
127    #[test]
128    fn a_steady_or_rising_level_does_not_warn() {
129        let mut tank = Depletion::new(2.0);
130        tank.update(6.0);
131        assert_eq!(tank.update(6.0), None); // steady
132        assert_eq!(tank.update(7.0), None); // rising
133    }
134
135    #[test]
136    fn at_or_below_the_threshold_is_zero() {
137        let mut tank = Depletion::new(2.0);
138        assert_eq!(tank.update(2.0), Some(0));
139        assert_eq!(tank.update(1.0), Some(0));
140    }
141
142    #[test]
143    fn an_enormous_estimate_saturates_rather_than_overflowing() {
144        // A far-off threshold with a small rate makes the estimate exceed u32; it must
145        // saturate instead of overflowing the count.
146        let mut tank = Depletion::new(-1e10);
147        tank.update(2.0);
148        assert_eq!(tank.update(1.0), Some(u32::MAX));
149    }
150
151    #[test]
152    fn a_level_that_is_not_a_number_is_ignored() {
153        let mut tank = Depletion::new(0.0);
154        assert_eq!(tank.update(10.0), None);
155        assert_eq!(tank.update(f32::NAN), None);
156        assert_eq!(tank.update(f32::NEG_INFINITY), None);
157        assert_eq!(tank.update(8.0), Some(4));
158    }
159
160    #[test]
161    fn a_threshold_that_is_not_a_number_never_reports() {
162        let mut tank = Depletion::new(f32::NAN);
163        assert_eq!(tank.update(10.0), None);
164        assert_eq!(tank.update(8.0), None);
165    }
166}