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:
1 parent
387929b01b
commit
c3646ba876
6 files changed
+220
-28
No files matched your search
@@ -19,7 +19,10 @@ from .const import (
|
||||
CONF_BAND, DEFAULTS,
|
||||
)
|
||||
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
|
||||
|
||||
_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._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
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._off_samples = 0
|
||||
|
||||
@@ -73,6 +78,34 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
|
||||
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]:
|
||||
now = datetime.now(timezone.utc)
|
||||
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_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._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
|
||||
|
||||
# No casting surprises: all rooms are valid here.
|
||||
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms}
|
||||
heater_on = heater.state == "on"
|
||||
if self._heat_lag is None:
|
||||
self._heat_lag = float(heater_on)
|
||||
if self._previous is not None:
|
||||
if self._previous is None:
|
||||
status["sample_result"] = "first_sample"
|
||||
else:
|
||||
dt = (now - self._previous["time"]).total_seconds()
|
||||
# Do not fit one transition across a provider/fallback switch.
|
||||
if (270 <= dt <= 330 and
|
||||
heater.last_changed <= self._previous["time"] and
|
||||
heater_on == self._previous["heater_on"] and
|
||||
self._previous["outside_source"] == active_sources["temperature"]):
|
||||
next_lag = self._heat_lag + (1 - exp(-dt / 1200)) * (float(self._previous["heater_on"]) - self._heat_lag)
|
||||
for room in self.rooms:
|
||||
# Do not fit across a relay change, a provider/fallback switch or an
|
||||
# uncertain lag estimate (restart, relay outage, several changes).
|
||||
if not 270 <= dt <= 330:
|
||||
status["sample_result"] = "interval_out_of_range"
|
||||
elif not steady or heater_on != self._previous["heater_on"]:
|
||||
status["sample_result"] = "relay_changed"
|
||||
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._previous["temps"][room], vals[room],
|
||||
self._previous["outside"], next_lag, dt, self._previous["heater_on"]
|
||||
)
|
||||
self._heat_lag = next_lag
|
||||
self._previous["outside"], self._lag["lag"], dt, heater_on)
|
||||
for room in self.rooms
|
||||
]
|
||||
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
|
||||
self._update_counts()
|
||||
# Persist at each sample so restart does not erase hours of training.
|
||||
await self._store.async_save({
|
||||
@@ -168,9 +211,6 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
else:
|
||||
# Model lag state becomes uncertain after long gaps.
|
||||
self._heat_lag = float(heater_on)
|
||||
|
||||
self._previous = {
|
||||
"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.
|
||||
job = partial(
|
||||
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(),
|
||||
horizon_minutes=int(self.config[CONF_HORIZON]),
|
||||
minimum_on_minutes=int(self.config[CONF_MIN_ON]),
|
||||
|
||||
@@ -11,7 +11,11 @@ from math import exp, isfinite
|
||||
from typing import Any
|
||||
|
||||
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_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))
|
||||
|
||||
|
||||
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:
|
||||
"""Keep P symmetric and no less certain than the prior, in place.
|
||||
|
||||
|
||||
Reference in new issue
Block a user