Files
espresso_frame/server/tests/test_battery_estimate.py
tfaour 3fdda096a9
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m36s
Build and push server image / deploy (push) Successful in 53s
Smooth battery percent readings before computing drop-rate steps
A 1M-ohm divider (way over the ~10k source impedance the ESP32 ADC's
sample-and-hold expects) doesn't always misfire in isolation -- short
bursts of a few consecutive bad readings, and multi-reading drifts,
both slip past the existing step-level MAD outlier rejection since the
steps between two bad readings in the same burst look ordinary. Add a
Hampel-filter smoothing pass (local-neighborhood MAD, same statistical
approach as the existing outlier rejection) ahead of it.
2026-07-28 02:09:12 +00:00

105 lines
3.8 KiB
Python

"""_reject_outlier_drops and _smooth_percents -- the two outlier-rejection
passes in the battery remaining-time estimate (see routers/common.py's
battery_estimate_s). Pure functions, no DB/HTTP."""
from __future__ import annotations
from app.routers.common import _reject_outlier_drops, _smooth_percents
def _steps(drops: list[float]) -> list[tuple[int, float]]:
return [(i + 1, d) for i, d in enumerate(drops)]
def _avg(steps: list[tuple[int, float]]) -> float:
total_weight = sum(w for w, _ in steps)
return sum(w * d for w, d in steps) / total_weight
def test_no_outlier_keeps_every_step():
steps = _steps([1, 1, 2, 1, 1, 2, 1])
assert _reject_outlier_drops(steps) == steps
def test_single_glitch_dip_is_rejected():
"""The exact shape reported in production: 18 ordinary 1%-per-wake
steps and one spliced-in 26% glitch -- the naive median-based MAD
degenerates to 0 here (more than half the steps tie at the median),
which used to let the glitch sail straight through untouched."""
normal = [1] * 18
glitchy = normal[:9] + [26] + normal[9:]
kept = _reject_outlier_drops(_steps(glitchy))
kept_drops = [d for _, d in kept]
assert 26 not in kept_drops
assert len(kept) == 18
# the whole point: the estimate should come out the same as if the
# glitch had never been recorded at all
baseline_avg = _avg(_steps(normal))
filtered_avg = _avg(kept)
assert abs(filtered_avg - baseline_avg) < 1e-9
def test_single_glitch_spike_is_rejected():
normal = [2] * 18
glitchy = normal[:5] + [40] + normal[5:]
kept = _reject_outlier_drops(_steps(glitchy))
kept_drops = [d for _, d in kept]
assert 40 not in kept_drops
assert len(kept) == 18
def test_identical_steps_reject_nothing():
"""Every step tied at the exact same value -- both the median MAD
and the mean-absolute-deviation fallback are 0 here, which is the
one case _reject_outlier_drops explicitly bails out of rather than
filtering down to nothing."""
steps = _steps([1] * 10)
assert _reject_outlier_drops(steps) == steps
def test_never_filters_down_to_nothing():
"""Even a genuinely bimodal series (half the wakes cheap, half
expensive -- not a single-glitch shape at all) shouldn't empty the
list; a battery_estimate_s caller treats an empty result as
"insufficient data," which a merely-noisy history isn't."""
steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
kept = _reject_outlier_drops(steps)
assert len(kept) > 0
def test_smooth_corrects_isolated_spike():
percents = [70, 70, 70, 70, 70, 90, 70, 70, 70, 70, 70]
smoothed = _smooth_percents(percents)
assert smoothed[5] == 70
assert smoothed[:5] == percents[:5]
assert smoothed[6:] == percents[6:]
def test_smooth_corrects_short_burst():
"""The shape seen in production: several consecutive corrupted
reports (a 1M-ohm divider glitching for a few reports in a row, not
just one) spliced into an otherwise flat run. A step computed
between two of these looks like an ordinary small change, which is
exactly why _reject_outlier_drops alone can't catch this shape."""
percents = [53, 53, 53, 53, 41, 40, 40, 42, 53, 53, 53, 53]
smoothed = _smooth_percents(percents)
assert smoothed[4:8] == [53, 53, 53, 53]
assert smoothed[:4] == percents[:4]
assert smoothed[8:] == percents[8:]
def test_smooth_leaves_gradual_legitimate_trend_alone():
"""A slow, steady climb (recharge) or decline spread over many
reports is a real trend, not a local glitch -- each reading is close
to its own neighborhood's median, so nothing should be flagged."""
percents = list(range(80, 60, -2)) # 80, 78, 76, ... steady discharge
assert _smooth_percents(percents) == percents
def test_smooth_identical_readings_untouched():
percents = [50] * 12
assert _smooth_percents(percents) == percents