Treat window states unknown at startup as unknown, and continue across restarts

Reproduced: with a window sensor already unavailable at startup, the
forecast assumed the window closed, exactly as for a window last seen
closed, and the first reading of an already open window was logged as a
confirmed opening.

- An opening or closing is confirmed only when witnessed: the previous
  reading was the other state with no unknown period between. A first
  reading, or a change across an outage or restart, is unconfirmed.
- Last known states, open events and recovery are saved (also whenever a
  window's state changes) and restored as unknown until the sensor
  reports; saved state for another sensor or polarity is ignored. Downtime
  counts as unknown time of an interrupted event.
- Forecast assumption per window: observed; last seen open, assumed open
  only until the configured open time after it was last seen (never
  renewed); last seen closed or never observed, no ventilation but marked
  uncertain. Status shows each window's assumption; zone forecasts list
  their uncertain windows.
- Events report a duration only when both ends are confirmed, otherwise a
  minimum (and a maximum when the opening was seen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 04:41:28 +02:00
1 parent 49565d610a
commit 13cb972566
5 files changed
+353 -55

No files matched your search

+35 -1
View File
@@ -1219,6 +1219,35 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
base = self.c.learners['office']
base.count, base.on_count, base.off_count = 200, 100, 100
async def test_reload_with_an_unavailable_window_keeps_its_last_known_state_uncertain(self):
self.ready_office()
await self.sample()
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
window = self.states['binary_sensor.office_window']
window.state, window.last_changed = 'on', self.now - timedelta(seconds=100)
self.c._store.async_save.reset_mock()
await self.sample()
self.c._store.async_save.assert_awaited() # a window change is saved even without learning
payload = self.c._store.async_save.await_args.args[0]
# Reload: a new coordinator restores the saved state; the sensor is now unavailable.
window.state = 'unavailable'
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
successor = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
successor._store.async_load.return_value = payload
await successor.async_restore_model()
base = successor.learners['office']
base.count, base.on_count, base.off_count = 200, 100, 100
successor._update_counts()
self.c = successor
data = await self.sample()
info = data['windows']['Window']
self.assertEqual((info['state'], info['last_known_state'], info['forecast_assumption']),
('unknown', 'open', 'last_known_open'))
self.assertGreater(info['forecast_open_minutes'], 0)
self.assertEqual(data['zone_details']['office']['uncertain_windows'], ['Window'])
self.assertTrue(data['ventilation']['Office']['affected'])
self.assertEqual(data['sample_result'], 'first_sample') # nothing learned across the reload
async def change_during_optimization(self, change):
async def worker(job):
output = job()
@@ -1261,7 +1290,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
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)
for _ in range(50): # until the older run is inside the optimizer (it may save first)
if getattr(self, '_first_started', False):
break
await asyncio.sleep(0)
self.assertTrue(self._first_started)
clock.now.return_value = self.now + timedelta(seconds=60)
newer = await self.c._async_update_data()
self.c.data = newer # what DataUpdateCoordinator publishes
@@ -1333,6 +1366,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
async def test_nothing_is_learned_saved_or_recorded_once_unloading_began(self):
self.ready_office()
await self.sample()
self.c._store.async_save.reset_mock() # the window's first reading is saved
shown = self.c.data = {'status': 'learning', 'proposal': None}
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
self.c.async_begin_shutdown()