Skip to content

feat: calibrate the idle-offset model per install from its own sensor history - #27

Merged
zebraengine merged 1 commit into
mainfrom
feat/idle-offset-per-install
Aug 27, 2026
Merged

feat: calibrate the idle-offset model per install from its own sensor history#27
zebraengine merged 1 commit into
mainfrom
feat/idle-offset-per-install

Conversation

@zebraengine

Copy link
Copy Markdown
Owner

Problem

The handle-proxy ambient (ambient ≈ idle handle − offset) rests on IDLE_OFFSET_* constants fitted from 42 idle segments in one garage over 8 days of one August. Every install without a stationary sensor runs on those numbers forever; every install with one still used them for the proxy paths (pre-charge idle, cool-down tails, the idle tile when the sensor is briefly silent). Issue #24 — the largest remaining place a single-install prior governed behaviour after #21#23.

Correction recorded on #24 before building: proxy-ambient error does not reach the amp controller. Its confidence guard reads steady_state_se_c only on trajectory forecasts, and the trajectory projection is ambient-free. The exposure is the model-basis forecast (first ~2 min of a session) and the idle tile. Smaller than the issue claimed; this PR does not touch the controller.

Code touched

  • wallmonitor/calibration.py (new) — the estimator, moved verbatim from contrib/calibrate_idle_offset.py: settled idle only (≥ 1 h since charging), quasi-static ambient only (< 0.5 °C over the prior 30 min), contiguous idle runs collapsed to per-segment means, offset-vs-ambient slope with a leave-one-day-out jackknife SE. Stationary sources only (source != "car", per Car-sourced ambient reads high during pre-departure preconditioning #2). Plus adoption: gate() (≥ 3 days, offset at 30 °C within −1..5 °C, |slope| ≤ 0.5 — the proxy inverts through 1 + slope), proposed_model() (constant offset when coverage is < 3 °C wide), material_change() hysteresis (> 0.25 °C at 30 °C, > 0.03 °C/°C, or > 2 °C wider coverage), maybe_adopt() which writes one JSON settings key. The two SQL strings are module constants so the in-process path and the script read identical rows.
  • wallmonitor/thermal.pyIdleOffset dataclass (offset/handle/inverse, clamp semantics unchanged), BUILTIN_IDLE_OFFSET from the existing constants, load_idle_offset(db) (falls back to the seed on missing/corrupt/implausible JSON), ambient_se_c (calibration residual sd, floored 0.2, else 1.5 uncalibrated). idle_offset_c / idle_handle_c / ambient_from_idle_handle take an optional model defaulting to the seed, so every existing caller and test is unchanged; _ambient_before, _decay_asymptote, _ambient_from_cooldown, _ambient_after pass it through; fit_sessions and predict load it once per call. predict adds model.idle_offset, forecast.ambient_se_c on model-basis forecasts, and ambient_se_c on the idle state.
  • wallmonitor/db.pyambient_series() and idle_calibration_rows(): raw, unbucketed rows (vitals_range is bucket-averaged and would smear contactor_closed and the handle), day-chunked by the caller.
  • wallmonitor/__main__.py_maintenance becomes a daily loop: calibrate → adopt on material sane change → idle_offset_calibrated event (old → new) → invalidate the poller's cached params; then the retention trim as before. Daily rather than per session end: the estimator scans a month of raw rows (~4.6 s on the reference install), unplugs are frequent.
  • wallmonitor/web.py — the 6 h fit cache is also keyed on the stored model string, so an adoption refits on the next /api/thermal read. wallmonitor/poller.pyinvalidate_thermal().
  • wallmonitor/static/app.js — model note names the model's source (calibrated: offset at 30 °C, segments, days, ±; built-in: the seed and how to calibrate); idle tile shows ± on proxy reads; alerts-page rise-vs-date chart marks idle_offset_calibrated events with a vertical line.
  • contrib/calibrate_idle_offset.py — thin wrapper over the shared estimator; prints the model the monitor would adopt and whether it's gated.
  • docs/ambient-sensors.md, docs/thermal-model.md — what a sensor now buys, what sensor-less installs get instead.
  • tests/test_calibration.py (new, 7 tests) — estimator recovers a seeded 3 °C / −0.08 °C/°C model from 6 synthetic days (offset within 0.25, slope within 0.03); car source is not ground truth; no sensor → nothing adopted, seed stands; adoption + hysteresis + every gate + the narrow-coverage constant fallback; load_idle_offset rejects garbage and a slope that would blow up the inversion; proxy-tier fits under the seed read ~1.6 °C low against a 3 °C garage and land on the seeded rise once the install's model is stored; the idle forecast states the uncalibrated ±1.5.

Risk

  • Adoption reinterprets every proxy-tier fit in history at once. That is the correction working, but a drift comparison spanning it sees a step. Mitigations: the event and the chart marker; the docs recommend re-anchoring the verified baseline after a material recalibration. The anchor is not moved automatically — it means "a human verified the hardware", and code must not claim that.
  • A contaminated sensor calibrates in its contamination. If the sensor is warmed by the charger (Verify a fixed ambient sensor isn't picking up heat from the charger itself #3), the fitted offset absorbs it. The settled-idle gate (≥ 1 h after charging) removes the worst of it; Verify a fixed ambient sensor isn't picking up heat from the charger itself #3's placement check remains the real defence and is out of scope here.
  • The gates are judgement calls (−1..5 °C, |slope| ≤ 0.5, 3 days). They are wide enough that the reference install's real fit passes comfortably and narrow enough that a sensor reading the wrong room fails; stated as such in the code.
  • Sensor-less installs gain no accuracy — only honesty. The ±1.5 °C is a stated prior, not a measurement, and the docs say so.
  • IdleOffset is loaded once per fit_sessions/predict call (one settings read); no per-row cost.

Verification

  • python -m pytest -q (venv): 127 passed (120 + 7 new). Every existing thermal test seeds through thermal.idle_handle_c() with no stored model and is untouched.
  • Same estimator, same window, same output — contrib script and in-process calibration.calibrate() run read-only against the production database on the reference install, today's 30-day window: both report 375,663 settled quasi-static samples in 128 segments over 24 days, mean offset 1.41 °C (sd 1.11, CI [1.21, 1.60]), slope −0.1588 °C/°C (jackknife SE 0.0171, t −9.3), coverage 18.4–38.4 °C, residual sd 0.89. In-process run: 4.6 s. Since they share the code this is expected; it confirms the loaders agree.
  • What the reference install will adopt on its first maintenance pass after deploy (gate passes, material vs the seed): offset 1.15 °C at 30 °C (seed 1.40), slope −0.159 (seed −0.124), coverage 18.0–38.5 (seed 23–38.5 — three more weeks of data reach 5 °C cooler nights). At 25/30/35 °C the seed says 2.02/1.40/0.78, the install's own model 1.95/1.15/0.36 — proxy-tier rises move by +0.05 to +0.4 °C, well inside the drift watch's noise floor. Day/night split 0.59 / 2.42 °C confirms the ambient dependence is real. Its ambient_se_c becomes 0.89 (calibrated residual) instead of the 1.5 default.
  • Not verified here: the adoption event firing in the live service — that happens on the first daily pass after deploy and will be checked then (the marker should appear on the rise chart, the model note should switch to "calibrated").

Deploy: git pull, then restart the service (_maintenance and predict changed in-process). No migration; the settings key is created on first adoption. The first pass runs at startup, so the adoption should be visible within a minute of restart.

… history

The handle-proxy ambient rested on IDLE_OFFSET_* constants fitted on one
install. Once a stationary ambient sensor gives an install ground truth,
the daily maintenance pass now refits the same linear model from that
install's settled, quasi-static idle segments (the estimator moved from
contrib/calibrate_idle_offset.py into wallmonitor.calibration; the script
is a thin wrapper over it, same SQL, same code) and adopts it when it
passes sanity gates — >= 8 segments over >= 3 days, offset within -1..5 C
at 30 C, |slope| <= 0.5 (the proxy inverts through 1 + slope), a constant
model when coverage is too narrow for a slope — and moves materially
(> 0.25 C at 30 C, > 0.03 C/C, or > 2 C wider coverage). Adoption writes
one JSON settings key, records an idle_offset_calibrated event (marked
on the rise-vs-date chart: every proxy-tier fit reinterprets at once),
and invalidates both thermal caches.

thermal gains an IdleOffset dataclass; the three proxy helpers and every
call site take the install's model, loaded once per fit_sessions/predict.
Without a sensor the built-in seed stands, labelled, and every
handle-derived ambient carries a stated +/-1.5 C uncertainty
(ambient_se_c) on the idle tile and model-basis forecasts. The amp
controller is untouched: its guard reads trajectory forecasts, which are
ambient-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zebraengine
zebraengine merged commit db1595f into main Aug 27, 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