fix: estimate heat-rise drift by regression, and only from free-running windows - #29
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The degradation watch fired "Heat rise increasing" on a real install with a
verdict that looked conclusive:
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):
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, andcurrent_sag_a+free_plateauonevery 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_driftrewritten: regressesrise_ref ~ days + ambient + currentover 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.
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._T95extended to the larger dof a regression reaches, with
_t95falling backto the next-lower tabulated dof so an untabulated value rounds toward
caution.
Assumption stated inline: fits predating this change carry no
free_plateaueither way and are treated as free-running; there is nothingbetter 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. TheAlerts 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_historyandtherefore the live forecast, which needs to describe what the handle actually
did. They bias
rise_ref_clow there, which under-predicts derates — thedangerous direction. That is a real issue and a separate change; flagging it
here so it is not lost.
Risk
latched alert may clear on the next session close or anchor move. On the
install above it goes
drifting: True→False. The poller's existingclear path handles it and emits
thermal_drift_cleared./api/thermal'sdriftobject dropsrecent_n,baseline_n,recent_mad_c,baseline_mad_cand gainsn,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 gaincurrent_sag_aandfree_plateau. Thebundled UI is updated in the same commit; a stale cached
app.jswouldrender
undefinedin the drift note until reloaded. No schema change, nomigration — every field is derived at read time from vitals already stored.
delta_ckeeps its name but changes meaning: modelled change across thewindow, not a difference of medians. A step fitted by a line reads slightly
larger than the step itself.
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).
verified-baseline anchor, the materiality floor, and the lead/alert split.
Verification
132 tests pass (
uv run pytest); the 2ruffE731 warnings pre-date thisbranch and are untouched.
Two existing tests updated for genuinely changed semantics (per-side MAD →
one residual scatter; median step → modelled change). Five added:
and the regulated fit really does read ~2 °C low, which is why it cannot
go near a baseline;
regulated_n == 4,n == 6);+0.29 °C, with the confound landing on the current coefficient and
the near-collinearity reported rather than silently resolved;
reads +0.10 °C, recovering the seeded −0.8 °C/°C ambient coefficient
as −0.794;
lead, not an alert.
_seed_thermal_sessiongainedsag_to_ato simulate foldback inside thesteady 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:
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.serviceon the mini-PC after merge. Nomigration or backfill — fits are derived at read time.
🤖 Generated with Claude Code