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
+228 -26

No files matched your search

@@ -321,9 +321,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
info = self._ventilation.window_state.get(window.key, {}) info = self._ventilation.window_state.get(window.key, {})
is_open, since = info.get("open"), info.get("since") is_open, since = info.get("open"), info.get("since")
direction = weather.get("wind_direction") direction = weather.get("wind_direction")
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
windows[window.name] = { windows[window.name] = {
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled, "zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
"state": "unknown" if is_open is None else "open" if is_open else "closed", "state": "unknown" if is_open is None else "open" if is_open else "closed",
"last_known_state": None if last_known is None else "open" if last_known else "closed",
"unknown_minutes": round((now - unknown_since) / 60, 1) if unknown_since else None,
"open_minutes": round((now - since) / 60, 1) if is_open and since else None, "open_minutes": round((now - since) / 60, 1) if is_open and since else None,
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted, "azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
"relative_wind_direction": info.get("relative_wind_direction"), "relative_wind_direction": info.get("relative_wind_direction"),
+2 -2
View File
@@ -24,7 +24,7 @@ from math import isfinite
from typing import Any from typing import Any
from .physics import Learner from .physics import Learner
from .windows import ventilation_vector from .windows import ventilation_vector_for_prediction
from .zone_model import feature_vector from .zone_model import feature_vector
HORIZONS = (15, 30, 60, 120) # minutes HORIZONS = (15, 30, 60, 120) # minutes
@@ -67,7 +67,7 @@ def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any]
ventilation = inputs.get("ventilation") ventilation = inputs.get("ventilation")
if snapshot["ventilation"] and ventilation: if snapshot["ventilation"] and ventilation:
features, theta = snapshot["ventilation"] features, theta = snapshot["ventilation"]
x = ventilation_vector(features, temp, inputs["outside"], ventilation) x = ventilation_vector_for_prediction(features, temp, inputs["outside"], ventilation)
if x is not None: if x is not None:
main += _dot(theta, x) main += _dot(theta, x)
return main return main
+91 -21
View File
@@ -190,6 +190,23 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
return values return values
def ventilation_vector_for_prediction(features: Iterable[str], temp: float, outside: float,
inputs: Mapping[str, Any]) -> list[float] | None:
"""Like ventilation_vector, but an unknown wind or gust counts as calm air.
For predictions only: an open window keeps its natural ventilation when the wind
sensor is unavailable, instead of silently being treated as closed. Learning
always uses ventilation_vector, which refuses unknown inputs.
"""
features = list(features)
x = ventilation_vector(features, temp, outside, inputs)
if x is None and inputs.get("opening") is not None:
x = ventilation_vector(features, temp, outside, {
**inputs, "wind": inputs.get("wind") or 0.0, "gust": inputs.get("gust") or inputs.get("wind") or 0.0,
"facing": inputs.get("facing") or 0.0})
return x
def _iso(timestamp: float) -> str: def _iso(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds") return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
@@ -206,6 +223,9 @@ class VentilationTracker:
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
self._last_temps: dict[str, float | None] = {} self._last_temps: dict[str, float | None] = {}
self._open_event: dict[str, dict[str, Any]] = {} self._open_event: dict[str, dict[str, Any]] = {}
# window -> last known open/closed state, since when, and since when it is unknown.
self._memory: dict[str, dict[str, Any]] = {}
self._recovery_confirmed: dict[str, bool] = {}
saved = saved if isinstance(saved, Mapping) else {} saved = saved if isinstance(saved, Mapping) else {}
events = saved.get("events") if isinstance(saved.get("events"), list) else [] events = saved.get("events") if isinstance(saved.get("events"), list) else []
self.events: deque[dict[str, Any]] = deque( self.events: deque[dict[str, Any]] = deque(
@@ -215,7 +235,13 @@ class VentilationTracker:
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]], def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None: temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
"""One poll. `states`: window key -> (binary sensor state, last_changed timestamp).""" """One poll. `states`: window key -> (binary sensor state, last_changed timestamp).
Each window is OPEN, CLOSED or UNKNOWN (unknown/unavailable/missing). Unknown
never means closed: the last known state is kept, an open window's event
stays open, and a close that happened while the sensor was unknown is
recorded as unconfirmed.
"""
self.window_state = {} self.window_state = {}
zones: dict[str, dict[str, Any]] = {} zones: dict[str, dict[str, Any]] = {}
for window in self.windows: for window in self.windows:
@@ -228,9 +254,11 @@ class VentilationTracker:
if previous and changed is not None and 0 < now - previous[0] <= 900: if previous and changed is not None and 0 < now - previous[0] <= 900:
fraction = open_fraction(previous[1], is_open, changed, previous[0], now) fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
self._previous[window.key] = (now, is_open) self._previous[window.key] = (now, is_open)
zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True, memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "unknown_since": None})
"open_now": False, "open_windows": [], "last_close": None, zone = zones.setdefault(window.zone, {
"first_open": None}) "opening": 0.0, "facing_unit": 0.0, "known": True, "open_now": False, "possibly_open": False,
"open_windows": [], "last_close": None, "close_confirmed": True, "first_open": None,
"open_confirmed": True, "unknown_windows": []})
if fraction is None: if fraction is None:
zone["known"] = False zone["known"] = False
else: else:
@@ -238,15 +266,31 @@ class VentilationTracker:
zone["facing_unit"] += window.opening * fraction * ( zone["facing_unit"] += window.opening * fraction * (
facing_cos(weather["wind_direction"], window.azimuth) facing_cos(weather["wind_direction"], window.azimuth)
if weather.get("wind_direction") is not None else 0.0) if weather.get("wind_direction") is not None else 0.0)
if is_open is None:
if memory["unknown_since"] is None:
memory["unknown_since"] = previous[0] if previous and previous[1] is not None else now
zone["unknown_windows"].append(window.name)
if memory["last_known"]:
zone["possibly_open"] = True # last seen open: still treated as open
else:
after_outage = memory["unknown_since"] is not None
if memory["last_known"] is not is_open:
memory["since"] = changed
if is_open and after_outage:
zone["open_confirmed"] = False # opened at some point during the outage
if is_open is False and memory["last_known"] and after_outage:
zone["close_confirmed"] = False # closed at some point during the outage
memory["last_known"], memory["unknown_since"] = is_open, None
if is_open: if is_open:
zone["open_now"] = True zone["open_now"] = True
zone["open_windows"].append(window.name) zone["open_windows"].append(window.name)
if changed is not None: if changed is not None:
zone["first_open"] = min(zone["first_open"] or changed, changed) zone["first_open"] = min(zone["first_open"] or changed, changed)
elif is_open is False and previous and changed is not None and changed > previous[0]: elif previous and changed is not None and changed > previous[0]:
zone["last_close"] = max(zone["last_close"] or 0.0, changed) zone["last_close"] = max(zone["last_close"] or 0.0, changed)
self.window_state[window.key] = { self.window_state[window.key] = {
"open": is_open, "since": changed, "open": is_open, "since": changed, "last_known_open": memory["last_known"],
"last_known_since": memory["since"], "unknown_since": memory["unknown_since"],
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1) "relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
if weather.get("wind_direction") is not None else None)} if weather.get("wind_direction") is not None else None)}
for key, zone in zones.items(): for key, zone in zones.items():
@@ -257,30 +301,46 @@ class VentilationTracker:
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None, def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None: weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
was_open = key in self._open_event was_open = key in self._open_event
ventilating = zone["open_now"] or zone["possibly_open"]
if zone["open_now"] and not was_open: if zone["open_now"] and not was_open:
# Temperature at the previous poll approximates the room before ventilation. # Temperature at the previous poll approximates the room before ventilation.
self._t_ref[key] = self._last_temps.get(key) self._t_ref[key] = self._last_temps.get(key)
self._closed_at.pop(key, None) self._closed_at.pop(key, None)
self._open_event[key] = { self._open_event[key] = {
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)), "zone": key, "opened": _iso(min(zone["first_open"] or now, now)),
"temperature_before": self._t_ref[key], "opened_confirmed": zone["open_confirmed"],
"temperature_before": self._t_ref[key] if zone["open_confirmed"] else None,
"outside": weather.get("temperature"), "wind": weather.get("wind"), "outside": weather.get("temperature"), "wind": weather.get("wind"),
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"), "wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
"windows": list(zone["open_windows"]), "windows": list(zone["open_windows"]),
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]], "other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]],
"lowest_temperature": temp, "lowest_temperature": temp, "sensor_unknown_minutes": 0.0,
} }
elif zone["open_now"] and temp is not None: if not zone["open_confirmed"]:
self._t_ref[key] = None # the room may already have cooled before we saw it
elif ventilating and was_open:
event = self._open_event[key] event = self._open_event[key]
if temp is not None:
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None) event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
elif was_open and not zone["open_now"]: if zone["unknown_windows"]:
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1)
elif was_open and not ventilating:
event = self._open_event.pop(key) event = self._open_event.pop(key)
if zone["close_confirmed"]:
closed = zone["last_close"] or now closed = zone["last_close"] or now
event.update(closed=_iso(closed), closed_confirmed=True,
duration_minutes=round((closed - _parse(event["opened"])) / 60, 1))
else:
# Closed while the sensor was unknown: no confirmed time, only a range.
closed = now
event.update(closed=None, closed_confirmed=False, closed_reported=_iso(now),
duration_minutes=None,
duration_max_minutes=round((now - _parse(event["opened"])) / 60, 1))
self._closed_at[key] = closed self._closed_at[key] = closed
event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1), self._recovery_confirmed[key] = zone["close_confirmed"] and event.get("opened_confirmed", True)
temperature_at_close=temp) event["temperature_at_close"] = temp
self.events.append(event) self.events.append(event)
elif not zone["open_now"] and key in self._closed_at and temp is not None: elif not ventilating and key in self._closed_at and temp is not None:
# Fill in the temperature half an hour after closing, for recovery review. # Fill in the temperature half an hour after closing, for recovery review.
for event in reversed(self.events): for event in reversed(self.events):
if event.get("zone") == key and "temperature_after" not in event: if event.get("zone") == key and "temperature_after" not in event:
@@ -289,36 +349,46 @@ class VentilationTracker:
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
break break
closed = self._closed_at.get(key) closed = self._closed_at.get(key)
recovery = exp(-(now - closed) / self.tau) if closed is not None and not zone["open_now"] else 0.0 recovery = exp(-(now - closed) / self.tau) if closed is not None and not ventilating else 0.0
if recovery < RECOVERY_THRESHOLD: if recovery < RECOVERY_THRESHOLD:
recovery = 0.0 recovery = 0.0
zone.update(recovery=recovery, t_ref=self._t_ref.get(key), zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
recovery_confirmed=self._recovery_confirmed.get(key, True),
other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"])) other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"]))
# Learning of the closed-window model is unsafe while anything ventilation-related goes on. # Learning of the closed-window model is unsafe while anything ventilation-related goes on.
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or zone["open_now"] or recovery > 0 zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or ventilating or recovery > 0
def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None: def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None:
"""Ventilation inputs of the interval that just ended; None for zones without windows.""" """Ventilation inputs of the interval that just ended; None for zones without windows.
`known` is False while a window state is unknown, and during a recovery whose
start is not confirmed: those intervals are not used for any learning.
"""
zone = self.zone_state.get(key) zone = self.zone_state.get(key)
if zone is None: if zone is None:
return None return None
return {"opening": zone["opening"] if zone["known"] else None, "facing": zone["facing_unit"], known = zone["known"] and (zone["recovery"] == 0 or zone["recovery_confirmed"])
return {"opening": zone["opening"] if known else None, "facing": zone["facing_unit"],
"other_opening": zone["other_opening"], "recovery": zone["recovery"], "other_opening": zone["other_opening"], "recovery": zone["recovery"],
"t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"), "t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"),
"affected": zone["affected"], "known": zone["known"]} "affected": zone["affected"], "known": known,
"unknown_windows": list(zone["unknown_windows"])}
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None], def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
now: float) -> list[dict[str, Any]]: now: float) -> list[dict[str, Any]]:
"""Assumed ventilation inputs per forecast step. """Assumed ventilation inputs per forecast step.
Windows open now are assumed to close after `open_minutes` (at least one Windows open now (or unknown but last seen open) are assumed to close after
step); after that the zone recovers. Weather is held at current values. `open_minutes` (at least one step); after that the zone recovers. Weather
is held at current values.
""" """
zone = self.zone_state.get(key) zone = self.zone_state.get(key)
if zone is None: if zone is None:
return [{} for _ in range(steps)] return [{} for _ in range(steps)]
windows = [w for w in self.windows if w.zone == key and w.enabled windows = [w for w in self.windows if w.zone == key and w.enabled
and self.window_state.get(w.key, {}).get("open")] and (self.window_state.get(w.key, {}).get("open")
or (self.window_state.get(w.key, {}).get("open") is None
and self.window_state.get(w.key, {}).get("last_known_open")))]
opening = sum(w.opening for w in windows) opening = sum(w.opening for w in windows)
facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \ facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \
if weather.get("wind_direction") is not None else 0.0 if weather.get("wind_direction") is not None else 0.0
+3 -3
View File
@@ -18,7 +18,7 @@ from typing import Any, Callable, Mapping
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
from .learning import FeatureLearner from .learning import FeatureLearner
from .physics import Learner from .physics import Learner
from .windows import ventilation_vector from .windows import ventilation_vector_for_prediction
BASE_FEATURES = ["loss", "heat", "bias"] BASE_FEATURES = ["loss", "heat", "bias"]
@@ -86,7 +86,7 @@ class ZoneModel:
ventilation = inputs.get("ventilation") ventilation = inputs.get("ventilation")
if self.ventilation is None or not ventilation: if self.ventilation is None or not ventilation:
return 0.0 return 0.0
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation) x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
return self.ventilation.predict(x) if x is not None else 0.0 return self.ventilation.predict(x) if x is not None else 0.0
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float: def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
@@ -98,7 +98,7 @@ class ZoneModel:
terms = self._main_contributions(temp, inputs) terms = self._main_contributions(temp, inputs)
ventilation = inputs.get("ventilation") ventilation = inputs.get("ventilation")
if self.ventilation is not None and ventilation: if self.ventilation is not None and ventilation:
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation) x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
if x is not None: if x is not None:
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x): for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
group = "recovery" if name == "recovery" else "ventilation" group = "recovery" if name == "recovery" else "ventilation"
+21
View File
@@ -1066,3 +1066,24 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
await grown.async_restore_model() await grown.async_restore_model()
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross') self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta) 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))
+108
View File
@@ -145,3 +145,111 @@ class TrackerTests(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class UnknownStateTests(unittest.TestCase):
"""OPEN, CLOSED and UNKNOWN are three states: unknown never means closed."""
def setUp(self):
self.tracker = wm.VentilationTracker(wm.build_windows([(window('w'), None)], ['bed']), recovery_minutes=20)
self.t = 1_000_000.0
def poll(self, state, changed_ago=None, temp=20.0):
changed = self.t - changed_ago if changed_ago is not None else self.t - 100_000
self.tracker.update(self.t, {'w': (state, changed)}, {'bed': temp}, WEATHER)
inputs = self.tracker.zone_inputs('bed', WEATHER)
forecast = self.tracker.forecast('bed', 6, 15, WEATHER, self.t)
self.t += 300
return inputs, forecast, dict(self.tracker.window_state['w'])
def open_for_a_while(self):
self.poll('off')
self.poll('off', temp=21.0)
self.poll('on', changed_ago=100, temp=20.4)
self.poll('on', changed_ago=400, temp=20.0)
def test_open_to_unknown_keeps_the_event_and_assumes_still_open(self):
self.open_for_a_while()
inputs, forecast, state = self.poll('unavailable', changed_ago=60, temp=19.8)
self.assertEqual(len(self.tracker.events), 0) # no confirmed closing without evidence
self.assertIn('bed', self.tracker._open_event)
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (False, True, 0.0))
self.assertEqual(forecast[0]['opening'], 1.0) # last seen open: still treated as open
self.assertEqual((state['open'], state['last_known_open']), (None, True))
self.assertIsNotNone(state['unknown_since'])
def test_unknown_to_open_continues_the_same_event(self):
self.open_for_a_while()
self.poll('unavailable', changed_ago=60)
self.poll('unavailable', changed_ago=360)
inputs, _, state = self.poll('on', changed_ago=30) # sensor back, window still open
self.assertEqual((len(self.tracker.events), state['unknown_since']), (0, None))
self.assertFalse(inputs['known']) # the interval itself was unknown
inputs, _, _ = self.poll('on', changed_ago=330)
self.assertEqual((inputs['known'], inputs['opening']), (True, 1.0))
self.poll('off', changed_ago=120, temp=20.2) # confirmed close
event = self.tracker.events[-1]
self.assertEqual((event['closed_confirmed'], event['sensor_unknown_minutes']), (True, 10.0)) # two polls
self.assertIsNotNone(event['duration_minutes'])
def test_unknown_to_closed_records_an_unconfirmed_close(self):
self.open_for_a_while()
self.poll('unavailable', changed_ago=60)
inputs, forecast, state = self.poll('off', changed_ago=20, temp=20.1)
event = self.tracker.events[-1]
self.assertEqual((event['closed'], event['closed_confirmed'], event['duration_minutes']), (None, False, None))
self.assertGreater(event['duration_max_minutes'], 0)
self.assertEqual((state['open'], state['last_known_open'], state['unknown_since']), (False, False, None))
# Recovery is assumed from when the sensor reported closed, but its timing is
# unknown, so neither the ventilation nor the envelope model learns from it.
self.assertGreater(inputs['recovery'], 0.9)
self.assertEqual((inputs['known'], inputs['affected'], inputs['opening']), (False, True, None))
self.assertEqual(forecast[0]['opening'], 0.0)
self.assertGreater(forecast[0]['recovery'], 0.0)
def test_closed_to_unknown_assumes_closed_but_blocks_learning(self):
self.poll('off')
self.poll('off')
inputs, forecast, state = self.poll('unavailable', changed_ago=120)
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
self.assertEqual(forecast[0]['opening'], 0.0)
self.assertEqual((state['open'], state['last_known_open']), (None, False))
self.assertEqual(len(self.tracker.events), 0)
def test_unknown_to_open_after_a_closed_window_starts_an_unconfirmed_event(self):
self.poll('off')
self.poll('off', temp=21.0)
self.poll('unavailable', changed_ago=60, temp=20.6)
self.poll('on', changed_ago=30, temp=20.0) # opened at some point during the outage
event = self.tracker._open_event['bed']
self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None))
self.poll('on', changed_ago=330, temp=19.8)
inputs, _, _ = self.poll('off', changed_ago=100, temp=19.9)
self.assertTrue(self.tracker.events[-1]['closed_confirmed'])
# Without the temperature before ventilation, recovery cannot be learned.
self.assertFalse(inputs['known'])
def test_recovery_after_prolonged_unavailability(self):
self.open_for_a_while()
for _ in range(72): # six hours without the sensor
self.poll('unavailable', changed_ago=60)
self.assertEqual(len(self.tracker.events), 0)
inputs, _, _ = self.poll('off', changed_ago=60)
event = self.tracker.events[-1]
self.assertFalse(event['closed_confirmed'])
self.assertGreaterEqual(event['duration_max_minutes'], 360)
self.assertEqual(event['sensor_unknown_minutes'], 360.0)
for _ in range(12): # recovery decays, learning resumes
inputs, _, _ = self.poll('off')
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (True, False, 0.0))
def test_multiple_windows_with_one_unknown(self):
tracker = wm.VentilationTracker(wm.build_windows([(window('a'), None), (window('b'), None)], ['bed']))
for t, a, b in ((0, 'off', 'off'), (300, 'on', 'off'), (600, 'on', 'unavailable'),
(900, 'off', 'unavailable')):
tracker.update(float(t), {'a': (a, t - 60.0), 'b': (b, t - 60.0)}, {'bed': 20.0}, WEATHER)
# Window a closed (confirmed); b was last seen closed: the event ends, confirmed,
# but the zone stays out of learning while b's state is unknown.
self.assertTrue(tracker.events[-1]['closed_confirmed'])
inputs = tracker.zone_inputs('bed', WEATHER)
self.assertEqual((inputs['known'], inputs['unknown_windows']), (False, ['B']))