From a40089c8d9a11615abd5448a303aaf7c95a90965 Mon Sep 17 00:00:00 2001 From: Fernando Gonzalez Date: Wed, 26 Aug 2026 20:28:26 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20drift=20watch=20=E2=80=94=20gate=20t?= =?UTF-8?q?runcated=20fits,=20buzz=20the=20phone=20only=20for=20confirmed?= =?UTF-8?q?=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the degradation watch, both traced from a real "Heat rise climbing" ntfy alert (n=8+3, Δ 4.65 °C, CI straddling zero). Fit gate: a steady-current window that ends before the plateau shows cannot separate rise from tau — the fitter trades a lower rise for a faster tau and passes every other gate with a fine RMSE. On the live install two such windows (11.6 and 14.3 min against an 11 min tau) fit 8 °C under the rest and sat in that alert's baseline, dragging its median down. fit_sessions now requires the window to span >= 1.8x the install's median tau (judged against the install's tau, not the fit's own biased one). The derate-midway test's full-rate phase grows from 20 to 25 min so it clears the gate at tau=12. Notification priority: with ~3.4 °C session-to-session scatter and a 3-session recent median, DRIFT_WARN_C sits near one sigma, so an unconfirmed verdict is a lead, not a conviction — detect_drift already says so via `confident`. The alert and event are unchanged; the ntfy push now goes at default priority (kind thermal_drift_lead) unless the confidence interval clears zero, which keeps high priority. Co-Authored-By: Claude Fable 5 --- docs/notifications.md | 6 +++- docs/thermal-model.md | 10 ++++++ tests/test_wallmonitor.py | 69 +++++++++++++++++++++++++++++++++++++-- wallmonitor/poller.py | 9 +++-- wallmonitor/thermal.py | 11 +++++++ 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/docs/notifications.md b/docs/notifications.md index 2fccbed..194ec2a 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -38,7 +38,11 @@ sudo ./install-service.sh --host [your other flags] \ On the phone, install the ntfy app, point it at `http://:8481` as the default server, and subscribe to the `wallmonitor` topic. Warnings arrive prioritized (a predicted derate is *urgent* — it's the one you can -act on in the moment by lowering the vehicle's charge current). +act on in the moment by lowering the vehicle's charge current). The +degradation watch sends at *high* priority only when its confidence +interval clears zero; a verdict that merely crosses the tripwire is sent as +a lead at *default* priority — the same alert on the dashboard, without the +phone buzz. iOS caveat, stated plainly: Apple only delivers instant background pushes through its own push service, so a purely self-hosted server means the iOS diff --git a/docs/thermal-model.md b/docs/thermal-model.md index 2940d2e..22d983d 100644 --- a/docs/thermal-model.md +++ b/docs/thermal-model.md @@ -36,6 +36,16 @@ So the fitter works per **charging segment**: it finds each segment's opening ramp wherever it occurs in the session and lets the quality gates decide what teaches the model. No configuration or "monitoring mode" is needed. +One gate deserves spelling out: the steady-current window must span at +least 1.8× the install's median τ (≈ 20 min at a typical 11 min τ). Shorter +than that the plateau is never observed, and the exponential can explain +the same samples with a lower rise and a faster τ — passing every other +gate with a fine RMSE while under-reading the rise by several degrees. Such +fits are not "noisy"; they are biased low, and a few of them in a drift +baseline manufacture a degradation verdict. The span is judged against the +install's τ, not the fit's own, so a truncated segment cannot vouch for +itself. + The unit of thermal analysis is the **load window** — the stretch where current actually flows. diff --git a/tests/test_wallmonitor.py b/tests/test_wallmonitor.py index d1ba975..2b566ac 100644 --- a/tests/test_wallmonitor.py +++ b/tests/test_wallmonitor.py @@ -560,11 +560,11 @@ async def test_thermal_fit_survives_ramp_and_midsession_derate(db): into = ts - start if into < 60: current = amps * into / 60.0 # ramp-up - elif into < 1200: - current = amps # full rate for 20 min... + elif into < 1500: + current = amps # full rate for 25 min... else: current = amps / 2 # ...then derated for hours (most samples) - temp = t_inf - (t_inf - temp0) * math.exp(-into / tau_s) if into < 1200 else 60.0 + temp = t_inf - (t_inf - temp0) * math.exp(-into / tau_s) if into < 1500 else 60.0 db.insert_vitals(ts, { "vehicle_connected": 1, "contactor_closed": 1, "vehicle_current_a": round(current, 2), "handle_temp_c": round(temp, 3), "pcba_temp_c": 55.0, "mcu_temp_c": 50.0, @@ -579,6 +579,29 @@ async def test_thermal_fit_survives_ramp_and_midsession_derate(db): assert fits[0]["rise_ref_c"] is not None and abs(fits[0]["rise_ref_c"] - rise) < 3.0 +async def test_thermal_fit_rejects_window_short_against_tau(db): + # A steady window that ends before the plateau shows can't separate rise + # from tau: the fitter trades a lower rise for a faster tau and passes + # every other gate with a fine RMSE. Seen on a real install as 21 min + # charges fitting 8 C under the rest and dragging the drift baseline + # down. The gate judges span against the install's median tau, not the + # fit's own (the biased quantity), so a truncated segment can't vouch + # for itself. + now = time.time() + tau_s = 720.0 + for i in range(4): # establish the install's tau from full-length ramps + _seed_thermal_session(db, now - (6 - i) * 7200, ambient_c=25.0, tau_s=tau_s, charge_s=1500.0) + short_start = now - 7200 + _seed_thermal_session(db, short_start, ambient_c=25.0, tau_s=tau_s, charge_s=720.0) # 1.0 tau + fits = thermal.fit_sessions(db, now) + assert len(fits) == 4, "the four plateau-observing ramps fit" + assert all(abs(fit["start_ts"] - short_start) > 120 for fit in fits), "the 1 tau window does not" + # The boundary itself: a 1.8 tau window is the shortest that passes. + _seed_thermal_session(db, now - 3600, ambient_c=25.0, tau_s=tau_s, + charge_s=thermal.MIN_SPAN_TAU * tau_s + 60.0) + assert len(thermal.fit_sessions(db, now)) == 5 + + async def test_thermal_fit_covers_late_charging_segments(db): # A session shaped like real overnight use: a plug-in burst too short to # fit, hours of connected idle, then distinct charging segments (vehicle @@ -1009,6 +1032,46 @@ async def test_thermal_drift_poller_alert(db): assert any(event["kind"] == "thermal_drift" for event in events) +async def _drift_notification(db, rises): + """Seed sessions with the given fitted rises, run the drift recheck, and + return the single (kind, body, detail) the poller tried to send.""" + now = time.time() + for i, rise in enumerate(rises): + _seed_thermal_session(db, now - (len(rises) - i) * 7200, ambient_c=25.0, rise_ref_c=rise) + sent = [] + async with aiohttp.ClientSession() as client: + poller = Poller(Config(host="127.0.0.1:1"), db, EventBus(), client) + + async def capture(kind, title, body, detail): + sent.append((kind, body, detail)) + + poller._notify = capture + await poller.recheck_thermal_drift(now) + assert any(a["alert"] == thermal.DRIFT_ALERT for a in db.active_alerts()) + assert len(sent) == 1 + return sent[0] + + +async def test_thermal_drift_confirmed_notifies_high_priority(db): + # Tight baseline, tight recent, big step: the interval clears zero, and + # that is the verdict worth interrupting a phone for. + kind, body, detail = await _drift_notification(db, [36.0, 36.5, 35.8, 36.2, 42.0, 41.5, 42.3]) + assert detail["confident"] is True and kind == "thermal_drift" + assert "statistically confirmed" in body + assert Poller.NTFY_PRIORITY[kind] == "high" + + +async def test_thermal_drift_lead_notifies_default_priority(db): + # Scattered baseline, modest step past the tripwire: drifting, but the + # interval straddles zero. Same alert and event, but with ~3.4 C + # session-to-session scatter the 2.5 C tripwire sits near one sigma, so + # an unconfirmed verdict is a lead for the dashboard, not a buzz. + kind, body, detail = await _drift_notification(db, [31.0, 38.0, 33.0, 38.0, 32.0, 37.0, 39.5, 38.0, 39.0]) + assert detail["drifting"] is True and detail["confident"] is False + assert kind == "thermal_drift_lead" and "treat as a lead" in body + assert Poller.NTFY_PRIORITY[kind] == "default" + + async def test_baseline_anchor_reevaluates_drift_alert(db): # Moving the anchor must re-judge the active alert immediately — not at # the next session end, which can be a full plugged-in day away. Setting diff --git a/wallmonitor/poller.py b/wallmonitor/poller.py index c58badd..cf9b488 100644 --- a/wallmonitor/poller.py +++ b/wallmonitor/poller.py @@ -528,8 +528,11 @@ async def recheck_thermal_drift(self, ts: float) -> None: if drift["confident"] else f"not yet confirmed at n={drift['baseline_n']}+{drift['recent_n']} — treat as a lead" ) + # Same alert and event either way; only the phone's interrupt + # level follows the statistics. A lead is a dashboard note, + # not a buzz — see the false-positive rate in detect_drift. await self._notify( - "thermal_drift", + "thermal_drift" if drift["confident"] else "thermal_drift_lead", "Heat rise climbing vs baseline", f"Recent sessions run +{drift['recent_rise_c']:.1f} °C vs a +{drift['baseline_rise_c']:.1f} °C " f"baseline at the same current (Δ {drift['delta_c']:.1f} °C, 95% CI {ci_lo:.1f}..{ci_hi:.1f}, " @@ -627,13 +630,15 @@ def _alert_label(self, code: str) -> str: NTFY_PRIORITY = { "derate_warning": "urgent", # actionable *now* — lower the amps "alert_raised": "high", - "thermal_drift": "high", + "thermal_drift": "high", # confidence interval clears zero + "thermal_drift_lead": "default", # tripwire only — a lead, not a conviction "poll_error": "default", } NTFY_TAGS = { "derate_warning": "zap,warning", "alert_raised": "rotating_light", "thermal_drift": "wrench", + "thermal_drift_lead": "wrench,mag", "poll_error": "electric_plug,x", } diff --git a/wallmonitor/thermal.py b/wallmonitor/thermal.py index dce63ef..62b51cc 100644 --- a/wallmonitor/thermal.py +++ b/wallmonitor/thermal.py @@ -105,6 +105,15 @@ def ambient_from_idle_handle(handle_c: float) -> float: MAX_FIT_RMSE_C = 0.6 TAU_RANGE_MIN = (3.0, 40.0) RISE_RANGE_C = (10.0, 80.0) +# The steady-current window must be long against the install's time +# constant, or rise and tau are not separately identifiable: a window that +# ends before the plateau shows lets the fitter trade a lower rise for a +# faster tau and pass every other gate with a fine RMSE. Judged against the +# install's median tau (not this fit's own, which is exactly the biased +# quantity), so a truncated segment can't vouch for itself. At 1.8 tau the +# handle has covered ~83% of its rise; the 30 min steady-prefix cap keeps +# a stricter multiple out of reach at typical tau. +MIN_SPAN_TAU = 1.8 # Live-forecast gate: a steady-current window must hold this many samples # over this much time before its trajectory is projected. @@ -474,6 +483,8 @@ def fit_sessions(db: Database, now: float, lookback_days: float = 120.0) -> list continue i_med = median(sample["vehicle_current_a"] for sample in prefix) tau_est = median([tau_s / 60.0] + [fit["tau_min"] for fit in fits]) + if seg[-1][0] - seg[0][0] < MIN_SPAN_TAU * tau_est * 60.0: + continue # plateau never observed: rise/tau not identifiable measured = _measured_ambient(db, seg_start - MEASURED_AMBIENT_WINDOW_S, seg_start + 60) ambient, ambient_source = measured if measured is not None else (None, None) if ambient is None: From fe5b34e1a2ae474e3e543cad1a58058c3c3f7698 Mon Sep 17 00:00:00 2001 From: Fernando Gonzalez Date: Wed, 26 Aug 2026 20:43:27 -0400 Subject: [PATCH 2/2] fix: judge the first fit against the default tau; size the fit window by tau MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identifiability gate compared a window against the install's median tau, which on a fresh install is the first fit's own tau — exactly the quantity a truncated window biases low. Floor it at DEFAULT_TAU_MIN so the first charge a new user records is held to a sane prior. The steady-prefix window was a fixed 30 min, tuned to an 11 min tau. An install with a 20 min tau could never span 1.8 tau inside it and would get no fits at all. The window now scales with the tau estimate (2.5 tau, 30 min floor), and when a segment fits slower than the prior that sized its window, the window is widened to the fitted tau and refit once — so a slow-tau install, or the first fit on a fresh one, can bootstrap. Tests: a 12 min first charge on an empty install is rejected; a tau=20 install with hour-long charges fits all four sessions. Co-Authored-By: Claude Fable 5 --- tests/test_wallmonitor.py | 32 +++++++++++++++++++ wallmonitor/thermal.py | 66 ++++++++++++++++++++++++++------------- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/tests/test_wallmonitor.py b/tests/test_wallmonitor.py index 2b566ac..b9c7060 100644 --- a/tests/test_wallmonitor.py +++ b/tests/test_wallmonitor.py @@ -602,6 +602,38 @@ async def test_thermal_fit_rejects_window_short_against_tau(db): assert len(thermal.fit_sessions(db, now)) == 5 +async def test_thermal_fit_first_fit_judged_against_default_tau(db): + # Fresh install, no history: the gate has no earlier fits to judge a + # window against, and the fit's own tau is exactly the quantity a + # truncated window biases low. Floored at DEFAULT_TAU_MIN, a 12 min + # first charge is rejected even though its own tau (6 min) would have + # let it vouch for itself at 1.8 tau = 10.8 min. + now = time.time() + _seed_thermal_session(db, now - 7200, ambient_c=25.0, tau_s=360.0, charge_s=720.0) + assert thermal.fit_sessions(db, now) == [] + # A charge that clears 1.8 x DEFAULT_TAU_MIN is the first to teach the model. + _seed_thermal_session(db, now - 3600, ambient_c=25.0, tau_s=360.0, + charge_s=thermal.MIN_SPAN_TAU * thermal.DEFAULT_TAU_MIN * 60.0 + 60.0) + fits = thermal.fit_sessions(db, now) + assert len(fits) == 1 and abs(fits[0]["tau_min"] - 6.0) < 1.0 + + +async def test_thermal_fit_slow_tau_install_still_fits(db): + # A heavier cable or enclosed handle can have a tau near 20 min. The + # steady-prefix window must scale with the install's tau: a fixed 30 min + # cap would leave every window under 1.8 tau and the install blind. + now = time.time() + tau_s, rise = 1200.0, 30.0 + for i in range(4): + _seed_thermal_session(db, now - (5 - i) * 4 * 3600, ambient_c=22.0, tau_s=tau_s, + rise_ref_c=rise, charge_s=3900.0) + fits = thermal.fit_sessions(db, now) + assert len(fits) == 4 + for fit in fits: + assert abs(fit["tau_min"] - 20.0) < 2.0 + assert fit["rise_ref_c"] is not None and abs(fit["rise_ref_c"] - rise) < 3.0 + + async def test_thermal_fit_covers_late_charging_segments(db): # A session shaped like real overnight use: a plug-in burst too short to # fit, hours of connected idle, then distinct charging segments (vehicle diff --git a/wallmonitor/thermal.py b/wallmonitor/thermal.py index 62b51cc..1f84c67 100644 --- a/wallmonitor/thermal.py +++ b/wallmonitor/thermal.py @@ -110,10 +110,15 @@ def ambient_from_idle_handle(handle_c: float) -> float: # ends before the plateau shows lets the fitter trade a lower rise for a # faster tau and pass every other gate with a fine RMSE. Judged against the # install's median tau (not this fit's own, which is exactly the biased -# quantity), so a truncated segment can't vouch for itself. At 1.8 tau the -# handle has covered ~83% of its rise; the 30 min steady-prefix cap keeps -# a stricter multiple out of reach at typical tau. +# quantity), so a truncated segment can't vouch for itself — and floored at +# DEFAULT_TAU_MIN, so on a fresh install the very first fit is judged +# against a sane prior rather than its own possibly-truncated tau. At 1.8 +# tau the handle has covered ~83% of its rise. The steady-prefix window +# scales with the same tau estimate (PREFIX_SPAN_TAU) so a slow-tau install +# is not starved of fits by a fixed cap it can never clear. MIN_SPAN_TAU = 1.8 +PREFIX_SPAN_TAU = 2.5 +PREFIX_SPAN_MIN_S = 1800.0 # Live-forecast gate: a steady-current window must hold this many samples # over this much time before its trajectory is projected. @@ -194,8 +199,8 @@ def _fit_exponential(points: list[tuple[float, float]]) -> tuple[float, float, f return best -def _steady_current_prefix(samples: list[dict]) -> list[dict]: - """The session's first steady-current run. +def _steady_current_prefix(samples: list[dict], max_span_s: float = PREFIX_SPAN_MIN_S) -> list[dict]: + """The session's first steady-current run, capped at max_span_s. The reference current is the median of the first 10 minutes of charging, not of the whole session: a session that derates midway spends most of @@ -227,7 +232,7 @@ def _steady_current_prefix(samples: list[dict]) -> list[dict]: break # the steady run ended (derate or charge stop) continue # still ramping up to the plateau prefix.append(sample) - if sample["ts"] - prefix[0]["ts"] > 1800: # first 30 min is where the ramp lives + if sample["ts"] - prefix[0]["ts"] > max_span_s: # the ramp lives in the first few tau break return prefix @@ -463,27 +468,46 @@ def fit_sessions(db: Database, now: float, lookback_days: float = 120.0) -> list segments = _segments(coarse)[:MAX_SEGMENTS_PER_SESSION] for idx, (seg_start, seg_end) in enumerate(segments): next_start = segments[idx + 1][0] if idx + 1 < len(segments) else sess["end_ts"] + 1 - t_hi = min(sess["end_ts"], seg_start + 2700, next_start - 1) - samples = db.vitals_range(seg_start - 1, t_hi + 1, 5000) - prefix = _steady_current_prefix(samples) - seg = [ - (sample["ts"], sample["handle_temp_c"]) - for sample in prefix - if sample.get("handle_temp_c") is not None - ] - if len(seg) < MIN_SEGMENT_SAMPLES or seg[-1][0] - seg[0][0] < MIN_SEGMENT_S: - continue - if max(temp for _, temp in seg) - seg[0][1] < MIN_RISE_SEEN_C: - continue - fit = _fit_exponential(seg) - if fit is None: + # The install's tau so far (the default until fits exist) sizes + # the window: a fixed cap that suits an 11 min tau would leave + # a 20 min tau install unable to clear the identifiability gate. + tau_prior = median(fit["tau_min"] for fit in fits) if fits else DEFAULT_TAU_MIN + fit = None + for _pass in range(2): + fit = None + span_cap_s = max(PREFIX_SPAN_MIN_S, PREFIX_SPAN_TAU * tau_prior * 60.0) + t_hi = min(sess["end_ts"], seg_start + span_cap_s + 900, next_start - 1) + samples = db.vitals_range(seg_start - 1, t_hi + 1, 5000) + prefix = _steady_current_prefix(samples, span_cap_s) + seg = [ + (sample["ts"], sample["handle_temp_c"]) + for sample in prefix + if sample.get("handle_temp_c") is not None + ] + if len(seg) < MIN_SEGMENT_SAMPLES or seg[-1][0] - seg[0][0] < MIN_SEGMENT_S: + break + if max(temp for _, temp in seg) - seg[0][1] < MIN_RISE_SEEN_C: + break + fit = _fit_exponential(seg) + if fit is None: + break + # The window was sized from the tau prior. If this segment + # fits slower than that, the prior under-sized it: widen to + # the fitted tau and refit once, so a slow-tau install (or + # the first fit on a fresh one) isn't stuck behind a window + # it can never clear. + slower = fit[0] / 60.0 > tau_prior * 1.1 + if not slower or prefix[-1]["ts"] - prefix[0]["ts"] < span_cap_s - 60: + break # window is tau-sized, or the run ended on its own + tau_prior = fit[0] / 60.0 + if fit is None or len(seg) < MIN_SEGMENT_SAMPLES: continue tau_s, t_inf, rmse = fit if rmse > MAX_FIT_RMSE_C or t_inf <= seg[0][1] + 3.0: continue i_med = median(sample["vehicle_current_a"] for sample in prefix) tau_est = median([tau_s / 60.0] + [fit["tau_min"] for fit in fits]) - if seg[-1][0] - seg[0][0] < MIN_SPAN_TAU * tau_est * 60.0: + if seg[-1][0] - seg[0][0] < MIN_SPAN_TAU * max(tau_est, DEFAULT_TAU_MIN) * 60.0: continue # plateau never observed: rise/tau not identifiable measured = _measured_ambient(db, seg_start - MEASURED_AMBIENT_WINDOW_S, seg_start + 60) ambient, ambient_source = measured if measured is not None else (None, None)