Keep unknown window states from passing as closed

Confirmed: when an open window's contact sensor became unknown or
unavailable, the tracker ended the ventilation event as a close at the
poll time, started recovery at full weight and forecasts assumed the
window closed. (Learning was already excluded for that zone.)

Each window is now OPEN, CLOSED or UNKNOWN with its last known state,
since when, and since when it is unknown:

- an unknown sensor keeps an open window's event (and the zone's
  "open" status) going; forecasts use the last known state;
- a close observed only after an outage is recorded as unconfirmed:
  closed=None with the reported time and a maximum duration; recovery
  is assumed from that report but not used for any learning;
- opening seen only after an outage starts an event without a
  temperature-before, so its recovery is not learned either;
- events count minutes with an unknown sensor; Status shows each
  window's last known state and unknown minutes;
- one unknown sensor only blocks learning for its own zone.

Predictions with an unavailable wind sensor now keep an open window's
natural ventilation (calm air) instead of dropping the term; learning
still refuses unknown inputs.

Regression tests for every OPEN/CLOSED/UNKNOWN transition, a six-hour
outage, multiple windows with one unknown, and missing wind during
ventilation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 03:28:06 +02:00
1 parent 17dbe76223
commit 2bddc74d37
6 files changed
+236 -34

No files matched your search

+21
View File
@@ -1066,3 +1066,24 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
await grown.async_restore_model()
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta)
async def test_unavailable_wind_during_ventilation(self):
self.c = self.bedroom_with_window()
for _ in range(2):
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
await self.sample()
self.open_window('on', 240)
self.states['sensor.wind'].state = 'unavailable'
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind_dir')
data = await self.sample()
# Learning needs the wind: nothing is fitted, the envelope model stays out too.
self.assertEqual(data['sample_result'], 'ventilation')
self.assertEqual((self.c.ventilation['bed'].count, self.c.learners['bed'].count), (0, 1))
# Predictions keep the open window, as calm air, rather than dropping it.
vent = self.c.ventilation['bed']
vent.theta[vent.features.index('vent_natural')] = 0.01
model = coordinator.ZoneModel(self.c.learners['bed'], None, vent)
inputs = self.c._ventilation.zone_inputs('bed', {'wind': None, 'wind_gust': None})
self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1)
window = data['windows']['Bedroom window']
self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None))