Reject outlier readings in the battery estimate
Build and push server image / build-and-push (push) Successful in 51s

A single noisy ADC/regulator glitch (see firmware/main/battery.c)
survives the existing recharge filter: whichever way it reads, one of
the two steps around it (into a dip, or out of a spike) still looks
like an ordinary drop and got averaged straight into the remaining-
time estimate, letting one bad reading swing it dramatically.

Added a MAD-based modified z-score outlier check on top of the
existing recency-weighted average. Had to special-case the standard
MAD degenerating to exactly 0, which happens whenever more than half
the steps share the same value -- the norm for battery data (most
wakes cost the same small integer percent), and exactly the shape a
single spliced-in glitch among a steady discharge rate has, so the
naive case would have let the outlier this is for sail straight
through. Falls back to mean absolute deviation there instead.

Verified against constructed glitch scenarios in both directions
(spurious dip and spurious spike): estimate now comes out identical
to the same series with the glitch removed entirely.
This commit is contained in:
2026-07-23 06:22:12 -04:00
parent 33af5408fd
commit 01b9e9f1d0
+66 -13
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import io
import logging
import os
import statistics
import time
from datetime import datetime, timedelta
from urllib.parse import urlparse
@@ -40,6 +41,11 @@ RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery
RECHARGE_LOOKBACK = 3
BATTERY_ESTIMATE_SAMPLE_COUNT = 100 # most recent battery_log rows considered
MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
# Modified z-score cutoff (Iglewicz & Hoaglin's standard figure) for
# _reject_outlier_drops -- see that function's docstring for why a
# single noisy reading needs rejecting at the per-wake-drop level, not
# just at the recharge-detection level.
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
@@ -128,6 +134,50 @@ def _avg_wake_interval_s(frame: Frame) -> float:
return 86400 / wakes_per_day
def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, float]]:
"""Drops (weight, drop_pct) pairs whose drop is a wild outlier
relative to the rest of the recent steps. A single noisy ADC/
regulator glitch (see firmware/main/battery.c) corrupts one of the
two steps around it, whichever way it reads: a glitch that dips low
then recovers makes the step INTO it a spurious huge drop (the step
back out is an increase, already excluded above as a "recharge");
one that spikes high then settles makes the step OUT OF it the
spurious one instead (the step into it is the excluded "recharge").
Either way, one bad reading survives the recharge filter looking
like an ordinary, legitimately huge drop and swings the whole
remaining-time estimate on its own.
Uses a MAD-based modified z-score (robust to a small number of
extreme values in a way a plain mean/stdev z-score isn't -- a single
huge outlier inflates the stdev itself, which just hides the outlier
from a stdev-based test) rather than a fixed percent-point cutoff, so
it adapts to how noisy a given frame's own sensor actually is
instead of guessing one global threshold for every install."""
drops = [drop for _, drop in steps]
median = statistics.median(drops)
abs_devs = [abs(d - median) for d in drops]
mad = statistics.median(abs_devs)
if mad == 0:
# The standard median-based MAD degenerates to exactly 0 as soon
# as more than half the steps share the median exactly -- and
# real battery data is small integer percents, so "most wakes
# cost exactly 1%" ties are the norm, not an edge case. That's
# precisely the shape a single spliced-in glitch among a steady
# discharge rate has (18 steps at "1", one at "26"), so treating
# MAD==0 as "no spread, nothing to reject" would let exactly the
# outlier this function exists for sail straight through. Fall
# back to mean absolute deviation instead, which only reaches 0
# when every single step is identical.
mad = statistics.mean(abs_devs)
if mad == 0:
return steps # every step really is identical -- nothing to reject
kept = [
(weight, drop) for weight, drop in steps
if abs(0.6745 * (drop - median) / mad) <= OUTLIER_MODIFIED_Z_THRESHOLD
]
return kept or steps # never filter down to nothing
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
"""Remaining-time estimate from a recency-weighted average of the
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
@@ -143,10 +193,13 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
entirely rather than folded in as a weird outlier; a flat step
(0% change) still counts as a real, cheap wake -- excluding those
would systematically overstate the per-wake cost by only counting
the wakes that happened to tick the percentage down. Steps are
weighted linearly by recency (step i of n gets weight i, 1-indexed)
so a recent change in usage pattern shows up quickly instead of
being washed out by a long flat history.
the wakes that happened to tick the percentage down. The remaining
steps then get one more pass, _reject_outlier_drops, to catch the
single-noisy-reading case that "percent went up" alone can't (see
that function's docstring). Steps are weighted linearly by recency
(step i of n gets weight i, 1-indexed) so a recent change in usage
pattern shows up quickly instead of being washed out by a long flat
history.
The resulting %/wake rate is then converted to wall-clock time using
the frame's *current* refresh_interval_s and quiet-hours settings
@@ -167,21 +220,21 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
return None
percents = list(reversed(rows)) # chronological order
weighted_drop_total = 0.0
weight_total = 0.0
valid_steps = 0
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
for i in range(1, len(percents)):
prev_pct, next_pct = percents[i - 1], percents[i]
if next_pct > prev_pct:
continue # recharge (or a swap) -- not a discharge sample
weight = i # later steps (larger i) count more
weighted_drop_total += weight * (prev_pct - next_pct)
weight_total += weight
valid_steps += 1
steps.append((i, prev_pct - next_pct)) # later steps (larger i) weigh more
if valid_steps < MIN_ESTIMATE_SAMPLES or weight_total <= 0:
if len(steps) < MIN_ESTIMATE_SAMPLES:
return None
avg_drop_per_wake = weighted_drop_total / weight_total
steps = _reject_outlier_drops(steps)
weight_total = sum(weight for weight, _ in steps)
if weight_total <= 0:
return None
avg_drop_per_wake = sum(weight * drop for weight, drop in steps) / weight_total
if avg_drop_per_wake <= 0:
return None # flat -- no honest rate to extrapolate