diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 375a973..5646dd8 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -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]), diff --git a/custom_components/heating_mpc/physics.py b/custom_components/heating_mpc/physics.py index e455819..33f4a69 100644 --- a/custom_components/heating_mpc/physics.py +++ b/custom_components/heating_mpc/physics.py @@ -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. diff --git a/tests/ha/conftest.py b/tests/ha/conftest.py index 70e49e7..00ef69b 100644 --- a/tests/ha/conftest.py +++ b/tests/ha/conftest.py @@ -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"}) diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index 27d2034..758135e 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -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 diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 6864173..04c78ed 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -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) diff --git a/tests/test_physics.py b/tests/test_physics.py index bebd8c3..dd3836f 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -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()