Confirmed: learned models were matched only by zone key, so replacing a
zone's sensors, changing sensor weights, moving or replacing a heat
source or turning a window kept coefficients learned for a different
configuration, silently.
compatibility.py fingerprints exactly what each learned component
depends on: the zone measurement (sensors and relative weights; equal
scaling changes nothing), each zone's heat sources, the neighbour's
measurement, the zone's windows, window orientation (direction term
only), the recovery time (recovery only) and other zones' openings
(cross term only). Comfort weight, target offset and names are not
included. Heat-source baselines have their own fingerprint (sensor and
included sources).
On restore, a changed measurement recalibrates the zone: base and
extended loss/heat/bias keep their values as a warm start, but their
uncertainty, counts and readiness start over, other terms return to the
prior, and the zone's forecast statistics are dropped. Otherwise only
the changed terms are reset; everything else, including other zones,
is kept. Each recalibration is logged (Status model_changes, info log)
and saved at once. Data from before fingerprints (0.4.0) is assumed to
match its current configuration and is fingerprinted at the first
start, so the next edit is detected even before a learned sample.
Tests cover comfort changes, sensor replacement and weights, window
orientation and size, moved and replaced heat sources, re-added zones,
0.4.0 data and a second restart, plus an HA Core test through real
subentry updates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Validation: the walk-forward evaluator now replays all zones together
with the full model (electrical heat, neighbour exchange, ventilation
and recovery) using the parameters frozen at the forecast start and the
electrical heat and window openings that actually happened, and scores
the base 3-parameter model on the same spans. Forecast Error sensors
add base_model_mae and improvement_vs_base (share of the base model's
error the new features remove; 0 = no gain, negative = worse). Saved
0.3 statistics load with their own error as the base column.
On synthetic apartments, 60-minute error full vs base: gaming office
0.031 vs 0.182 °C (83 % better), bedroom with ventilation 0.026 vs
0.218 °C (88 %), two coupled rooms 0.024 vs 0.037 °C (34 %; gone by
120 min, where the constant-outdoor assumption dominates). The same
ventilation week without window sensors shows no improvement, as it
should. These check the method, not real-world accuracy.
Performance: every term of a zone's 5-minute change is affine in the
zone temperatures and the radiator lag, and which layer applies depends
only on which inputs exist. compile_step_model reads the coefficients
off the real model per zone and step, so the plan search does a few
multiply-adds per step. It matches the direct model to 1e-12 and makes
a 180-minute, 5-zone search 4.5x faster (0.73 s here, close to the old
base-only search).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each zone forecast entity now shows the current temperature, the
forecast at +15/+30/+60/+120 minutes (what the horizon covers), which
model layer is in use, and what drives the zone right now in °C/h:
envelope loss, boiler, bias, electrical heat, neighbours, ventilation
and recovery, plus each layer's coefficients with standard error,
confidence and whether it is identified. These attributes change every
poll and are not recorded.
Tests for inter-zone exchange on a two-room synthetic apartment (living
room and a colder bedroom, one boiler): with a true coupling of 0.01 the
learner finds a clearly positive value but, because the difference
between rooms is fairly steady and therefore collinear with each room's
bias, correctly reports it as not identified; with no coupling it stays
near zero.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a window adds heat exchange that the envelope model mistook for
poor insulation or a constant cooling bias.
windows.py adds windows as config subentries ("Add window"): a contact
sensor (optionally inverted; unknown/unavailable stays unknown, never
"closed"), its zone, the direction its outside faces (default 345°,
editable) and an opening factor. Each poll computes the share of the
interval each window was open (one change located by its timestamp, a
round trip is unknown), the wind exposure with proper circular angles
(meteorological "from" direction vs the window's facing) and a recovery
weight after closing that decays towards 0, pulling the air back
towards its pre-ventilation temperature. A bounded event log records
opening/closing, duration, temperatures before/lowest/at close/30 min
after, weather and other open windows.
While a zone's window is open, unknown or recovering, the base and
extended models skip it; a separate ventilation learner fits the
closed-window model's residual with natural exchange, wind speed, a
directional term bounded by the wind term (a leeward window still
ventilates), recovery, and optional gust excess and cross-ventilation
terms. Forecasts assume an open window closes after a configurable time
(default 15 min), then recovers. New poll outcome "ventilation".
Weather gains optional wind direction (degrees, 360 -> 0) and wind gust
sources with the same priorities and fallback; knots and ft/s accepted.
On a synthetic week with ventilation events every four hours and a
daily outdoor swing, the configured zone keeps loss/heat/bias close to
the truth and recovers the combined natural+wind effect within 15 %,
recovery within 15 % and direction within 30 %. The same data without
the window configured produces a -0.013 °C/5 min cooling bias and an
11 % inflated boiler effect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each temperature sensor used to be its own "room", so a room with two
sensors counted twice and comfort importance was tied to sensors.
zones.py adds Zone: a name, one or more sensors with sensor weights, a
comfort weight, an optional target offset and adjacent zones (made
symmetric). Sensor weights only combine readings; comfort weights only
steer the MPC; neither touches the learned thermal coefficients.
Zones are config subentries (HA's native "Add zone" / edit / delete on
the integration page), with validation that a sensor belongs to one
zone and is not an outdoor source, and a sensor-weight step for
multi-sensor zones. Initial setup still asks for room sensors and
weights and creates one zone per sensor. Configure no longer asks for
rooms or weights.
Config entry version 3. Migrating from 2 turns each room sensor into a
one-sensor zone with its comfort weight and the sensor's entity ID as
the zone key, so learned models, forecast entities (now linked to their
zone, so deleting a zone removes them) and accuracy statistics carry
over. Older versions cannot load a version 3 entry; rolling back needs
the HA backup.
A zone learns only across polls that used the same sensors (a sensor
dropping out shifts the mean by its offset); the new poll outcome
"sensors_changed" reports this. Status renames: input_quality.zones
(per-sensor detail nested), excluded_zones, retired_zone_models; adds
zone_names. Invalid stored comfort weights now fall back to 1.0, so the
invalid_weights status is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
During a first test the Status entity only said "learning" for the
first half hour (the radiator-lag wait after a restart), and the
attributes explaining why were hard to find. Add two sensors:
- Learning Progress (%): for the least trained room, the mean of its
three readiness requirements (200 samples, 30 ON, 30 OFF), each capped
at 100 %; 100 % means ready. Attributes show the counts.
- Last Sample: an enum of the last poll's learning outcome, with English
and Romanian state names (e.g. "Waiting for radiators to settle"). Its
history shows when learning happens.
Polls blocked by missing shared inputs now report waiting_for_inputs.
Readiness thresholds move to const.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was no evidence of how accurate the thermal model is: the only
number was an in-sample 5-minute error.
At every poll, record each room's reading, the outdoor temperature, the
radiator-lag estimate and the model parameters. 15, 30, 60 and 120
minutes later, replay the room from that poll with the parameters it
had then (out of sample), the outdoor temperature held constant and the
radiator lag that actually followed from the relay's real history, and
compare with the reading now. A no-change forecast is scored alongside.
Only spans with regular polls and a well-known lag are scored. 7 days
of per-room statistics are saved with the model (optional "evaluation"
payload key; about 18 KB at most).
Four new sensors, Heating MPC Forecast Error 15/30/60/120 min, show the
mean absolute error with bias, persistence error, skill and per-room
detail. On the synthetic lagged plant the 60-minute error is 0.03 °C
against 0.39 °C for persistence.
Room forecast entities gain forecast_15min and plan_15min attributes
and are labelled counterfactual. Volatile attributes are unrecorded.
The model is now saved after scoring, so saved statistics are current.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Choosing room and weather freshness limits required knowing how long
each sensor normally goes without reporting, which HA does not show.
Add max_age_seconds_24h to every source in input_quality: the largest
report age seen at polls over the last 24 hours (hourly buckets, reset
on reload, underestimates by up to one poll interval).
input_quality and weather_comparison change every poll, so the Recorder
stored a new attribute row every five minutes. Mark them unrecorded;
they remain visible on the Status entity. Model coefficients and other
attributes are still recorded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>