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
+236 -27

No files matched your search

+103
View File
@@ -2,6 +2,7 @@
import importlib.util
import sys
import types
import asyncio
import unittest
import unittest.mock
from datetime import datetime, timedelta, timezone
@@ -1193,3 +1194,105 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
saved = without._payload('sensor.outside')
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))
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'])