Skip to content

fix: estimate heat-rise drift by regression, and only from free-running windows - #29

Merged
zebraengine merged 1 commit into
mainfrom
fix/drift-regression-free-plateau
Sep 10, 2026
Merged

zebraengine merged 1 commit into
mainfrom
fix/drift-regression-free-plateau

Conversation

@zebraengine

Copy link
Copy Markdown
Owner

Problem

The degradation watch fired "Heat rise increasing" on a real install with a
verdict that looked conclusive:

recent_rise_c   42.74      baseline_rise_c  35.52
delta_c         +7.22      delta_ci95_c     [5.37, 9.07]
drifting        True       confident        True

The connector had not changed. Two separate confounders produced it, and the
recent-vs-baseline median split could not see either, because "are the last
few fits higher?" is answered by anything that moved with the calendar.

1. The charger regulates, and the fitter could not tell. The Gen 3
defends its thermal limit long before alert 40: it trims charge current as
the handle warms, and it can trim ~10% without ever leaving the fitter's
steady-current band. The ramp flattens because the current fell, and the
exponential reads that flattening as the plateau — a lower rise paired with a
faster τ, clearing every gate with an excellent RMSE. Traced in the raw
vitals (session 66, 48 A):

 t+25min  I=48.6A  handle=58.5C
 t+30min  I=48.4A  handle=60.6C
 t+35min  I=43.4A  handle=62.7C   <- trimmed
 t+70min  I=43.4A  handle=63.0C   <- pinned

The fitted plateau came out 63.16 ± 1.57 °C across a 9.5 °C ambient range. A
passive resistive heater must move 1:1 with ambient; this moved ~0, because
it is a setpoint. The bias is not random either — foldback starts sooner in a
hot garage, so it arrives and leaves with the weather. Regulated windows
fitted a median +33.3 °C rise where the same charger's steady windows fitted
+37.2 °C.

2. The I² normalization is not exact, and the vehicle changed current.
The two fits driving the verdict ran at 39.6 A and had the lowest raw rise
in the whole dataset
(29.1, 29.5 °C). They only became the highest after
being multiplied by (48/39.6)² = 1.47. Regressing log(raw rise) on log(I)
gives an exponent of 0.97 ± 0.27 uncontrolled and 0.51 ± 0.27 with ambient
held — 2.0 is rejected at 3.8σ and 5.6σ. Every amp below 48 A added ~1.2 °C
of pure normalization artifact.

Meanwhile the actual quantity of interest — the time trend with ambient and
current held — was +0.01 to +0.02 °C/day, t ≈ 0.2–0.4. The confidence was
real. It was confidence in the wrong estimand.

Code touched

