Timestamp relay changes with a read-only state listener

Polling only sees the relay's current state and last change, so two or
more changes between polls (e.g. an on/off round trip) made the lag
estimate unknown and paused learning for about half an hour.

Subscribe to the relay's state changes (read-only) and integrate every
recorded change between polls. Because HA delivers these events one
loop iteration late, the current state stays authoritative and fills in
a change not yet recorded. Unknown periods (non on/off states, missed
transitions, event overflow) still restart the estimate with an
explicit error bound. Without the listener, behavior is as before.

HA skips unload callbacks after unexpected setup errors, so setup now
removes the listener itself on any failure and registers it for unload
only once setup succeeds.

Tested on HA Core 2025.12.0 and 2026.2.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 21:49:34 +02:00
1 parent af0a564279
commit 955c679f5e
6 files changed
+185 -22

No files matched your search

+47 -16
View File
@@ -2,12 +2,13 @@
from __future__ import annotations
import logging
from collections import deque
from datetime import datetime, timedelta, timezone
from functools import partial
from math import exp, isfinite
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -21,11 +22,13 @@ from .const import (
from .data_quality import freshness
from .physics import (
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
lag_from_time_in_state, optimize,
lag_from_time_in_state, optimize, relay_segments,
)
from .weather_sources import choose_weather_sources, sources_for, normalize
_LOGGER = logging.getLogger(__name__)
# Relay changes kept between polls; more than this in five minutes is chatter.
MAX_RELAY_EVENTS = 256
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -43,6 +46,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# 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
# (time, new state) of relay changes since the last poll, from a read-only listener.
self._relay_events: deque[tuple[datetime, str]] = deque(maxlen=MAX_RELAY_EVENTS)
self._relay_events_dropped = False
self._on_samples = 0
self._off_samples = 0
@@ -79,32 +85,55 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
now, limit * 60)
@callback
def async_relay_changed(self, event: Event) -> None:
"""Record relay state changes as they happen; read-only state listener."""
old, new = event.data.get("old_state"), event.data.get("new_state")
if new is None:
self.record_relay_state(event.time_fired, "removed")
elif old is None or old.state != new.state:
self.record_relay_state(new.last_changed, new.state)
def record_relay_state(self, when: datetime, state: str) -> None:
if len(self._relay_events) == self._relay_events.maxlen:
self._relay_events_dropped = True
self._relay_events.append((when, state))
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.
Relay changes since the last poll are integrated at their timestamps. Anything
ambiguous (missed 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:
if tracker is not None and now > tracker["time"]:
start = tracker["time"]
changes = [(when, state) for when, state in self._relay_events if start < when <= now]
# The current state is authoritative; its change event may still be queued,
# and without a listener it is the only change visible.
if last_changed > start and (not changes or changes[-1][0] < last_changed):
changes.append((last_changed, "on" if heater_on else "off"))
segments = None if self._relay_events_dropped else relay_segments(
tracker["heater_on"], start.timestamp(),
[(when.timestamp(), state) for when, state in changes], now.timestamp())
if segments is not None and segments[-1][0] == heater_on:
lag = tracker["lag"]
for command, seconds in segments:
lag = advance_lag(lag, command, seconds)
# The lag update is a contraction, so an initial error decays the same way.
uncertainty = tracker["uncertainty"] * exp(-dt / LAG_TAU_SECONDS)
uncertainty = tracker["uncertainty"] * exp(
-(now - start).total_seconds() / LAG_TAU_SECONDS)
steady = len(segments) == 1
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}
self._relay_events = deque((e for e in self._relay_events if e[0] > now),
maxlen=MAX_RELAY_EVENTS)
self._relay_events_dropped = False
return steady
def _prediction_inputs(self) -> tuple:
@@ -191,6 +220,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
status["heat_lag_uncertainty"] = round(self._lag["uncertainty"], 3)
else:
self._lag = None
self._relay_events.clear()
self._relay_events_dropped = False
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