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}