Never publish a plan computed from inputs that have since changed

Confirmed: the check run after the optimizer compared only zone sensors,
outdoor temperature, thermostat and relay. A window opening, a UPS load
change or a wind change during the calculation went unnoticed; nothing
prevented an older update that finished late from overwriting a newer
result; and an update in flight during unload still saved.

- Every input a prediction uses is captured as a normalized snapshot
  (zone and outdoor temperatures, wind speed/direction/gust, heat-source
  power, window states, relay state and switch time, thermostat mode and
  target) and compared after the calculation with tolerances (0.25 °C,
  50 W, 1 m/s, 2 m/s gusts, 20° direction around the circle; exact for
  relay, thermostat and windows). A meaningful change discards the plan
  and lists the inputs in changed_inputs; noise does not. No immediate
  recalculation: an off-schedule poll would break the 5-minute learning
  interval; the next regular poll computes a fresh plan.
- Updates are numbered; one overtaken by a newer update, or running when
  the entry unloads, keeps the newer data and skips saving and
  optimizing. Configuration changes reload the entry, so they are covered.
- A per-entry storage lock makes a reloaded coordinator restore only
  after the previous one's save finished, and integration removal waits
  for it too, so a late save cannot recreate a deleted model file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 03:37:51 +02:00
1 parent 099c63cd02
commit d73e32a4b9
5 files changed
+235 -26

No files matched your search

