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:
1 parent
49565d610a
commit
13cb972566
5 files changed
+353
-55
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user