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}