Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/thermal-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 38 additions & 10 deletions tests/test_wallmonitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]

Expand All @@ -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):
Expand Down
48 changes: 32 additions & 16 deletions wallmonitor/poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
31 changes: 23 additions & 8 deletions wallmonitor/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ` +
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
27 changes: 19 additions & 8 deletions wallmonitor/thermal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand All @@ -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,
}


Expand Down
Loading