From 564027f4005f915959f93917283aa52ad257e290 Mon Sep 17 00:00:00 2001 From: Fernando Gonzalez Date: Wed, 26 Aug 2026 20:53:05 -0400 Subject: [PATCH] feat: drift alert self-calibrates to the install's own scatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The degradation watch alarmed on a fixed 2.5 °C delta. On a real install with ~3.4 °C session-to-session scatter that sits near one sigma — about a 14% chance of firing per look with no real change — and the same value would be far too loose on a quiet install and far too tight on a noisy one. detect_drift already computed a Student-t confidence interval from the install's own MAD; it just wasn't the trigger. Now "drifting" (the alert) needs both: the interval clears zero, and the delta is material (>= DRIFT_WARN_C, demoted from trigger to floor). The effective threshold served as threshold_c is the larger of the floor and what the scatter demands, so the dashboard shows which one binds. A delta past the floor whose interval straddles zero is a "lead": dashboard chip, one default-priority push per episode (latched in the poller), an event — no alert row. Co-Authored-By: Claude Fable 5 --- docs/thermal-model.md | 16 +++++++++++++ tests/test_wallmonitor.py | 48 +++++++++++++++++++++++++++++++-------- wallmonitor/poller.py | 48 ++++++++++++++++++++++++++------------- wallmonitor/static/app.js | 31 ++++++++++++++++++------- wallmonitor/thermal.py | 27 +++++++++++++++------- 5 files changed, 128 insertions(+), 42 deletions(-) diff --git a/docs/thermal-model.md b/docs/thermal-model.md index 22d983d..878c4df 100644 --- a/docs/thermal-model.md +++ b/docs/thermal-model.md @@ -115,6 +115,22 @@ means added resistance — a loose lug, a degrading contact — so when recent segments' fitted rise climbs past the baseline, the poller raises a monitor alert and the Alerts page charts the fitted-rise trend. +### What counts as drift + +The alert needs two things at once: the recent-vs-baseline delta must be +**material** (≥ 2.5 °C — a confirmed 0.3 °C increase is real but not worth +an inspection) and **confirmed** — its 95% confidence interval, built from +this install's own session-to-session scatter, must clear zero. The +effective alert threshold is therefore the larger of the floor and what the +scatter demands, and the dashboard shows which one is binding. A noisy +install (variable ambient, a sensor in a draughty spot) must show more +before the watch alarms; a quiet one, less. A fixed 2.5 °C tripwire sat +near one sigma on a real install and fired on scatter alone. + +A delta past the floor whose interval still straddles zero is a **lead**: +shown on the dashboard, pushed once at default priority, no alert row. More +sessions either confirm it or dissolve it. + ### What it compares - **Only sessions near the install's recent operating current.** Cap the diff --git a/tests/test_wallmonitor.py b/tests/test_wallmonitor.py index b9c7060..d3b6fe6 100644 --- a/tests/test_wallmonitor.py +++ b/tests/test_wallmonitor.py @@ -987,9 +987,31 @@ async def test_thermal_drift_wide_scatter_is_not_confident(db): for i, rise in enumerate([30.0, 36.0, 31.0, 35.8, 36.2, 42.0, 36.6]): _seed_thermal_session(db, now - (7 - i) * 7200, ambient_c=25.0, rise_ref_c=rise) drift = thermal.detect_drift(thermal.fit_sessions(db, now)) - assert drift is not None and drift["drifting"] is True - assert drift["confident"] is False + assert drift is not None and drift["confident"] is False assert drift["delta_ci95_c"][0] < 0 + # Past the floor, inside the scatter: a lead, not an alert — and the + # effective threshold says how much this install needs to confirm. + assert drift["drifting"] is False and drift["lead"] is True + assert drift["threshold_c"] > drift["floor_c"] and drift["delta_c"] < drift["threshold_c"] + + +def test_thermal_drift_threshold_follows_install_scatter(): + # Same delta, two installs: the quiet one's interval clears zero and + # it alarms; the noisy one's doesn't and it gets a lead. The threshold + # is the larger of the materiality floor and what the scatter needs. + def fits(rises): + return [{"start_ts": 1000.0 * i, "rise_ref_c": r, "current_a": 48.5, "ambient_drift_c": None} + for i, r in enumerate(rises)] + quiet = thermal.detect_drift(fits([36.0, 36.2, 35.9, 36.1, 36.0, 35.8, 39.0, 39.2, 38.9])) + noisy = thermal.detect_drift(fits([33.0, 39.0, 34.0, 38.0, 35.0, 37.0, 39.0, 39.2, 38.9])) + assert abs(quiet["delta_c"] - 3.0) < 0.2 and abs(noisy["delta_c"] - 3.0) < 0.6 + assert quiet["drifting"] is True and quiet["lead"] is False + assert abs(quiet["threshold_c"] - thermal.DRIFT_WARN_C) < 0.01 # the floor binds + assert noisy["drifting"] is False and noisy["lead"] is True + assert noisy["threshold_c"] > thermal.DRIFT_WARN_C # the scatter binds + # A confirmed but immaterial increase is not drift either. + tiny = thermal.detect_drift(fits([36.0, 36.1, 35.9, 36.0, 36.1, 35.9, 36.8, 36.9, 36.7])) + assert tiny["confident"] is True and tiny["drifting"] is False and tiny["lead"] is False async def test_thermal_drift_pools_bracketed_cross_current_fits(db): @@ -1079,7 +1101,8 @@ async def capture(kind, title, body, detail): poller._notify = capture await poller.recheck_thermal_drift(now) - assert any(a["alert"] == thermal.DRIFT_ALERT for a in db.active_alerts()) + # A second recheck with nothing new must not push again. + await poller.recheck_thermal_drift(now + 1) assert len(sent) == 1 return sent[0] @@ -1088,20 +1111,25 @@ 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 detail["drifting"] is True and kind == "thermal_drift" assert "statistically confirmed" in body assert Poller.NTFY_PRIORITY[kind] == "high" + assert any(a["alert"] == thermal.DRIFT_ALERT for a in db.active_alerts()) 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. + # Scattered baseline, modest step past the floor: the interval straddles + # zero. With ~3.4 C session-to-session scatter the 2.5 C floor sits near + # one sigma, so this is a lead for the dashboard and a quiet push that + # says what it would take to confirm — not an alert. 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 detail["lead"] is True and detail["drifting"] is False + assert kind == "thermal_drift_lead" and "to confirm" in body assert Poller.NTFY_PRIORITY[kind] == "default" + # A lead is not an alert: no banner, no alert row — an event only. + assert not any(a["alert"] == thermal.DRIFT_ALERT for a in db.active_alerts()) + kinds = [e["kind"] for e in db.events_range(time.time() - 60, time.time() + 60)] + assert "thermal_drift_lead" in kinds and "thermal_drift" not in kinds async def test_baseline_anchor_reevaluates_drift_alert(db): diff --git a/wallmonitor/poller.py b/wallmonitor/poller.py index cf9b488..6c469c0 100644 --- a/wallmonitor/poller.py +++ b/wallmonitor/poller.py @@ -125,6 +125,7 @@ def __init__(self, cfg: Config, db: Database, bus: EventBus, session: aiohttp.Cl # Thermal params for the in-poller derate forecast: fitted lazily, # refreshed whenever a session closes (the only time fits change). self._params: thermal.ThermalParams | None = None + self._drift_lead_active = False # latch: push a lead once per episode, not per session self._next_forecast_ts = 0.0 self._derate_active = False self._alert_labels: dict | None = None @@ -514,35 +515,50 @@ async def recheck_thermal_drift(self, ts: float) -> None: # No verdict — the comparable history got too thin (sessions aged # out of the lookback, or off-current sessions were set aside). An # active alert can no longer be justified either, so let it clear. + self._drift_lead_active = False cleared = await asyncio.to_thread(self.db.clear_alert, ts, thermal.DRIFT_ALERT, "monitor") if cleared: await self._event(ts, "thermal_drift_cleared", {"reason": "insufficient_history"}) return + ci_lo, ci_hi = drift["delta_ci95_c"] + body = ( + 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}, " + f"n={drift['baseline_n']}+{drift['recent_n']}" + ) if drift["drifting"]: + # Confirmed: the interval clears zero and the delta is material. + self._drift_lead_active = False _, newly = await asyncio.to_thread(self.db.raise_alert, ts, thermal.DRIFT_ALERT, "monitor") if newly: await self._event(ts, "thermal_drift", drift) - ci_lo, ci_hi = drift["delta_ci95_c"] - sureness = ( - "statistically confirmed" - 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" if drift["confident"] else "thermal_drift_lead", + "thermal_drift", "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}, " - f"{sureness}) — inspect the handle and charge-port pins, and have the terminal torque checked.", + body + ", statistically confirmed) — inspect the handle and charge-port pins, " + "and have the terminal torque checked.", + drift, + ) + return + # Not confirmed: whatever alert was latched can no longer be justified. + cleared = await asyncio.to_thread(self.db.clear_alert, ts, thermal.DRIFT_ALERT, "monitor") + if cleared: + await self._event(ts, "thermal_drift_cleared", drift) + if drift["lead"]: + # Past the floor but inside this install's own scatter: a lead + # for the dashboard and a quiet push, once per episode — no alert. + if not self._drift_lead_active: + self._drift_lead_active = True + await self._event(ts, "thermal_drift_lead", drift) + await self._notify( + "thermal_drift_lead", + "Heat rise may be climbing — a lead, not yet confirmed", + body + f"; needs Δ ≥ {drift['threshold_c']:.1f} °C at this install's scatter to confirm) " + "— worth a look at the handle and pins next time you're there; more sessions will settle it.", drift, ) else: - cleared = await asyncio.to_thread(self.db.clear_alert, ts, thermal.DRIFT_ALERT, "monitor") - if cleared: - await self._event(ts, "thermal_drift_cleared", drift) + self._drift_lead_active = False async def _check_derate_forecast(self, ts: float, raw: dict) -> None: """While charging: warn while the user can still act — a capped diff --git a/wallmonitor/static/app.js b/wallmonitor/static/app.js index 53168ab..bd7a7ac 100644 --- a/wallmonitor/static/app.js +++ b/wallmonitor/static/app.js @@ -1085,7 +1085,14 @@ async function viewLive(root) { // same current means added resistance somewhere in the current path. const drift = data.drift; let driftLine = null; - if (drift && drift.drifting) { + if (drift && drift.lead) { + driftLine = el("div", { class: "note" }, + chipFor("warning", "heat rise: lead"), + ` Recent sessions average +${fmtNum(drift.recent_rise_c, 1)} °C at ${fmtNum(model.ref_current_a, 0)} A vs a ` + + `+${fmtNum(drift.baseline_rise_c, 1)} °C baseline (Δ ${fmtNum(drift.delta_c, 1)} °C) — past the ` + + `${fmtNum(drift.floor_c, 1)} °C floor but within this install's session-to-session scatter, which needs ` + + `Δ ≥ ${fmtNum(drift.threshold_c, 1)} °C to confirm. Not an alert; worth a look at the handle and pins.`); + } else if (drift && drift.drifting) { driftLine = el("div", { class: "note" }, chipFor("serious", "heat rise increasing"), ` Recent sessions average +${fmtNum(drift.recent_rise_c, 1)} °C at ${fmtNum(model.ref_current_a, 0)} A vs a ` + @@ -1097,7 +1104,7 @@ async function viewLive(root) { const modelNote = `Model: τ ≈ ${fmtNum(model.tau_min, 1)} min, +${fmtNum(model.rise_ref_c, 0)} °C at ${fmtNum(model.ref_current_a, 0)} A — ` + (model.fitted ? `fitted from ${model.tau_fits} recorded session ramp${model.tau_fits === 1 ? "" : "s"}.` : "defaults from the verified alert-40 event; refits automatically as sessions accumulate.") + - (drift && !drift.drifting ? ` Heat rise stable across the last ${drift.recent_n + drift.baseline_n} fitted sessions` + + (drift && !drift.drifting && !drift.lead ? ` Heat rise stable across the last ${drift.recent_n + drift.baseline_n} fitted sessions` + `${drift.off_current_n ? ` (${drift.off_current_n} off-current session${drift.off_current_n === 1 ? "" : "s"} excluded)` : ""}.` : ""); thermalCard.append(el("div", { class: "chart-card" }, el("div", { class: "chart-title" }, "Thermal derate forecast", chip ? " " : null, chip), @@ -1709,17 +1716,25 @@ async function viewAlerts(root, rangeKey = "7d") { // fits is a lead, not a conviction, and the note must show which. const [ciLo, ciHi] = drift.delta_ci95_c || [null, null]; const sureness = ciLo == null ? "" : - ` · 95% CI ${fmtNum(ciLo, 1)}..${fmtNum(ciHi, 1)} °C from n=${drift.baseline_n}+${drift.recent_n}` + - (drift.confident ? "" : " — not yet statistically confirmed; more sessions will tighten this"); + ` · 95% CI ${fmtNum(ciLo, 1)}..${fmtNum(ciHi, 1)} °C from n=${drift.baseline_n}+${drift.recent_n}`; const pooled = drift.cross_current_n ? ` (${drift.cross_current_n} ambient-bracketed fit${drift.cross_current_n > 1 ? "s" : ""} pooled from other charge currents)` : ""; + // The alert threshold is the larger of the materiality floor and + // what this install's own scatter needs to clear zero — so the same + // delta is an alert on a quiet install and a lead on a noisy one. + const thresholdNote = `alert threshold Δ ≥ ${fmtNum(drift.threshold_c, 1)} °C` + + (drift.threshold_c > drift.floor_c + 0.05 + ? ` (the ${fmtNum(drift.floor_c, 1)} °C floor, raised to what this install's scatter needs to confirm)` + : ` (the ${fmtNum(drift.floor_c, 1)} °C floor)`); + const summary = `recent median +${fmtNum(drift.recent_rise_c, 1)} °C vs baseline +${fmtNum(drift.baseline_rise_c, 1)} °C`; rise.card.append(el("div", { class: "note" }, (drift.drifting - ? `Recent median +${fmtNum(drift.recent_rise_c, 1)} °C vs baseline +${fmtNum(drift.baseline_rise_c, 1)} °C ` + - `(Δ ${fmtNum(drift.delta_c, 1)} °C ≥ ${fmtNum(drift.threshold_c, 1)} °C threshold) — a monitor alert is active` - : `Stable: recent median +${fmtNum(drift.recent_rise_c, 1)} °C vs baseline +${fmtNum(drift.baseline_rise_c, 1)} °C ` + - `(alert threshold Δ ≥ ${fmtNum(drift.threshold_c, 1)} °C)`) + sureness + pooled + ".")); + ? `Confirmed: ${summary} (Δ ${fmtNum(drift.delta_c, 1)} °C; ${thresholdNote}) — a monitor alert is active` + : drift.lead + ? `Lead: ${summary} (Δ ${fmtNum(drift.delta_c, 1)} °C, past the floor but not yet confirmed; ${thresholdNote}) — ` + + "no alert; more sessions will settle it" + : `Stable: ${summary} (${thresholdNote})`) + sureness + pooled + ".")); } // Ambient bracketing: fits that read ambient at both ends of the load // window are de-trended for weather that moved during the charge — the diff --git a/wallmonitor/thermal.py b/wallmonitor/thermal.py index 1f84c67..2e1df64 100644 --- a/wallmonitor/thermal.py +++ b/wallmonitor/thermal.py @@ -603,7 +603,7 @@ def fit_history(db: Database, now: float, lookback_days: float = 120.0, # earlier baseline and flags a sustained increase. DRIFT_RECENT_N = 3 DRIFT_MIN_BASELINE_N = 3 -DRIFT_WARN_C = 2.5 +DRIFT_WARN_C = 2.5 # materiality floor, not the trigger — see detect_drift DRIFT_ALERT = "Handle heat rise increasing (check connector/wiring)" # Cross-current pooling: fits whose ambient was bracketed at both ends are @@ -667,10 +667,16 @@ def detect_drift(fits: list[dict], anchor_ts: float | None = None) -> dict | Non from that moment, not "the first charges the monitor happened to see". The verdict carries its own uncertainty: MAD spread per side and a - Student-t ~95% confidence interval on the delta. "drifting" stays a - plain threshold tripwire; "confident" says whether the interval clears - zero — a Δ from a four-fit baseline is a lead, not a conviction, and the - UI should show the difference. + Student-t ~95% confidence interval on the delta. "drifting" — the alert + — needs both: the interval clears zero ("confident") *and* the delta is + material (>= DRIFT_WARN_C, the floor below which a real increase isn't + worth an inspection). A delta past the floor whose interval still + straddles zero is a "lead": shown, pushed quietly, but no alert. The + effective threshold ("threshold_c") is therefore the larger of the floor + and what this install's own scatter requires, so a noisy install must + show more before the watch alarms and a quiet one less — the fixed + 2.5 °C tripwire sat near one sigma on a real install and fired on + scatter. """ usable = [fit for fit in fits if fit["rise_ref_c"] is not None] if anchor_ts is not None: @@ -702,10 +708,14 @@ def detect_drift(fits: list[dict], anchor_ts: float | None = None) -> dict | Non delta_se = math.sqrt(recent_se**2 + baseline_se**2) t_mult = _T95.get(len(recent) + len(baseline) - 2, 2.0) ci_lo, ci_hi = delta - t_mult * delta_se, delta + t_mult * delta_se + confident = ci_lo > 0.0 + threshold = max(DRIFT_WARN_C, t_mult * delta_se) + drifting = confident and delta >= DRIFT_WARN_C cross_current = sum(1 for fit in comparable if abs(fit["current_a"] - typical_a) > band) return { - "drifting": delta >= DRIFT_WARN_C, - "confident": ci_lo > 0.0, + "drifting": drifting, + "lead": delta >= DRIFT_WARN_C and not drifting, + "confident": confident, "recent_rise_c": round(recent_med, 2), "baseline_rise_c": round(baseline_med, 2), "delta_c": round(delta, 2), @@ -718,7 +728,8 @@ def detect_drift(fits: list[dict], anchor_ts: float | None = None) -> dict | Non "off_current_n": len(usable) - len(comparable), "cross_current_n": cross_current, "anchor_ts": anchor_ts, - "threshold_c": DRIFT_WARN_C, + "threshold_c": round(threshold, 2), + "floor_c": DRIFT_WARN_C, }