feat: calibrate the idle-offset model per install from its own sensor history - #27
Merged
Merged
Conversation
… 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>
This was referenced Aug 27, 2026
Merged
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 handle-proxy ambient (
ambient ≈ idle handle − offset) rests onIDLE_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_conly 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 fromcontrib/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 through1 + 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.py—IdleOffsetdataclass (offset/handle/inverse, clamp semantics unchanged),BUILTIN_IDLE_OFFSETfrom 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_handletake an optional model defaulting to the seed, so every existing caller and test is unchanged;_ambient_before,_decay_asymptote,_ambient_from_cooldown,_ambient_afterpass it through;fit_sessionsandpredictload it once per call.predictaddsmodel.idle_offset,forecast.ambient_se_con model-basis forecasts, andambient_se_con the idle state.wallmonitor/db.py—ambient_series()andidle_calibration_rows(): raw, unbucketed rows (vitals_rangeis bucket-averaged and would smearcontactor_closedand the handle), day-chunked by the caller.wallmonitor/__main__.py—_maintenancebecomes a daily loop: calibrate → adopt on material sane change →idle_offset_calibratedevent (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/thermalread.wallmonitor/poller.py—invalidate_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 marksidle_offset_calibratedevents 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_offsetrejects 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
IdleOffsetis loaded once perfit_sessions/predictcall (one settings read); no per-row cost.Verification
python -m pytest -q(venv): 127 passed (120 + 7 new). Every existing thermal test seeds throughthermal.idle_handle_c()with no stored model and is untouched.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.ambient_se_cbecomes 0.89 (calibrated residual) instead of the 1.5 default.Deploy:
git pull, then restart the service (_maintenanceandpredictchanged 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.