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

+21 -1
View File
@@ -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.