+4 -1
View File
@@ -12,7 +12,7 @@ from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
from .weather_sources import sources_for from .weather_sources import sources_for
from .coordinator import HeatingMPCCoordinator from .coordinator import HeatingMPCCoordinator, storage_lock
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
@@ -62,7 +62,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove the learned model when this integration instance is deleted.""" """Remove the learned model when this integration instance is deleted."""
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
# Wait for a save still in progress, which would otherwise recreate the file.
async with storage_lock(hass, entry.entry_id):
await store.async_remove() await store.async_remove()
hass.data.get(f"{DOMAIN}_storage_locks", {}).pop(entry.entry_id, None)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+78 -25
View File
@@ -1,6 +1,7 @@
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions.""" """Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from collections import deque from collections import deque
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -21,7 +22,7 @@ from .const import (
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES, CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
) )
from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints
from .data_quality import ReportAgeHistory, freshness from .data_quality import ReportAgeHistory, freshness, inputs_differ
from .evaluation import ForecastEvaluator, model_snapshot from .evaluation import ForecastEvaluator, model_snapshot
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
from .learning import FeatureLearner from .learning import FeatureLearner
@@ -30,7 +31,9 @@ from .physics import (
lag_from_time_in_state, optimize, relay_segments, lag_from_time_in_state, optimize, relay_segments,
) )
from .weather_sources import choose_weather_sources, sources_for, normalize from .weather_sources import choose_weather_sources, sources_for, normalize
from .windows import SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, facing_cos, ventilation_vector from .windows import (
SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, contact_open, facing_cos, ventilation_vector,
)
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
@@ -43,6 +46,14 @@ MAX_MODEL_CHANGES = 20
BASE_TERMS = ("loss", "heat", "bias") BASE_TERMS = ("loss", "heat", "bias")
def storage_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
"""One lock per entry, shared by successive coordinators across reloads.
A reload's new coordinator restores only after the old one's save finished.
"""
return hass.data.setdefault(f"{DOMAIN}_storage_locks", {}).setdefault(entry_id, asyncio.Lock())
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]: def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
"""Zones from the entry's zone subentries; one zone per legacy room sensor otherwise.""" """Zones from the entry's zone subentries; one zone per legacy room sensor otherwise."""
subentries = getattr(entry, "subentries", None) or {} subentries = getattr(entry, "subentries", None) or {}
@@ -130,8 +141,22 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._relay_events_dropped = False self._relay_events_dropped = False
self._on_samples = 0 self._on_samples = 0
self._off_samples = 0 self._off_samples = 0
# Increases with every update; an older update finishing late never publishes.
self._run = 0
async def async_restore_model(self) -> None: async def async_restore_model(self) -> None:
async with storage_lock(self.hass, self.entry.entry_id):
await self._async_restore_model()
async def _async_save(self, outside_source: str | None) -> None:
async with storage_lock(self.hass, self.entry.entry_id):
await self._store.async_save(self._payload(outside_source))
def _stale(self, run: int) -> bool:
"""True when a newer update started or the entry is unloading."""
return run != self._run or getattr(self, "_shutdown_requested", False)
async def _async_restore_model(self) -> None:
stored = await self._store.async_load() or {} stored = await self._store.async_load() or {}
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2): if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
_LOGGER.warning("Ignoring unsupported Heating MPC model data") _LOGGER.warning("Ignoring unsupported Heating MPC model data")
@@ -189,7 +214,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Save at once: a recalibration is not redone after a restart, and data # Save at once: a recalibration is not redone after a restart, and data
# from before fingerprints gets them now, so the next edit is detected # from before fingerprints gets them now, so the next edit is detected
# even if it happens before the next learned sample. # even if it happens before the next learned sample.
await self._store.async_save(self._payload(stored.get("outside_source"))) await self._store.async_save(self._payload(stored.get("outside_source"))) # lock held
def _check_compatibility(self, stored: dict[str, Any] | None, base_prints: dict[str, str | None]) -> bool: def _check_compatibility(self, stored: dict[str, Any] | None, base_prints: dict[str, str | None]) -> bool:
"""Recalibrate exactly the components whose configuration changed.""" """Recalibrate exactly the components whose configuration changed."""
@@ -318,6 +343,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._relay_events_dropped = False self._relay_events_dropped = False
return steady return steady
def _superseded(self, status: dict[str, Any]) -> dict[str, Any]:
"""Result of an update that a newer one (or unloading) overtook: keep what is shown."""
current = getattr(self, "data", None)
return current if current is not None else {**status, "status": "inputs_changed", "proposal": None}
def _count_result(self, status: dict[str, Any], result: str) -> None: def _count_result(self, status: dict[str, Any], result: str) -> None:
"""Record how this poll was handled for learning, and the learning progress.""" """Record how this poll was handled for learning, and the learning progress."""
status["sample_result"] = result status["sample_result"] = result
@@ -435,22 +465,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return {"windows": windows, "ventilation": zones, return {"windows": windows, "ventilation": zones,
"ventilation_events": list(self._ventilation.events)[-5:]} "ventilation_events": list(self._ventilation.events)[-5:]}
def _prediction_inputs(self) -> tuple: def _input_snapshot(self) -> dict[tuple[str, str], Any]:
"""Values the prediction depends on, used to detect changes during calculation.""" """Normalized value of every input a prediction uses, keyed by (kind, entity).
heater = self.config[CONF_HEATER]
entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) | Compared (with tolerances, see data_quality.inputs_differ) before publishing,
{self.config[CONF_CLIMATE], heater}) so a result computed from inputs that have since changed is not shown.
values = [] """
for entity_id in entities: def value(entity_id: str, metric: str):
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
if state is None: return normalize(metric, state.state, state.attributes.get("unit_of_measurement")) if state else None
values.append((entity_id, None))
continue snapshot: dict[tuple[str, str], Any] = {}
attrs = state.attributes for entity_id in set(self.sensors) | set(sources_for(self.config, "temperature")):
values.append((entity_id, state.state, attrs.get("unit_of_measurement"), snapshot[("temperature", entity_id)] = value(entity_id, "temperature")
attrs.get("temperature"), attrs.get("temperature_unit"), for metric in ("wind", "wind_direction", "wind_gust"):
state.last_changed if entity_id == heater else None)) for entity_id in sources_for(self.config, metric):
return tuple(values) snapshot[(metric, entity_id)] = value(entity_id, metric)
for source in self.sources:
state = self.hass.states.get(source.sensor)
snapshot[("power", source.sensor)] = normalize_power(
state.state, state.attributes.get("unit_of_measurement")) if state else None
for window in self.windows:
state = self.hass.states.get(window.sensor)
snapshot[("window", window.sensor)] = contact_open(state.state if state else None, window.inverted)
heater = self.hass.states.get(self.config[CONF_HEATER])
snapshot[("relay", self.config[CONF_HEATER])] = (heater.state, heater.last_changed) if heater else None
climate = self.hass.states.get(self.config[CONF_CLIMATE])
snapshot[("thermostat", self.config[CONF_CLIMATE])] = (
climate.state, climate.attributes.get("temperature"), climate.attributes.get("temperature_unit")
) if climate else None
return snapshot
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -464,7 +508,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
} }
snapshot = {entity: self.hass.states.get(entity) snapshot = {entity: self.hass.states.get(entity)
for entity in set(self.sensors) | weather_entity_ids} for entity in set(self.sensors) | weather_entity_ids}
inputs_before = self._prediction_inputs() self._run += 1
run = self._run
inputs_before = self._input_snapshot()
sensor_quality = {} sensor_quality = {}
readings: dict[str, float | None] = {} readings: dict[str, float | None] = {}
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
@@ -649,9 +695,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.ventilation.get(room))) for room in vals}, self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals}) inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms) status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned: if learned and not self._stale(run):
# Persist at each sample so restart does not erase hours of training. # Persist at each sample so restart does not erase hours of training.
await self._store.async_save(self._payload(active_sources["temperature"])) await self._async_save(active_sources["temperature"])
if self._stale(run):
return self._superseded(status)
# Comfort weights are validated when zones are parsed (invalid -> 1.0). # Comfort weights are validated when zones are parsed (invalid -> 1.0).
weights = {r: self.zones[r].comfort_weight for r in self.rooms} weights = {r: self.zones[r].comfort_weight for r in self.rooms}
# Left-out zones take their weight with them; the rest are re-normalized. # Left-out zones take their weight with them; the rest are re-normalized.
@@ -718,12 +766,17 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]), step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
weather, now.timestamp()), weather, now.timestamp()),
) )
# Discard a result if a value used by the prediction changed while
# saving or while the worker calculated the hypothetical plan. Optional
# diagnostic weather sensors are not prediction inputs.
output = await self.hass.async_add_executor_job(job) output = await self.hass.async_add_executor_job(job)
if self._prediction_inputs() != inputs_before: if self._stale(run):
return self._superseded(status)
# Discard a result if an input it used changed meaningfully while saving
# or while the worker calculated the hypothetical plan. Not recomputed at
# once: an off-schedule poll would break the 5-minute learning interval;
# the next regular poll (at most 5 minutes) computes a fresh plan.
changed = inputs_differ(inputs_before, self._input_snapshot())
if changed:
status["status"] = "inputs_changed" status["status"] = "inputs_changed"
status["changed_inputs"] = [f"{kind}: {entity}" for kind, entity in changed[:10]]
return status return status
except (ValueError, RuntimeError, OverflowError) as err: except (ValueError, RuntimeError, OverflowError) as err:
_LOGGER.warning("MPC prediction failed: %s", err) _LOGGER.warning("MPC prediction failed: %s", err)
@@ -37,3 +37,37 @@ class ReportAgeHistory:
else: else:
buckets.append([hour, age_seconds]) buckets.append([hour, age_seconds])
return max(age for start, age in buckets if hour - 24 < start <= hour) return max(age for start, age in buckets if hour - 24 < start <= hour)
# How much an input may move while a prediction is computed before the result no
# longer describes the current situation. Below these, changes are sensor noise.
INPUT_TOLERANCES = {
"temperature": 0.25, # °C, zone sensors and outdoor sources
"power": 50.0, # W, heat-source sensors
"wind": 1.0, # m/s
"wind_gust": 2.0, # m/s
"wind_direction": 20.0, # degrees, compared around the circle
}
def inputs_differ(before: dict, after: dict) -> list:
"""Inputs whose change is meaningful: keys are (kind, entity), values normalized.
Kinds without a tolerance (relay, thermostat mode and target, window states,
configuration revision) must match exactly; a value appearing or disappearing
(for example a sensor becoming unavailable) always counts.
"""
changed = []
for key in sorted(set(before) | set(after), key=str):
old, new = before.get(key), after.get(key)
tolerance = INPUT_TOLERANCES.get(key[0])
if old is None or new is None or tolerance is None:
if old != new:
changed.append(key)
continue
difference = abs(new - old)
if key[0] == "wind_direction":
difference = min(difference, 360.0 - difference)
if difference > tolerance:
changed.append(key)
return changed
+103
View File
@@ -2,6 +2,7 @@
import importlib.util import importlib.util
import sys import sys
import types import types
import asyncio
import unittest import unittest
import unittest.mock import unittest.mock
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -1193,3 +1194,105 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
saved = without._payload('sensor.outside') saved = without._payload('sensor.outside')
back = await self.restored(saved, zones=[{**self.ZONES[0], 'sensors': ['sensor.other']}, self.ZONES[1]]) back = await self.restored(saved, zones=[{**self.ZONES[0], 'sensors': ['sensor.other']}, self.ZONES[1]])
self.assertEqual((back.learners['living'].theta, back.learners['living'].count), ([0.004, 0.12, 0.001], 0)) self.assertEqual((back.learners['living'].theta, back.learners['living'].count), ([0.004, 0.12, 0.001], 0))
def ready_office(self):
"""Office with a UPS and a window, trained enough to optimize."""
self.entry.subentries = {
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
'key': 'office', 'name': 'Office', 'sensors': ['sensor.room']}),
'h': types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office'}),
'w': types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
'key': 'w', 'name': 'Window', 'sensor': 'binary_sensor.office_window', 'zone': 'office'})}
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
self.set_state('binary_sensor.office_window', 'off')
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
base = self.c.learners['office']
base.count, base.on_count, base.off_count = 200, 100, 100
async def change_during_optimization(self, change):
async def worker(job):
output = job()
change()
return output
self.hass.async_add_executor_job = worker
return await self.sample()
async def test_meaningful_input_changes_during_optimization_discard_the_plan(self):
cases = {
'window: binary_sensor.office_window': lambda: setattr(self.states['binary_sensor.office_window'], 'state', 'on'),
'power: sensor.ups_power': lambda: setattr(self.states['sensor.ups_power'], 'state', '600'),
'thermostat: climate.reference': lambda: self.states['climate.reference'].attributes.update(temperature=21),
'temperature: sensor.room': lambda: setattr(self.states['sensor.room'], 'state', '20.6'),
}
for expected, change in cases.items():
with self.subTest(change=expected):
self.setUp()
self.ready_office()
data = await self.change_during_optimization(change)
self.assertEqual((data['status'], data['proposal'], data['changed_inputs']),
('inputs_changed', None, [expected]))
async def test_sensor_noise_during_optimization_does_not_discard_the_plan(self):
self.ready_office()
def noise():
self.states['sensor.room'].state = '20.1'
self.states['sensor.ups_power'].state = '230'
self.assertEqual((await self.change_during_optimization(noise))['status'], 'ready')
async def test_an_older_run_finishing_late_never_overwrites_a_newer_result(self):
self.ready_office()
release = asyncio.Event()
async def slow_then_fast(job):
if not release.is_set() and not getattr(self, '_first_started', False):
self._first_started = True
await release.wait()
return job()
self.hass.async_add_executor_job = slow_then_fast
with patch.object(coordinator, 'datetime') as clock:
clock.now.return_value = self.now
older = asyncio.ensure_future(self.c._async_update_data())
await asyncio.sleep(0)
clock.now.return_value = self.now + timedelta(seconds=60)
newer = await self.c._async_update_data()
self.c.data = newer # what DataUpdateCoordinator publishes
release.set()
result = await older
self.assertEqual(newer['status'], 'ready')
self.assertIs(result, newer)
async def test_unloading_during_save_or_optimization_publishes_nothing_new(self):
self.ready_office()
await self.sample() # a first sample to learn from
previous = self.c.data = {'status': 'learning', 'proposal': None}
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
async def unload_while_saving(payload):
self.c._shutdown_requested = True
self.c._store.async_save.side_effect = unload_while_saving
worker = AsyncMock(side_effect=AssertionError('no optimization after unload'))
self.hass.async_add_executor_job = worker
self.assertIs(await self.sample(), previous)
worker.assert_not_called()
async def test_restore_waits_for_a_save_still_in_progress(self):
self.ready_office()
order = []
release = asyncio.Event()
async def slow_save(payload):
order.append('save started')
await release.wait()
order.append('save finished')
self.c._store.async_save.side_effect = slow_save
saving = asyncio.ensure_future(self.c._async_save('sensor.outside'))
await asyncio.sleep(0)
successor = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
async def load():
order.append('load')
return None
successor._store.async_load.side_effect = load
restoring = asyncio.ensure_future(successor.async_restore_model())
await asyncio.sleep(0)
self.assertEqual(order, ['save started']) # the new coordinator waits
release.set()
await asyncio.gather(saving, restoring)
self.assertEqual(order, ['save started', 'save finished', 'load'])
+16
View File
@@ -46,6 +46,22 @@ class FreshnessTests(unittest.TestCase):
self.assertEqual(history.observe('sensor.a', later, 300), 300) self.assertEqual(history.observe('sensor.a', later, 300), 300)
self.assertEqual(history.observe('sensor.b', later, 30), 30) self.assertEqual(history.observe('sensor.b', later, 30), 30)
def test_inputs_differ_ignores_noise_but_not_meaningful_changes(self):
before = {('temperature', 'sensor.room'): 20.0, ('power', 'sensor.ups'): 210.0,
('wind_direction', 'sensor.dir'): 355.0, ('wind', 'sensor.wind'): 4.0,
('window', 'binary_sensor.w'): False, ('thermostat', 'climate.t'): ('heat', 22, None)}
noise = {**before, ('temperature', 'sensor.room'): 20.1, ('power', 'sensor.ups'): 240.0,
('wind_direction', 'sensor.dir'): 5.0, ('wind', 'sensor.wind'): 4.6}
self.assertEqual(quality.inputs_differ(before, noise), []) # 10° across north is small
for key, value in ((('temperature', 'sensor.room'), 20.5), (('power', 'sensor.ups'), 610.0),
(('wind_direction', 'sensor.dir'), 40.0), (('window', 'binary_sensor.w'), True),
(('window', 'binary_sensor.w'), None), (('thermostat', 'climate.t'), ('heat', 21, None)),
(('temperature', 'sensor.room'), None)):
with self.subTest(key=key, value=value):
self.assertEqual(quality.inputs_differ(before, {**before, key: value}), [key])
self.assertEqual(quality.inputs_differ(before, {**before, ('power', 'sensor.new'): 5.0}),
[('power', 'sensor.new')])
def test_freshness_settings_translated(self): def test_freshness_settings_translated(self):
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'): for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
data = json.loads((ROOT / name).read_text()) data = json.loads((ROOT / name).read_text())