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}