wallmonitor/thermal.py

  • _current_sag_a / _free_plateau, and current_sag_a + free_plateau on
    every fit. Sag compares head- and tail-quarter medians (so one dropped
    sample cannot pass for regulation) and is signed, because a rising
    current breaks the constant-current premise just as thoroughly. Free when
    |sag| ≤ max(0.5 A, 1.5% of the window's current). The threshold separates
    the two populations with room to spare — steady ≤ 0.6%, regulated ≥ 3.9% —
    and the absolute floor keeps sensor quantization on a low-current charge
    from reading as regulation.
  • detect_drift rewritten: regresses rise_ref ~ days + ambient + current
    over the comparable history and reads the days coefficient; Δ is that slope
    times the observed span, and its interval is the slope's at a small-sample
    Student-t multiplier. A covariate earns a column only when the history
    moved in it (≥ 3 °C ambient, ≥ 2 A current) — below that it buys nothing
    and spends a degree of freedom. Columns drop back to front (current first,
    ambient last) when the history is too thin to estimate them, because
    ambient is the confounder this watch exists to survive.
  • Ambient premise now checked rather than assumed: an install whose rise
    still moves ≥ 0.3 °C/°C after the subtraction, resolved well enough to be
    sure of the sign, is capped at a lead and can never raise an alert.
    Adjusting for a confounder is not understanding it.
  • _ols / _invert / _pearson — pure Python, no new dependencies. _T95
    extended to the larger dof a regression reaches, with _t95 falling back
    to the next-lower tabulated dof so an untabulated value rounds toward
    caution.

Assumption stated inline: fits predating this change carry no
free_plateau either way and are treated as free-running; there is nothing
better to assume about a window whose steadiness was never recorded.

wallmonitor/poller.py — notification text reports the trend
(°C/month at fixed ambient and current) instead of a median step, and the
lead push now distinguishes its two causes: scatter needs more sessions,
an ambient confound needs the cause found first.

wallmonitor/static/app.js — live card and Alerts trend note follow. The
Alerts page gains three diagnostics: which covariates were held and the
residual scatter, the rise-vs-ambient coefficient with a plain reading of
what a non-flat one means, and how many fits were excluded as regulated —
so a verdict resting on few fits does not look like one resting on many.

Deliberately left alone: regulated fits still feed fit_history and
therefore the live forecast, which needs to describe what the handle actually
did. They bias rise_ref_c low there, which under-predicts derates — the
dangerous direction. That is a real issue and a separate change; flagging it
here so it is not lost.

Risk

  • Verdicts will change on existing installs, which is the point, but a
    latched alert may clear on the next session close or anchor move. On the
    install above it goes drifting: True → False. The poller's existing
    clear path handles it and emits thermal_drift_cleared.
  • API shape changed. /api/thermal's drift object drops recent_n,
    baseline_n, recent_mad_c, baseline_mad_c and gains n, dof,
    resid_sd_c, slope_c_per_day, slope_se_c_per_day, span_days,
    covariates, ambient_coef_c_per_c, ambient_coef_se,
    current_coef_c_per_a, collinear_with_time, compromised_by,
    regulated_n. Per-fit dicts gain current_sag_a and free_plateau. The
    bundled UI is updated in the same commit; a stale cached app.js would
    render undefined in the drift note until reloaded. No schema change, no
    migration — every field is derived at read time from vitals already stored.
  • delta_c keeps its name but changes meaning: modelled change across the
    window, not a difference of medians. A step fitted by a line reads slightly
    larger than the step itself.
  • The watch gets quieter, and could be too quiet. An install where
    full-rate charging always ends in foldback has few free-running windows,
    and one whose ambient coefficient stays non-flat can never alert at all.
    Both states are now reported rather than silent, and the docs say what to
    charge to fix it (a monthly fixed-current probe below the foldback point).
  • Unchanged: the forecast, the amp controller, the fitter's own gates, the
    verified-baseline anchor, the materiality floor, and the lead/alert split.

Verification

132 tests pass (uv run pytest); the 2 ruff E731 warnings pre-date this
branch and are untouched.

Two existing tests updated for genuinely changed semantics (per-side MAD →
one residual scatter; median step → modelled change). Five added:

  • the gate flags a simulated foldback and leaves a steady window alone —
    and the regulated fit really does read ~2 °C low, which is why it cannot
    go near a baseline;
  • regulated windows are excluded from the verdict (regulated_n == 4,
    n == 6);
  • a 48.6 → 39.6 A step the median split calls +7.38 °C reads
    +0.29 °C, with the confound landing on the current coefficient and
    the near-collinearity reported rather than silently resolved;
  • a garage cooling 34 → 24 °C that the median split calls +4.82 °C
    reads +0.10 °C, recovering the seeded −0.8 °C/°C ambient coefficient
    as −0.794;
  • an ambient-confounded install with a real trend on top is capped at a
    lead, not an alert.

_seed_thermal_session gained sag_to_a to simulate foldback inside the
steady band; it integrates the lag ODE against the instantaneous current and
is a no-op when unset (existing tests unchanged).

Run against the production database (1.8 GB, 18 fits, on the mini-PC),
using this branch's fitter rather than spliced values — the computed sag
matched the out-of-band measurement on all 18:

delta_c              0.30           (was +7.22)
delta_ci95_c         [-2.83, 3.43]  (was [5.37, 9.07])
slope_c_per_day      0.0095 ± 0.0415
drifting             False          (was True)
n / regulated_n      11 / 7
current_coef_c_per_a -0.986         <- the phantom +7.2 °C, absorbed
ambient_coef_c_per_c -0.434 ± 0.181 -> compromised_by: ['ambient']
resid_sd_c           1.11           (1.37 before the gate)

The gate also tightens the measurement: excluding regulated windows drops
residual scatter from 1.37 to 1.11 °C.

The detector did not just get quieter — a seeded +6 °C step at fixed
ambient and current still alerts: Δ +7.52 °C, CI [2.57, 12.47], drifting: True.

Deploy: restart wallmonitor.service on the mini-PC after merge. No
migration or backfill — fits are derived at read time.

🤖 Generated with Claude Code

…ng windows

The degradation watch compared a recent median against a baseline median,
which asks "are the last few fits higher?" — a question any covariate that
moved with the calendar answers for it. Two such covariates were live on a
real install and the watch reported +7.2 °C, 95% CI [5.4, 9.1],
"statistically confirmed", on a connector that had not changed.

Both are now handled rather than assumed away.

The charger defends its own thermal limit by trimming charge current as the
handle warms, and it can trim ~10% without leaving the fitter's steady band.
The ramp then flattens because the current fell, and the exponential reads
that as the plateau — a lower rise paired with a faster tau, clearing every
gate with an excellent RMSE. The bias is not random: foldback starts sooner
in a hot garage, so it arrives and leaves with the weather. Each fit now
records current_sag_a and free_plateau (sag within 1.5% of the window's
current), and the watch compares only free-running fits. On the install
above the populations do not overlap: steady windows sagged <= 0.6%,
regulated ones >= 3.9%, and the regulated ones fitted 33.3 °C against 37.2.

detect_drift now regresses rise_ref on days while holding ambient and
charge current, and reads the days coefficient; delta is that slope times
the observed span. Confounders become covariates, the estimate uses the
whole history instead of six fits, and a covariate that cannot be separated
from the calendar inflates the slope's standard error so the verdict
declines to confirm on its own. The ambient coefficient is exposed and
checked: an install whose rise still tracks garage air after the
subtraction is measuring something other than connector resistance, and is
capped at a lead until that is found.

On the install above: delta 0.30 °C, CI [-2.83, 3.43], slope
0.0095 +/- 0.0415 °C/day, with the I^2 normalization error landing on the
current coefficient (-0.99 °C/A) where it belongs. A genuine step at fixed
ambient and current still alerts.

Regulated fits still feed the forecast, which needs to describe what the
handle actually did; they bias rise_ref low there, which is left for a
separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zebraengine
zebraengine merged commit 2ffeb19 into main Sep 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant