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
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import ServiceRegistry
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
|
||||
@@ -36,5 +37,6 @@ def entry():
|
||||
def inputs(hass):
|
||||
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("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"})
|
||||
@@ -146,3 +146,17 @@ async def test_startup_missing_inputs_then_recovery(hass, entry):
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.data["status"] == "learning"
|
||||
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
|
||||
@@ -4,6 +4,7 @@ import sys
|
||||
import types
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import exp, pi, sin
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -92,6 +93,12 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.now += timedelta(seconds=seconds)
|
||||
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):
|
||||
m = self.c.learners['sensor.room']
|
||||
m.count, m.on_count, m.off_count = 200, 100, 100
|
||||
@@ -310,3 +317,98 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['input_quality']['weather']['temperature']['sensor.outside']['status'],
|
||||
'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)
|
||||
@@ -135,5 +135,19 @@ class ModelTests(unittest.TestCase):
|
||||
self.assertEqual(restored.p[0][0], m.PRIOR_P[0][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__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user