Track radiator lag across relay changes

The lag proxy was reset to the new relay command at every relay change
and after any skipped interval, so it read 1.0 straight after ON and 0.0
straight after OFF. This disabled the lag model around every boiler
cycle: on a synthetic lagged plant cycled by a hysteresis thermostat the
learner's heat gain came out 34% low (0.079 vs 0.12) and the loss term
43% low, and the optimizer started post-transition plans from the wrong
lag.

Track the lag at every poll with a valid relay state, independently of
room sensors. Integrate a single change between polls at its
last_changed time. For ambiguous cases (round trip between polls, relay
outage, restart, clock jump) restart from time-in-state with an explicit
error half-width, and learn only once it is at most 0.1 (about 32 min
after an HA restart). The same synthetic test now learns 0.1195/0.00389
(true 0.12/0.004).

Expose heat_lag, heat_lag_uncertainty and sample_result on Status so
users can see why a poll was or was not learned. The HA test fixture
relay now has an hour-old timestamp; a new test covers a freshly
written relay state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 21:21:14 +02:00
1 parent 387929b01b
commit c3646ba876
6 files changed
+220 -28

No files matched your search

+66 -26
View File
@@ -19,7 +19,10 @@ from .const import (
CONF_BAND, DEFAULTS, CONF_BAND, DEFAULTS,
) )
from .data_quality import freshness from .data_quality import freshness
from .physics import Learner, optimize from .physics import (
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
lag_from_time_in_state, optimize,
)
from .weather_sources import choose_weather_sources, sources_for, normalize from .weather_sources import choose_weather_sources, sources_for, normalize
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -36,7 +39,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
self._previous: dict[str, Any] | None = None self._previous: dict[str, Any] | None = None
self._heat_lag: float | None = None # Radiator-lag proxy at the last poll with a valid relay state. Tracked
# independently of room sensors so lag survives room outages and transitions.
self._lag: dict[str, Any] | None = None
self._on_samples = 0 self._on_samples = 0
self._off_samples = 0 self._off_samples = 0
@@ -73,6 +78,34 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)), return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
now, limit * 60) now, limit * 60)
def _track_lag(self, now: datetime, heater_on: bool, last_changed: datetime) -> bool:
"""Advance the lag proxy to `now`; return True if the relay held one state throughout.
A single relay change since the last poll is integrated at its timestamp.
Anything ambiguous (several changes, an outage, clock jumps) restarts the
estimate from the time spent in the current state, with an explicit error bound.
"""
tracker = self._lag
steady = False
lag = uncertainty = None
if tracker is not None:
dt = (now - tracker["time"]).total_seconds()
if dt > 0 and heater_on == tracker["heater_on"] and last_changed <= tracker["time"]:
lag = advance_lag(tracker["lag"], heater_on, dt)
steady = True
elif dt > 0 and heater_on != tracker["heater_on"] and tracker["time"] < last_changed <= now:
before = (last_changed - tracker["time"]).total_seconds()
lag = advance_lag(tracker["lag"], tracker["heater_on"], before)
lag = advance_lag(lag, heater_on, (now - last_changed).total_seconds())
if lag is not None:
# The lag update is a contraction, so an initial error decays the same way.
uncertainty = tracker["uncertainty"] * exp(-dt / LAG_TAU_SECONDS)
if lag is None:
lag, uncertainty = lag_from_time_in_state(
heater_on, (now - last_changed).total_seconds())
self._lag = {"time": now, "heater_on": heater_on, "lag": lag, "uncertainty": uncertainty}
return steady
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
heater = self.hass.states.get(self.config[CONF_HEATER]) heater = self.hass.states.get(self.config[CONF_HEATER])
@@ -131,34 +164,44 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v}, "weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
"weather_model_inputs": ["temperature"], "weather_model_inputs": ["temperature"],
} }
if heater is None or heater.state not in ("on", "off") or climate is None: heater_valid = heater is not None and heater.state in ("on", "off")
heater_on = heater_valid and heater.state == "on"
steady = False
if heater_valid:
steady = self._track_lag(now, heater_on, heater.last_changed)
status["heat_lag"] = round(self._lag["lag"], 3)
status["heat_lag_uncertainty"] = round(self._lag["uncertainty"], 3)
else:
self._lag = None
if (not heater_valid or climate is None or outside is None or
any(temp is None for temp in temps.values())):
self._previous = None self._previous = None
self._heat_lag = None
return status
if outside is None or any(temp is None for temp in temps.values()):
self._previous = None
self._heat_lag = None
return status return status
# No casting surprises: all rooms are valid here. # No casting surprises: all rooms are valid here.
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms} vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms}
heater_on = heater.state == "on" if self._previous is None:
if self._heat_lag is None: status["sample_result"] = "first_sample"
self._heat_lag = float(heater_on) else:
if self._previous is not None:
dt = (now - self._previous["time"]).total_seconds() dt = (now - self._previous["time"]).total_seconds()
# Do not fit one transition across a provider/fallback switch. # Do not fit across a relay change, a provider/fallback switch or an
if (270 <= dt <= 330 and # uncertain lag estimate (restart, relay outage, several changes).
heater.last_changed <= self._previous["time"] and if not 270 <= dt <= 330:
heater_on == self._previous["heater_on"] and status["sample_result"] = "interval_out_of_range"
self._previous["outside_source"] == active_sources["temperature"]): elif not steady or heater_on != self._previous["heater_on"]:
next_lag = self._heat_lag + (1 - exp(-dt / 1200)) * (float(self._previous["heater_on"]) - self._heat_lag) status["sample_result"] = "relay_changed"
for room in self.rooms: elif self._previous["outside_source"] != active_sources["temperature"]:
status["sample_result"] = "outside_source_changed"
elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY:
status["sample_result"] = "lag_uncertain"
else:
accepted = [
self.learners[room].learn( self.learners[room].learn(
self._previous["temps"][room], vals[room], self._previous["temps"][room], vals[room],
self._previous["outside"], next_lag, dt, self._previous["heater_on"] self._previous["outside"], self._lag["lag"], dt, heater_on)
) for room in self.rooms
self._heat_lag = next_lag ]
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts() self._update_counts()
# Persist at each sample so restart does not erase hours of training. # Persist at each sample so restart does not erase hours of training.
await self._store.async_save({ await self._store.async_save({
@@ -168,9 +211,6 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"heater_entity": self.config[CONF_HEATER], "heater_entity": self.config[CONF_HEATER],
"outside_source": active_sources["temperature"], "outside_source": active_sources["temperature"],
}) })
else:
# Model lag state becomes uncertain after long gaps.
self._heat_lag = float(heater_on)
self._previous = { self._previous = {
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on, "time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
@@ -224,7 +264,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# never block Home Assistant's event loop with CPU work. # never block Home Assistant's event loop with CPU work.
job = partial( job = partial(
optimize, optimize,
vals, outside, self._heat_lag, target, weights, self.learners, vals, outside, self._lag["lag"], target, weights, self.learners,
heater_on, (now - heater.last_changed).total_seconds(), heater_on, (now - heater.last_changed).total_seconds(),
horizon_minutes=int(self.config[CONF_HORIZON]), horizon_minutes=int(self.config[CONF_HORIZON]),
minimum_on_minutes=int(self.config[CONF_MIN_ON]), minimum_on_minutes=int(self.config[CONF_MIN_ON]),
+21 -1
View File
@@ -11,7 +11,11 @@ from math import exp, isfinite
from typing import Any from typing import Any
STEP_MINUTES = 5 STEP_MINUTES = 5
HEAT_LAG = 1.0 - exp(-STEP_MINUTES / 20.0) # First-order radiator lag between the relay command and the heat reaching rooms.
LAG_TAU_SECONDS = 1200.0
HEAT_LAG = 1.0 - exp(-STEP_MINUTES * 60 / LAG_TAU_SECONDS)
# Learn only when the lag proxy is known within this half-width.
LAG_LEARN_MAX_UNCERTAINTY = 0.1
# Prior covariance; also the upper bound that stops RLS windup (see _bound_covariance). # Prior covariance; also the upper bound that stops RLS windup (see _bound_covariance).
PRIOR_P = ((0.0005, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 0.08)) PRIOR_P = ((0.0005, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 0.08))
@@ -20,6 +24,22 @@ def bound(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value)) return max(lo, min(hi, value))
def advance_lag(lag: float, command: bool, seconds: float) -> float:
"""Move the lag proxy towards the relay command over `seconds`."""
return lag + (1.0 - exp(-seconds / LAG_TAU_SECONDS)) * (float(command) - lag)
def lag_from_time_in_state(command: bool, seconds_in_state: float) -> tuple[float, float]:
"""Estimate the lag when only the time since the last relay change is known.
Starting from any unknown lag in [0, 1], `seconds_in_state` of a constant command
leaves the lag inside an interval of width exp(-t/tau). Return its midpoint and
half-width, so the error bound is explicit rather than assuming a steady state.
"""
width = exp(-max(0.0, seconds_in_state) / LAG_TAU_SECONDS)
return (1.0 - width / 2) if command else width / 2, width / 2
def _bound_covariance(p: list[list[float]]) -> None: def _bound_covariance(p: list[list[float]]) -> None:
"""Keep P symmetric and no less certain than the prior, in place. """Keep P symmetric and no less certain than the prior, in place.
+3 -1
View File
@@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant.core import ServiceRegistry from homeassistant.core import ServiceRegistry
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -36,5 +37,6 @@ def entry():
def inputs(hass): def inputs(hass):
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"}) hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"})
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"}) hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"})
hass.states.async_set("switch.boiler", "off") # The relay has held its state for an hour, so the radiator-lag estimate is settled.
hass.states.async_set("switch.boiler", "off", timestamp=dt_util.utcnow().timestamp() - 3600)
hass.states.async_set("climate.reference", "heat", {"temperature": 22, "preset_mode": "comfort"}) hass.states.async_set("climate.reference", "heat", {"temperature": 22, "preset_mode": "comfort"})
+14
View File
@@ -146,3 +146,17 @@ async def test_startup_missing_inputs_then_recovery(hass, entry):
await coordinator.async_refresh() await coordinator.async_refresh()
assert coordinator.data["status"] == "learning" assert coordinator.data["status"] == "learning"
assert coordinator.data["samples"] == 0 assert coordinator.data["samples"] == 0
async def test_freshly_written_relay_state_defers_learning(hass, entry, inputs, freezer):
"""After an HA restart the relay's last_changed is new, so radiator lag is unknown."""
hass.states.async_set("switch.boiler", "on")
coordinator = await setup(hass, entry)
assert coordinator.data["heat_lag"] == 0.5
assert coordinator.data["heat_lag_uncertainty"] == 0.5
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.data["sample_result"] == "lag_uncertain"
assert coordinator.learners["sensor.room"].count == 0
+102
View File
@@ -4,6 +4,7 @@ import sys
import types import types
import unittest import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import exp, pi, sin
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
@@ -92,6 +93,12 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.now += timedelta(seconds=seconds) self.now += timedelta(seconds=seconds)
return data return data
def switch(self, state, seconds_before_next_sample):
"""Change the relay between polls; `self.now` is already the next poll time."""
heater = self.states['switch.boiler']
heater.state = state
heater.last_changed = self.now - timedelta(seconds=seconds_before_next_sample)
def ready(self): def ready(self):
m = self.c.learners['sensor.room'] m = self.c.learners['sensor.room']
m.count, m.on_count, m.off_count = 200, 100, 100 m.count, m.on_count, m.off_count = 200, 100, 100
@@ -310,3 +317,98 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
data = await self.sample() data = await self.sample()
self.assertEqual(data['input_quality']['weather']['temperature']['sensor.outside']['status'], self.assertEqual(data['input_quality']['weather']['temperature']['sensor.outside']['status'],
'invalid_value_or_unit') 'invalid_value_or_unit')
async def test_lag_is_continuous_across_relay_transitions(self):
advance = coordinator.advance_lag
await self.sample()
lag = self.c._lag['lag']
self.switch('on', 150)
data = await self.sample()
lag = advance(advance(lag, False, 150), True, 150)
self.assertEqual(data['sample_result'], 'relay_changed')
self.assertAlmostEqual(self.c._lag['lag'], lag)
self.assertLess(lag, 0.2) # radiators have only started warming, not 1.0
data = await self.sample()
lag = advance(lag, True, 300)
self.assertEqual(data['sample_result'], 'learned')
self.assertAlmostEqual(self.c._lag['lag'], lag)
self.switch('off', 100)
await self.sample()
lag = advance(advance(lag, True, 200), False, 100)
self.assertAlmostEqual(self.c._lag['lag'], lag)
self.assertGreater(lag, 0.3) # residual radiator heat is kept, not 0.0
async def test_prediction_starts_from_tracked_lag(self):
self.ready()
self.set_state('switch.boiler', 'on')
await self.sample()
self.switch('off', 60)
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
data = await self.sample()
self.assertEqual(data['status'], 'ready')
self.assertAlmostEqual(spy.call_args.args[2], self.c._lag['lag'])
self.assertGreater(spy.call_args.args[2], 0.9)
async def test_round_trip_between_polls_restarts_lag_estimate(self):
await self.sample()
self.switch('off', 60) # off -> on -> off; only the last change is visible
data = await self.sample()
self.assertEqual(data['sample_result'], 'relay_changed')
self.assertEqual(data['heat_lag_uncertainty'], round(exp(-60 / 1200) / 2, 3))
data = await self.sample()
self.assertEqual(data['sample_result'], 'lag_uncertain')
self.assertEqual(self.c.learners['sensor.room'].count, 0)
async def test_learning_waits_until_lag_settles_after_restart(self):
self.states['switch.boiler'].last_changed = self.now # HA restart resets last_changed
results = []
for _ in range(8):
for entity in ('sensor.room', 'sensor.outside'):
self.states[entity].last_reported = self.now
results.append((await self.sample())['sample_result'])
self.assertEqual(results[0], 'first_sample')
self.assertEqual(results[1:7], ['lag_uncertain'] * 6)
self.assertEqual(results[7], 'learned')
async def test_room_outage_keeps_relay_lag_tracking(self):
await self.sample()
self.switch('on', 150)
self.states['sensor.room'].state = 'unavailable'
data = await self.sample()
self.assertEqual(data['status'], 'waiting_for_sensors')
self.assertLess(data['heat_lag'], 0.2)
async def test_relay_outage_restarts_lag_estimate(self):
await self.sample()
self.states['switch.boiler'].state = 'unavailable'
data = await self.sample()
self.assertNotIn('heat_lag', data)
self.switch('off', 30)
data = await self.sample()
self.assertEqual(data['sample_result'], 'first_sample')
self.assertGreater(data['heat_lag_uncertainty'], 0.4)
async def test_heat_gain_identified_with_mid_interval_switching(self):
"""Synthetic plant with radiator lag, cycled by a hysteresis thermostat."""
a, b, c = 0.004, 0.12, 0.0005
advance = coordinator.advance_lag
self.states['climate.reference'].state = 'off' # learn only
temp, lag, on = 21.0, 0.0, False
for k in range(1500):
outside = 5 + 5 * sin(2 * pi * k / 288)
for entity, value in (('sensor.room', temp), ('sensor.outside', outside)):
self.states[entity].state = f'{value:.5f}'
self.states[entity].last_reported = self.now
await self.sample()
command = temp < 20.7 or (on and temp < 21.3)
if command != on: # relay switches 120 s after this poll
self.switch('on' if command else 'off', 180)
lag = advance(advance(lag, on, 120), command, 180)
else:
lag = advance(lag, on, 300)
on = command
temp += a * (outside - temp) + b * lag + c
model = self.c.learners['sensor.room']
self.assertGreater(model.on_count, 100)
self.assertAlmostEqual(model.theta[1], b, delta=0.1 * b)
self.assertAlmostEqual(model.theta[0], a, delta=0.25 * a)
+14
View File
@@ -135,5 +135,19 @@ class ModelTests(unittest.TestCase):
self.assertEqual(restored.p[0][0], m.PRIOR_P[0][0]) self.assertEqual(restored.p[0][0], m.PRIOR_P[0][0])
self.assertEqual(restored.p[0][1], restored.p[1][0]) self.assertEqual(restored.p[0][1], restored.p[1][0])
def test_lag_helpers(self):
self.assertAlmostEqual(m.advance_lag(0, True, 300), m.HEAT_LAG)
self.assertAlmostEqual(m.advance_lag(1, False, 1e9), 0)
self.assertEqual(m.lag_from_time_in_state(True, 0), (0.5, 0.5))
lag, half_width = m.lag_from_time_in_state(False, 3600)
self.assertAlmostEqual(lag, half_width)
self.assertLess(half_width, 0.03)
# Any starting lag in [0, 1] ends inside the stated interval.
for start in (0.0, 0.3, 1.0):
for command in (False, True):
lag, half_width = m.lag_from_time_in_state(command, 900)
self.assertLessEqual(abs(m.advance_lag(start, command, 900) - lag), half_width + 1e-12)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()