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()
|
||||
|
||||
@@ -253,3 +253,142 @@ class UnknownStateTests(unittest.TestCase):
|
||||
self.assertTrue(tracker.events[-1]['closed_confirmed'])
|
||||
inputs = tracker.zone_inputs('bed', WEATHER)
|
||||
self.assertEqual((inputs['known'], inputs['unknown_windows']), (False, ['B']))
|
||||
|
||||
|
||||
class StartupAndRestartTests(unittest.TestCase):
|
||||
"""Unknown at startup is not closed; saved state comes back as unknown, never live."""
|
||||
|
||||
WINDOWS = wm.build_windows([(window('w'), None)], ['bed'])
|
||||
|
||||
def setUp(self):
|
||||
self.t = 2_000_000.0
|
||||
|
||||
def poll(self, tracker, state, changed_ago=100_000, temp=20.0):
|
||||
tracker.update(self.t, {'w': (state, self.t - changed_ago)}, {'bed': temp}, WEATHER)
|
||||
result = (tracker.zone_inputs('bed', WEATHER), tracker.forecast('bed', 6, 15, WEATHER, self.t),
|
||||
tracker.assumption(self.WINDOWS[0], 15, self.t))
|
||||
self.t += 300
|
||||
return result
|
||||
|
||||
def saved_with(self, *states):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
for state in states:
|
||||
self.poll(tracker, state, changed_ago=200 if state == 'on' else 100_000, temp=21.0)
|
||||
return tracker, tracker.as_dict()
|
||||
|
||||
def test_unavailable_from_startup_is_unobserved_not_closed(self):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
for _ in range(3):
|
||||
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
self.assertEqual(assumed, {'basis': 'unobserved', 'open_minutes': 0.0, 'uncertain': True})
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['unknown_windows']), (False, True, ['W']))
|
||||
self.assertEqual(forecast[0]['opening'], 0.0) # no ventilation assumed, but flagged uncertain
|
||||
self.assertEqual((len(tracker.events), tracker._open_event), (0, {}))
|
||||
|
||||
def test_first_reading_open_is_an_unconfirmed_opening(self):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
self.poll(tracker, 'on', changed_ago=30) # we never saw it closed
|
||||
event = tracker._open_event['bed']
|
||||
self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None))
|
||||
self.poll(tracker, 'on')
|
||||
self.poll(tracker, 'off', changed_ago=60)
|
||||
event = tracker.events[-1]
|
||||
self.assertTrue(event['closed_confirmed'])
|
||||
# Opened at some unknown time before it was first seen: a minimum duration only.
|
||||
self.assertEqual((event['duration_minutes'], event['duration_max_minutes']), (None, None))
|
||||
self.assertEqual(event['duration_min_minutes'], 9.5)
|
||||
|
||||
def test_restart_with_saved_open_window_and_sensor_unavailable(self):
|
||||
before, saved = self.saved_with('off', 'off', 'on', 'on')
|
||||
self.assertEqual(saved['state']['windows']['w']['last_known'], True)
|
||||
self.assertIn('bed', saved['state']['zones'])
|
||||
self.t += 120 # restart takes two minutes
|
||||
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
|
||||
self.assertEqual(tracker.window_state, {}) # nothing is live before the first reading
|
||||
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
state = tracker.window_state['w']
|
||||
self.assertEqual((state['open'], state['last_known_open']), (None, True))
|
||||
self.assertEqual(state['unknown_since'], saved['state']['windows']['w']['seen'])
|
||||
# Assumed open only until 15 minutes after it was last seen (7 minutes ago).
|
||||
self.assertEqual((assumed['basis'], assumed['open_minutes'], assumed['uncertain']),
|
||||
('last_known_open', 8.0, True))
|
||||
self.assertEqual([s['opening'] for s in forecast[:3]], [1.0, 1.0, 0.0])
|
||||
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
|
||||
# The event from before the restart continues; the downtime counts as unknown.
|
||||
self.assertEqual(len(tracker.events), 0)
|
||||
event = tracker._open_event['bed']
|
||||
self.assertEqual((event['opened'], event['opened_confirmed']), (before._open_event['bed']['opened'], True))
|
||||
self.assertEqual(event['sensor_unknown_minutes'], 12.0) # 7 minutes down + one unknown poll
|
||||
# Not assumed open forever: past the bound the forecast assumes it closed.
|
||||
for _ in range(3):
|
||||
_, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
self.assertEqual((assumed['open_minutes'], forecast[0]['opening']), (0.0, 0.0))
|
||||
self.assertGreater(forecast[0]['recovery'], 0.0)
|
||||
# Back as closed: closed at an unknown time, so an unconfirmed close and recovery.
|
||||
inputs, _, _ = self.poll(tracker, 'off', changed_ago=30)
|
||||
event = tracker.events[-1]
|
||||
self.assertEqual((event['closed_confirmed'], event['duration_minutes']), (False, None))
|
||||
self.assertEqual(event['duration_min_minutes'], 8.3) # opened 3.3 min before a poll, seen open 5 min later
|
||||
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
|
||||
|
||||
def test_restart_with_saved_closed_window(self):
|
||||
_, saved = self.saved_with('off', 'off')
|
||||
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
|
||||
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
self.assertEqual((assumed['basis'], assumed['uncertain'], forecast[0]['opening']),
|
||||
('last_known_closed', True, 0.0))
|
||||
self.assertEqual((inputs['known'], len(tracker.events)), (False, 0))
|
||||
# It reports open: opened during the unknown period, so not a confirmed opening.
|
||||
self.poll(tracker, 'on', changed_ago=60)
|
||||
self.assertFalse(tracker._open_event['bed']['opened_confirmed'])
|
||||
|
||||
def test_restart_with_the_same_state_again_is_a_known_state(self):
|
||||
_, saved = self.saved_with('off', 'off')
|
||||
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
|
||||
self.poll(tracker, 'off') # first interval after restart: unknown
|
||||
inputs, _, assumed = self.poll(tracker, 'off')
|
||||
self.assertEqual((inputs['known'], inputs['affected'], assumed['basis']), (True, False, 'observed'))
|
||||
|
||||
def test_saved_state_of_another_sensor_or_polarity_is_ignored(self):
|
||||
_, saved = self.saved_with('off', 'off', 'on', 'on')
|
||||
for changed in ({'sensor': 'binary_sensor.other'}, {'inverted': True}):
|
||||
with self.subTest(changed=changed):
|
||||
windows = wm.build_windows([(window('w', **changed), None)], ['bed'])
|
||||
tracker = wm.VentilationTracker(windows, saved=saved)
|
||||
self.assertEqual((tracker._memory, tracker._open_event), ({}, {}))
|
||||
corrupt = {'events': [], 'state': {'windows': {'w': {'sensor': 'binary_sensor.w', 'last_known': 'yes',
|
||||
'seen': 'x'}}, 'zones': {'bed': 5}}}
|
||||
self.assertEqual(wm.VentilationTracker(self.WINDOWS, saved=corrupt)._memory, {})
|
||||
|
||||
def test_mixed_windows_each_get_their_own_assumption(self):
|
||||
windows = wm.build_windows([(window('a'), None), (window('b', opening_factor=0.5), None),
|
||||
(window('c', zone='living'), None)], ['bed', 'living'])
|
||||
tracker = wm.VentilationTracker(windows)
|
||||
t = 0.0
|
||||
for a, b, c in (('off', 'off', 'off'), ('off', 'on', 'off'), ('on', 'on', 'unavailable'),
|
||||
('on', 'unavailable', 'unavailable'), ('on', 'unavailable', 'unavailable'),
|
||||
('on', 'unavailable', 'unavailable'), ('on', 'unavailable', 'unavailable')):
|
||||
t += 300
|
||||
tracker.update(t, {'a': (a, t - 200), 'b': (b, t - 200), 'c': (c, t - 200)},
|
||||
{'bed': 20.0, 'living': 21.0}, WEATHER)
|
||||
# b was last seen open 20 minutes ago: no longer assumed open; a is observed open.
|
||||
self.assertEqual(tracker.assumption(windows[1], 15, t)['open_minutes'], 0.0)
|
||||
self.assertEqual(tracker.forecast('bed', 4, 15, WEATHER, t)[0]['opening'], 1.0)
|
||||
self.assertEqual(tracker.assumption(windows[2], 15, t)['basis'], 'last_known_closed')
|
||||
# An unknown window in one zone does not stop another zone with known windows.
|
||||
tracker2 = wm.VentilationTracker(windows)
|
||||
for t in (300.0, 600.0):
|
||||
tracker2.update(t, {'a': ('off', 0), 'b': ('off', 0), 'c': ('unavailable', 0)},
|
||||
{'bed': 20.0, 'living': 21.0}, WEATHER)
|
||||
self.assertEqual((tracker2.zone_inputs('bed', WEATHER)['known'],
|
||||
tracker2.zone_inputs('living', WEATHER)['known']), (True, False))
|
||||
|
||||
def test_changes_worth_saving_are_flagged(self):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
self.poll(tracker, 'off')
|
||||
self.assertTrue(tracker.changed) # first reading
|
||||
tracker.changed = False
|
||||
self.poll(tracker, 'off')
|
||||
self.assertFalse(tracker.changed) # nothing new
|
||||
self.poll(tracker, 'unavailable')
|
||||
self.assertTrue(tracker.changed)
|
||||
Reference in new issue
Block a user