umsh_ux_tracker/
battery.rs

1//! User-facing tracker battery-state classification.
2
3/// Mutually exclusive battery modes presented by the tracker UX.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5#[repr(u8)]
6pub enum BatteryState {
7    BatteryOnly = 0,
8    BatteryLow = 1,
9    BatteryCritical = 2,
10    BatteryCharging = 3,
11    BatteryCharged = 4,
12}
13
14impl BatteryState {
15    pub const fn from_u8(value: u8) -> Self {
16        match value {
17            1 => Self::BatteryLow,
18            2 => Self::BatteryCritical,
19            3 => Self::BatteryCharging,
20            4 => Self::BatteryCharged,
21            _ => Self::BatteryOnly,
22        }
23    }
24}
25
26/// The charge-state distinction reported to something outside the UX —
27/// a protocol property, a companion app — as opposed to the five-way
28/// presentation classification.
29///
30/// Low and Critical are presentation policy layered over one physical
31/// condition: the cell is discharging. Consumers that report charge state
32/// rather than warning about it collapse all three unpowered
33/// classifications into [`ChargeClass::Discharging`].
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum ChargeClass {
36    Discharging,
37    Charging,
38    Charged,
39}
40
41/// The [`ChargeClass`] behind a five-way [`BatteryState`].
42///
43/// One definition so every consumer of the same monitor — an on-demand
44/// read and an asynchronous notification, in particular — reports the
45/// same charge state for the same sample.
46pub const fn charge_class(state: BatteryState) -> ChargeClass {
47    match state {
48        BatteryState::BatteryCharging => ChargeClass::Charging,
49        BatteryState::BatteryCharged => ChargeClass::Charged,
50        BatteryState::BatteryOnly | BatteryState::BatteryLow | BatteryState::BatteryCritical => {
51            ChargeClass::Discharging
52        }
53    }
54}
55
56/// Default state thresholds for a single-cell Li-ion tracker.
57#[derive(Clone, Copy, Debug)]
58pub struct BatteryThresholds {
59    pub low_mv: u16,
60    pub critical_mv: u16,
61}
62
63impl Default for BatteryThresholds {
64    fn default() -> Self {
65        Self {
66            low_mv: 3_500,
67            critical_mv: 3_100,
68        }
69    }
70}
71
72/// Classify external power first so battery-only warnings and lockouts can
73/// never leak into Charging or Charged from the user's perspective.
74pub const fn classify(
75    battery_mv: u16,
76    external_power: bool,
77    charging: bool,
78    thresholds: BatteryThresholds,
79) -> BatteryState {
80    if external_power {
81        if charging {
82            BatteryState::BatteryCharging
83        } else {
84            BatteryState::BatteryCharged
85        }
86    } else if battery_mv <= thresholds.critical_mv {
87        BatteryState::BatteryCritical
88    } else if battery_mv <= thresholds.low_mv {
89        BatteryState::BatteryLow
90    } else {
91        BatteryState::BatteryOnly
92    }
93}
94
95/// Generic single-cell Li-ion open-circuit-voltage → state-of-charge
96/// breakpoints, linearly interpolated. Approximate by design (no cell
97/// model, no temperature term); refine from a bench discharge log when
98/// one exists.
99const OCV_TABLE: &[(u16, u8)] = &[
100    (3_300, 0),
101    (3_400, 2),
102    (3_500, 5),
103    (3_550, 8),
104    (3_600, 12),
105    (3_650, 18),
106    (3_700, 28),
107    (3_750, 40),
108    (3_800, 52),
109    (3_850, 60),
110    (3_900, 67),
111    (3_950, 74),
112    (4_000, 81),
113    (4_050, 87),
114    (4_100, 92),
115    (4_150, 96),
116    (4_200, 100),
117];
118
119/// State of charge for a *resting* (open-circuit) terminal voltage.
120pub fn soc_from_ocv(mv: u16) -> u8 {
121    let (first, last) = (OCV_TABLE[0], OCV_TABLE[OCV_TABLE.len() - 1]);
122    if mv <= first.0 {
123        return first.1;
124    }
125    if mv >= last.0 {
126        return last.1;
127    }
128    let mut below = first;
129    for &point in OCV_TABLE {
130        if point.0 >= mv {
131            let span_mv = u32::from(point.0 - below.0);
132            let span_pct = u32::from(point.1 - below.1);
133            let offset = u32::from(mv - below.0);
134            return below.1 + ((offset * span_pct + span_mv / 2) / span_mv) as u8;
135        }
136        below = point;
137    }
138    last.1
139}
140
141/// Number of consecutive quiet samples the anchor median runs over.
142const LEVEL_WINDOW: usize = 5;
143/// Quiet time required before a window median is trusted as OCV.
144const LEVEL_REST_MS: u32 = 180_000;
145/// Reported levels move in steps of this size; coarse output is the
146/// honesty the estimate can actually back.
147const LEVEL_QUANT: u8 = 5;
148
149/// How long after a transient load a terminal-voltage reading is still
150/// treated as possibly sagged rather than as resting OCV.
151///
152/// A property of the cell's recovery, not of the monitor's schedule.
153/// Deciding sag by "was there a load since the previous sample" instead
154/// couples it to the sampling cadence: at a multi-minute cadence an
155/// ordinary duty cycle puts one transmission in nearly every interval,
156/// every sample looks sagged, and the anchor window never fills — so the
157/// level would silently stop tracking on exactly the busy nodes that
158/// matter most.
159pub const SAG_WINDOW_MS: u32 = 30_000;
160
161/// One estimator input: the monitor's measurement plus its context.
162#[derive(Clone, Copy, Debug)]
163pub struct LevelSample {
164    /// Measured terminal voltage, millivolts.
165    pub battery_mv: u16,
166    /// The classification for the same instant (see [`classify`]).
167    pub state: BatteryState,
168    /// A significant load (e.g. a radio transmission) ran within
169    /// [`SAG_WINDOW_MS`] of this reading, so the voltage may be sagged
170    /// rather than resting.
171    pub load_recent: bool,
172    /// Monotonic milliseconds; any epoch, wrapping arithmetic.
173    pub now_ms: u32,
174}
175
176/// Whether a reading taken at `now_ms` falls inside the sag window of the
177/// most recent reported load.
178///
179/// `last_load_ms` is `None` when no load has ever been reported. Shared
180/// by every monitor so the sag rule is one definition rather than one per
181/// board.
182pub const fn load_recent(now_ms: u32, last_load_ms: Option<u32>) -> bool {
183    match last_load_ms {
184        Some(load_ms) => now_ms.wrapping_sub(load_ms) < SAG_WINDOW_MS,
185        None => false,
186    }
187}
188
189/// Approximate state-of-charge estimator for gauge-less boards:
190/// a rest-gated OCV table with a median filter, a discharge-direction
191/// clamp, and quantized output.
192///
193/// Feed it every monitor sample via [`Self::sample`]. It moves at two
194/// speeds:
195///
196/// - **Every quiet sample** sets a ceiling. A terminal voltage that is
197///   not sagging relaxes downward toward true OCV, so the table can only
198///   overstate what is in the pack; the level is capped to that reading
199///   immediately. This is what keeps a stale estimate — most visibly the
200///   one bootstrapped from a charger's elevated rail — from surviving
201///   long after the pack has been unplugged.
202/// - **A rested window of [`LEVEL_WINDOW`] samples** anchors. Only after
203///   [`LEVEL_REST_MS`] of quiet (no external power, no reported load)
204///   does the median become the level outright, and only then is the
205///   discharge clamp re-established.
206///
207/// Anchored levels never rise while discharging, so the output is stable
208/// and monotone between charge sessions. A charge since the last anchor
209/// invalidates the stored level in both directions, so until the next
210/// anchor the ceiling replaces it rather than capping it — which is how
211/// a partial charge shows up without waiting out a full window.
212///
213/// While charging there is no level at all: charging voltage is not
214/// comparable to the discharge table, so the estimate is withdrawn rather
215/// than frozen at its pre-charge value. It returns on the first quiet
216/// reading after the charger goes away. The one exception is the
217/// `Charged` classification, which is a charger's completion signal and
218/// therefore an exact calibration point: it pins the level to 100. Boards
219/// whose charger reports no completion never see that state and simply
220/// report nothing for as long as they are plugged in.
221pub struct LevelEstimator {
222    window: [u16; LEVEL_WINDOW],
223    window_len: usize,
224    level: Option<u8>,
225    last_disturbance_ms: u32,
226    charged_since_anchor: bool,
227    started: bool,
228}
229
230impl LevelEstimator {
231    pub const fn new() -> Self {
232        Self {
233            window: [0; LEVEL_WINDOW],
234            window_len: 0,
235            level: None,
236            last_disturbance_ms: 0,
237            charged_since_anchor: false,
238            started: false,
239        }
240    }
241
242    /// The current estimate, or `None` when no trustworthy one exists —
243    /// before the first quiet sample, and for as long as the pack is
244    /// charging.
245    pub const fn level(&self) -> Option<u8> {
246        self.level
247    }
248
249    pub fn sample(&mut self, s: LevelSample) {
250        if !self.started {
251            self.started = true;
252            self.last_disturbance_ms = s.now_ms;
253        }
254        match s.state {
255            BatteryState::BatteryCharged => {
256                // The charger's completion signal is the one exact
257                // calibration point available.
258                self.level = Some(100);
259                self.disturb(s.now_ms);
260                self.charged_since_anchor = true;
261            }
262            BatteryState::BatteryCharging => {
263                // Charging terminal voltage does not map through the
264                // discharge table, and on a charger that reports no
265                // completion there is no later moment to correct against
266                // either — so there is no level to report, and holding the
267                // pre-charge one would state a number that only grows more
268                // wrong the longer the pack is plugged in. Report nothing
269                // until a quiet reading says otherwise.
270                self.level = None;
271                self.disturb(s.now_ms);
272                self.charged_since_anchor = true;
273            }
274            BatteryState::BatteryOnly
275            | BatteryState::BatteryLow
276            | BatteryState::BatteryCritical => {
277                if s.load_recent {
278                    // Sagged sample: not OCV, restart the quiet window.
279                    self.disturb(s.now_ms);
280                    return;
281                }
282                if self.window_len < LEVEL_WINDOW {
283                    self.window[self.window_len] = s.battery_mv;
284                    self.window_len += 1;
285                } else {
286                    self.window.rotate_left(1);
287                    self.window[LEVEL_WINDOW - 1] = s.battery_mv;
288                }
289
290                // A quiet reading bounds the charge from above straight
291                // away. Terminal voltage relaxes *downward* toward true
292                // OCV once a charge stops, so the table can only overstate
293                // what is left in the pack — which makes it a ceiling
294                // worth applying on the spot rather than holding a stale
295                // number until an anchor lands twenty-odd minutes later.
296                // The median runs over however much of the window has
297                // filled, so the bound gains outlier rejection as it goes
298                // without giving up the first-sample response.
299                let bound = quantize(soc_from_ocv(median(&self.window[..self.window_len])));
300                self.level = Some(match self.level {
301                    // A charge since the last anchor invalidates the
302                    // stored level in *both* directions, so the bound
303                    // replaces it rather than capping it: the pack may
304                    // genuinely hold more than it did before.
305                    Some(_) if self.charged_since_anchor => bound,
306                    Some(current) => current.min(bound),
307                    None => bound,
308                });
309
310                let rested = s.now_ms.wrapping_sub(self.last_disturbance_ms) >= LEVEL_REST_MS;
311                if rested && self.window_len == LEVEL_WINDOW {
312                    let mut candidate = quantize(soc_from_ocv(median(&self.window)));
313                    // Discharge never raises the level; a completed or
314                    // partial charge since the last anchor releases the
315                    // clamp exactly once.
316                    if !self.charged_since_anchor
317                        && let Some(current) = self.level
318                    {
319                        candidate = candidate.min(current);
320                    }
321                    self.level = Some(candidate);
322                    self.charged_since_anchor = false;
323                }
324            }
325        }
326    }
327
328    fn disturb(&mut self, now_ms: u32) {
329        self.last_disturbance_ms = now_ms;
330        self.window_len = 0;
331    }
332}
333
334impl Default for LevelEstimator {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340fn quantize(pct: u8) -> u8 {
341    ((pct + LEVEL_QUANT / 2) / LEVEL_QUANT * LEVEL_QUANT).min(100)
342}
343
344/// Median of a non-empty run of samples, at most [`LEVEL_WINDOW`] long.
345///
346/// An even-length run takes the upper of the two middle values, which
347/// biases a partially filled window's bound very slightly high — the
348/// forgiving direction for a ceiling.
349fn median(samples: &[u16]) -> u16 {
350    let mut sorted = [0u16; LEVEL_WINDOW];
351    let len = samples.len().min(LEVEL_WINDOW);
352    sorted[..len].copy_from_slice(&samples[..len]);
353    sorted[..len].sort_unstable();
354    sorted[len / 2]
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    const T: BatteryThresholds = BatteryThresholds {
362        low_mv: 3_500,
363        critical_mv: 3_100,
364    };
365
366    #[test]
367    fn external_power_suppresses_battery_only_modes() {
368        assert_eq!(
369            classify(2_900, true, true, T),
370            BatteryState::BatteryCharging
371        );
372        assert_eq!(
373            classify(2_900, true, false, T),
374            BatteryState::BatteryCharged
375        );
376    }
377
378    #[test]
379    fn battery_levels_are_mutually_exclusive() {
380        assert_eq!(classify(3_900, false, false, T), BatteryState::BatteryOnly);
381        assert_eq!(classify(3_400, false, false, T), BatteryState::BatteryLow);
382        assert_eq!(
383            classify(3_000, false, false, T),
384            BatteryState::BatteryCritical
385        );
386    }
387
388    fn quiet(mv: u16, now_ms: u32) -> LevelSample {
389        LevelSample {
390            battery_mv: mv,
391            state: BatteryState::BatteryOnly,
392            load_recent: false,
393            now_ms,
394        }
395    }
396
397    #[test]
398    fn ocv_table_is_monotone_and_clamped() {
399        assert_eq!(soc_from_ocv(3_000), 0);
400        assert_eq!(soc_from_ocv(4_300), 100);
401        let mut previous = 0;
402        for mv in (3_300..=4_200).step_by(10) {
403            let soc = soc_from_ocv(mv);
404            assert!(soc >= previous, "SoC fell at {mv} mV");
405            previous = soc;
406        }
407        // A midpoint interpolates rather than steps.
408        assert_eq!(soc_from_ocv(3_775), 46);
409    }
410
411    #[test]
412    fn bootstraps_from_the_first_quiet_sample() {
413        let mut estimator = LevelEstimator::new();
414        assert_eq!(estimator.level(), None);
415        estimator.sample(quiet(3_850, 0));
416        assert_eq!(estimator.level(), Some(60));
417    }
418
419    #[test]
420    fn rest_anchor_uses_the_median_and_never_raises_while_discharging() {
421        let mut estimator = LevelEstimator::new();
422        estimator.sample(quiet(3_850, 0));
423        assert_eq!(estimator.level(), Some(60));
424        // Five quiet samples past the rest window; one recovery spike is
425        // absorbed by the median, and the anchor can only move down.
426        for (index, mv) in [3_805, 3_990, 3_805, 3_800, 3_805].iter().enumerate() {
427            estimator.sample(quiet(*mv, 190_000 + index as u32 * 30_000));
428        }
429        assert_eq!(estimator.level(), Some(55));
430        // A later, higher-voltage anchor cannot raise the level.
431        for index in 0..5 {
432            estimator.sample(quiet(3_900, 400_000 + index * 30_000));
433        }
434        assert_eq!(estimator.level(), Some(55));
435    }
436
437    #[test]
438    fn a_sagged_sample_neither_lowers_the_level_nor_fills_the_window() {
439        let mut estimator = LevelEstimator::new();
440        estimator.sample(quiet(3_850, 0));
441        for index in 0..3u32 {
442            estimator.sample(quiet(3_850, 190_000 + index * 30_000));
443        }
444        assert_eq!(estimator.level(), Some(60));
445        // A transmission drags the terminal voltage down right before the
446        // window fills. That reading is sag, not state of charge: it must
447        // not touch the level, and it restarts the window so the anchor
448        // waits for genuinely quiet samples.
449        estimator.sample(LevelSample {
450            battery_mv: 3_400,
451            state: BatteryState::BatteryOnly,
452            load_recent: true,
453            now_ms: 280_000,
454        });
455        assert_eq!(estimator.level(), Some(60));
456        estimator.sample(quiet(3_850, 310_000));
457        assert_eq!(estimator.level(), Some(60));
458    }
459
460    /// The failure this fixes, from a T-Echo flashed over USB: the level
461    /// bootstraps from the charger's elevated rail, reads full, and then
462    /// sits there for twenty-odd minutes after unplugging while the pack
463    /// is visibly at 3.6 V. The elevated rail now produces no level at
464    /// all, and the first quiet reading produces a true one.
465    #[test]
466    fn a_quiet_reading_after_a_charge_replaces_a_stale_level_at_once() {
467        let mut estimator = LevelEstimator::new();
468        estimator.sample(LevelSample {
469            battery_mv: 4_360,
470            state: BatteryState::BatteryCharging,
471            load_recent: false,
472            now_ms: 0,
473        });
474        assert_eq!(estimator.level(), None);
475        // Unplugged. The pack is nowhere near full and the very next
476        // quiet reading is enough to say so — no five-sample anchor, no
477        // twenty-five minute wait.
478        estimator.sample(quiet(3_600, 300_000));
479        assert_eq!(estimator.level(), Some(10));
480    }
481
482    #[test]
483    fn a_partial_charge_lets_the_level_rise_on_the_next_quiet_reading() {
484        let mut estimator = LevelEstimator::new();
485        estimator.sample(quiet(3_600, 0));
486        assert_eq!(estimator.level(), Some(10));
487        estimator.sample(LevelSample {
488            battery_mv: 4_000,
489            state: BatteryState::BatteryCharging,
490            load_recent: false,
491            now_ms: 60_000,
492        });
493        assert_eq!(estimator.level(), None, "charging voltage must not map");
494        // Unplugged with real charge in the pack. The ceiling now sits
495        // above the stored level, and a charge since the last anchor is
496        // precisely the case where it is allowed to raise it.
497        estimator.sample(quiet(3_900, 360_000));
498        assert_eq!(estimator.level(), Some(65));
499    }
500
501    /// Once an anchor has re-established the clamp, the ceiling can only
502    /// ever lower the level — no amount of voltage recovery raises it
503    /// without a charge in between.
504    #[test]
505    fn the_ceiling_never_raises_a_level_that_has_been_anchored() {
506        let mut estimator = LevelEstimator::new();
507        for index in 0..5u32 {
508            estimator.sample(quiet(3_700, 190_000 + index * 30_000));
509        }
510        assert_eq!(estimator.level(), Some(30));
511        for index in 0..5u32 {
512            estimator.sample(quiet(4_100, 400_000 + index * 30_000));
513        }
514        assert_eq!(estimator.level(), Some(30));
515    }
516
517    #[test]
518    fn sag_window_is_a_recovery_time_not_a_sample_gap() {
519        // Never loaded.
520        assert!(!load_recent(500_000, None));
521        // Inside the window.
522        assert!(load_recent(500_000, Some(500_000)));
523        assert!(load_recent(500_000, Some(500_000 - SAG_WINDOW_MS + 1)));
524        // At and past the boundary the cell is considered recovered —
525        // this is what lets a multi-minute sampling cadence still find
526        // quiet samples on a node that transmits regularly.
527        assert!(!load_recent(500_000, Some(500_000 - SAG_WINDOW_MS)));
528        assert!(!load_recent(500_000, Some(200_000)));
529        // The millisecond counter wraps; a load just before the wrap is
530        // still recent just after it.
531        assert!(load_recent(10, Some(u32::MAX - 10)));
532    }
533
534    #[test]
535    fn charging_withdraws_the_level_and_charged_pins_full() {
536        let mut estimator = LevelEstimator::new();
537        estimator.sample(quiet(3_700, 0));
538        assert_eq!(estimator.level(), Some(30));
539        estimator.sample(LevelSample {
540            battery_mv: 4_050,
541            state: BatteryState::BatteryCharging,
542            load_recent: false,
543            now_ms: 30_000,
544        });
545        assert_eq!(
546            estimator.level(),
547            None,
548            "a pre-charge level must not survive the charge"
549        );
550        estimator.sample(LevelSample {
551            battery_mv: 4_200,
552            state: BatteryState::BatteryCharged,
553            load_recent: false,
554            now_ms: 60_000,
555        });
556        assert_eq!(estimator.level(), Some(100));
557        // After unplugging, the first rested anchor may lower the level
558        // (the charge released the discharge clamp exactly once).
559        for index in 0..5u32 {
560            estimator.sample(quiet(4_150, 250_000 + index * 30_000));
561        }
562        assert_eq!(estimator.level(), Some(95));
563    }
564
565    /// A board whose charger reports no completion — the T-Echo, the Wio
566    /// Tracker L1, the SenseCAP Solar Node — stays in `BatteryCharging`
567    /// for the whole session and never reaches `BatteryCharged`. It must
568    /// report no level for that entire time rather than inventing one
569    /// from the charger's elevated rail.
570    #[test]
571    fn a_charger_without_completion_reports_no_level_until_unplugged() {
572        let mut estimator = LevelEstimator::new();
573        for index in 0..10u32 {
574            estimator.sample(LevelSample {
575                battery_mv: 4_060 + index as u16 * 20,
576                state: BatteryState::BatteryCharging,
577                load_recent: false,
578                now_ms: index * 300_000,
579            });
580            assert_eq!(estimator.level(), None, "sample {index} invented a level");
581        }
582        // Unplugged, and now the terminal voltage means something again.
583        estimator.sample(quiet(4_050, 3_300_000));
584        assert_eq!(estimator.level(), Some(85));
585    }
586}