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
+350 -52

No files matched your search

+10 -3
View File
@@ -520,7 +520,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
details[key] = {"temperature": round(temp, 3),
"model_in_use": "extended" if model.uses_extended else "base",
"contributions_c_per_h": model.contributions(temp, inputs),
"coefficients": coefficients}
"coefficients": coefficients,
# Windows whose state the forecast had to assume (sensor unknown).
"uncertain_windows": list((vent_inputs.get(key) or {}).get("unknown_windows", []))}
return details
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
@@ -531,11 +533,14 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
is_open, since = info.get("open"), info.get("since")
direction = weather.get("wind_direction")
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
assumed = self._ventilation.assumption(window, float(self.config[CONF_WINDOW_OPEN_MINUTES]), now)
windows[window.name] = {
"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",
"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,
# What forecasts assume: observed, or a bounded guess from the last reading.
"forecast_assumption": assumed["basis"], "forecast_open_minutes": assumed["open_minutes"],
"open_minutes": round((now - since) / 60, 1) if is_open and since else None,
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
"relative_wind_direction": info.get("relative_wind_direction"),
@@ -799,8 +804,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned and not self._stale(run):
# Persist at each sample so restart does not erase hours of training.
# Persist at each sample so restart does not erase hours of training, and when
# a window's state changes so a restart continues from it.
window_changed, self._ventilation.changed = self._ventilation.changed, False
if (learned or window_changed) and not self._stale(run):
await self._async_save(active_sources["temperature"])
if self._stale(run):
return self._superseded(status)
+2
View File
@@ -103,6 +103,8 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
"forecast_15min": trajectory or None,
"plan_15min": data.get("plan_15min"),
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
# Windows with an unknown state: the forecast assumes their state (see Status).
"uncertain_windows": details.get("uncertain_windows") or [],
"model_in_use": details.get("model_in_use"),
"contributions_c_per_h": details.get("contributions_c_per_h"),
"coefficients": details.get("coefficients"),
+165 -49
View File
@@ -212,7 +212,15 @@ def _iso(timestamp: float) -> str:
class VentilationTracker:
"""Window states, ventilation inputs per zone, recovery after closing, event log."""
"""Window states, ventilation inputs per zone, recovery after closing, event log.
Each window is OPEN, CLOSED or UNKNOWN (unknown, unavailable or missing).
Unknown never means closed. The last known state is kept apart from the
current one, and an opening or closing counts as confirmed only when it was
seen: the previous reading was the other state, with no unknown period in
between. Last known states, open events and recovery are saved so a restart
or reload continues them; they come back as unknown until the sensor reports.
"""
def __init__(self, windows: list[Window], recovery_minutes: float = DEFAULT_RECOVERY_MINUTES,
saved: Any = None) -> None:
@@ -223,25 +231,24 @@ class VentilationTracker:
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
self._last_temps: dict[str, float | None] = {}
self._open_event: dict[str, dict[str, Any]] = {}
# window -> last known open/closed state, since when, and since when it is unknown.
# window -> last known open/closed state, since when, when it was last seen
# and since when it is unknown (None while known).
self._memory: dict[str, dict[str, Any]] = {}
self._recovery_confirmed: dict[str, bool] = {}
self._last_update: float | None = None
self._restored_at: float | None = None
self.changed = False # something worth saving happened since the last save
saved = saved if isinstance(saved, Mapping) else {}
events = saved.get("events") if isinstance(saved.get("events"), list) else []
self.events: deque[dict[str, Any]] = deque(
(e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS)
self.window_state: dict[str, dict[str, Any]] = {}
self.zone_state: dict[str, dict[str, Any]] = {}
self._restore(saved.get("state"))
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
"""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.
"""
"""One poll. `states`: window key -> (binary sensor state, last_changed timestamp)."""
self.window_state = {}
zones: dict[str, dict[str, Any]] = {}
for window in self.windows:
@@ -254,7 +261,8 @@ class VentilationTracker:
if previous and changed is not None and 0 < now - previous[0] <= 900:
fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
self._previous[window.key] = (now, is_open)
memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "unknown_since": None})
memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "seen": None,
"unknown_since": None})
zone = zones.setdefault(window.zone, {
"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,
@@ -268,19 +276,22 @@ class VentilationTracker:
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
memory["unknown_since"] = memory["seen"] if memory["seen"] is not None else now
self.changed = True
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:
if memory["last_known"] is not is_open: # a change, or the first reading
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
# Seen only if the previous reading was the other state, with no gap.
witnessed = memory["last_known"] is not None and memory["unknown_since"] is None
if not witnessed:
zone["open_confirmed" if is_open else "close_confirmed"] = False
self.changed = True
elif memory["unknown_since"] is not None:
self.changed = True # the same state again after a gap
memory.update(last_known=is_open, seen=now, unknown_since=None)
if is_open:
zone["open_now"] = True
zone["open_windows"].append(window.name)
@@ -290,13 +301,21 @@ class VentilationTracker:
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
self.window_state[window.key] = {
"open": is_open, "since": changed, "last_known_open": memory["last_known"],
"last_known_since": memory["since"], "unknown_since": memory["unknown_since"],
"last_known_since": memory["since"], "seen": memory["seen"],
"unknown_since": memory["unknown_since"],
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
if weather.get("wind_direction") is not None else None)}
# Time the integration was not running counts as unknown for events it interrupted.
gap = now - self._restored_at if self._restored_at is not None else 0.0
self._restored_at = None
for key, zone in zones.items():
if gap > 330 and key in self._open_event:
event = self._open_event[key]
event["sensor_unknown_minutes"] = round(event.get("sensor_unknown_minutes", 0.0) + gap / 60, 1)
self._update_zone(now, key, zone, temps.get(key), weather, zones)
self.zone_state = zones
self._last_temps = dict(temps)
self._last_update = now
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:
@@ -314,32 +333,41 @@ class VentilationTracker:
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
"windows": list(zone["open_windows"]),
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]],
"lowest_temperature": temp, "sensor_unknown_minutes": 0.0,
"lowest_temperature": temp, "sensor_unknown_minutes": 0.0, "last_seen_open": _iso(now),
}
if not zone["open_confirmed"]:
self._t_ref[key] = None # the room may already have cooled before we saw it
self.changed = True
elif ventilating and was_open:
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)
if zone["unknown_windows"]:
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1)
if zone["open_now"]:
event["last_seen_open"] = _iso(now)
elif was_open and not ventilating:
event = self._open_event.pop(key)
if zone["close_confirmed"]:
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))
opened = _parse(event["opened"]) # the confirmed opening, or when it was first seen open
seen_open = _parse(event.get("last_seen_open", event["opened"]))
closed = zone["last_close"] or now if zone["close_confirmed"] else now
confirmed = zone["close_confirmed"] and event.get("opened_confirmed", True)
# Uncertain periods give a range instead of a duration.
event.update(
closed=_iso(closed) if zone["close_confirmed"] else None,
closed_confirmed=zone["close_confirmed"],
duration_minutes=round((closed - opened) / 60, 1) if confirmed else None,
duration_min_minutes=round(((closed if zone["close_confirmed"] else seen_open) - opened) / 60, 1),
duration_max_minutes=(round((now - opened) / 60, 1)
if not zone["close_confirmed"] and event.get("opened_confirmed", True)
else None))
if not zone["close_confirmed"]:
event["closed_reported"] = _iso(now) # closed at some point before this
self._closed_at[key] = closed
self._recovery_confirmed[key] = zone["close_confirmed"] and event.get("opened_confirmed", True)
self._recovery_confirmed[key] = confirmed
event["temperature_at_close"] = temp
self.events.append(event)
self.changed = True
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.
for event in reversed(self.events):
@@ -374,43 +402,131 @@ class VentilationTracker:
"affected": zone["affected"], "known": known,
"unknown_windows": list(zone["unknown_windows"])}
def assumption(self, window: Window, open_minutes: float, now: float) -> dict[str, Any]:
"""What forecasts assume for one window, and on what basis.
Observed open: open for `open_minutes` more. Unknown but last seen open: open
until `open_minutes` after it was last seen, never renewed, so an unavailable
sensor does not keep a window open forever. Unknown otherwise: no ventilation,
marked uncertain (`last_known_closed`, or `unobserved` with no reading at all).
"""
state = self.window_state.get(window.key, {})
if state.get("open"):
return {"basis": "observed", "open_minutes": float(open_minutes), "uncertain": False}
if state.get("open") is False:
return {"basis": "observed", "open_minutes": 0.0, "uncertain": False}
if state.get("last_known_open"):
seen = state.get("seen")
left = open_minutes - (now - seen) / 60 if seen is not None else 0.0
return {"basis": "last_known_open", "open_minutes": round(max(0.0, left), 1), "uncertain": True,
"assumed_closed_at": seen + open_minutes * 60 if seen is not None else now}
basis = "last_known_closed" if state.get("last_known_open") is False else "unobserved"
return {"basis": basis, "open_minutes": 0.0, "uncertain": True}
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
now: float) -> list[dict[str, Any]]:
"""Assumed ventilation inputs per forecast step.
"""Assumed ventilation inputs per forecast step (see `assumption`).
Windows open now (or unknown but last seen open) are assumed to close after
`open_minutes` (at least one step); after that the zone recovers. Weather
is held at current values.
Each window open now, or assumed open, closes after its assumed time; once
the last one has closed the zone recovers. Weather is held at current values.
"""
zone = self.zone_state.get(key)
if zone is None:
return [{} for _ in range(steps)]
windows = [w for w in self.windows if w.zone == key and w.enabled
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)
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
open_steps = max(1, round(open_minutes / 5)) if windows else 0
windows = [w for w in self.windows if w.zone == key and w.enabled]
assumed = {w.key: self.assumption(w, open_minutes, now) for w in windows}
# Steps each window stays open: at least one for an observed open window.
open_steps = {w.key: (max(1, round(open_minutes / 5)) if self.window_state.get(w.key, {}).get("open")
else round(assumed[w.key]["open_minutes"] / 5)) for w in windows}
direction = weather.get("wind_direction")
last_open = max(open_steps.values(), default=0)
if last_open:
start = now + last_open * 300.0
else:
# Recovery from the last close: observed, or assumed for a window last seen open.
closes = [a["assumed_closed_at"] for a in assumed.values() if "assumed_closed_at" in a]
closed_at = self._closed_at.get(key)
start = max([t for t in (closed_at, *closes) if t is not None], default=None)
result = []
for k in range(steps):
t = now + (k + 1) * 300.0
if k < open_steps:
result.append({"opening": opening, "facing": facing, "recovery": 0.0, "t_ref": zone["t_ref"],
"other_opening": zone["other_opening"], "wind": weather.get("wind") or 0.0,
open_now = [w for w in windows if k < open_steps[w.key]]
if open_now:
result.append({
"opening": sum(w.opening for w in open_now),
"facing": (sum(w.opening * facing_cos(direction, w.azimuth) for w in open_now)
if direction is not None else 0.0),
"recovery": 0.0, "t_ref": zone["t_ref"], "other_opening": zone["other_opening"],
"wind": weather.get("wind") or 0.0,
"gust": weather.get("wind_gust") or weather.get("wind") or 0.0})
continue
start = now + open_steps * 300.0 if windows else closed_at
recovery = exp(-(t - start) / self.tau) if start is not None else 0.0
recovery = exp(-(t - start) / self.tau) if start is not None and t > start else 0.0
result.append({"opening": 0.0, "facing": 0.0, "other_opening": 0.0, "wind": 0.0, "gust": 0.0,
"recovery": recovery if recovery >= RECOVERY_THRESHOLD else 0.0,
"t_ref": zone["t_ref"]})
return result
def as_dict(self) -> dict[str, Any]:
return {"events": list(self.events)}
"""Events, plus the state a restart or reload needs to continue them."""
windows = {w.key: w for w in self.windows if w.enabled}
state = {
"time": self._last_update,
"windows": {key: {"sensor": windows[key].sensor, "inverted": windows[key].inverted,
"last_known": m["last_known"], "since": m["since"], "seen": m["seen"]}
for key, m in self._memory.items()
if key in windows and m["last_known"] is not None and m["seen"] is not None},
"zones": {zone: {"open_event": self._open_event.get(zone), "closed_at": self._closed_at.get(zone),
"recovery_confirmed": self._recovery_confirmed.get(zone, True),
"t_ref": self._t_ref.get(zone)}
for zone in {w.zone for w in windows.values()}
if zone in self._open_event or zone in self._closed_at},
}
return {"events": list(self.events), "state": state}
def _restore(self, state: Any) -> None:
"""Continue from saved state. Nothing restored is live: every window is unknown
from when it was last seen until its sensor reports again."""
if not isinstance(state, Mapping):
return
windows = {w.key: w for w in self.windows if w.enabled}
saved_windows = state.get("windows") if isinstance(state.get("windows"), Mapping) else {}
for key, item in saved_windows.items():
window = windows.get(key)
if (window is None or not isinstance(item, Mapping) or item.get("sensor") != window.sensor
or item.get("inverted") is not window.inverted or not isinstance(item.get("last_known"), bool)
or not _finite(item.get("seen"))):
continue # another sensor now, or polarity flipped: nothing carries over
seen = float(item["seen"])
self._memory[key] = {"last_known": item["last_known"], "seen": seen, "unknown_since": seen,
"since": float(item["since"]) if _finite(item.get("since")) else None}
remembered_open = {windows[key].zone for key, m in self._memory.items() if m["last_known"]}
saved_zones = state.get("zones") if isinstance(state.get("zones"), Mapping) else {}
for zone, item in saved_zones.items():
if zone not in {w.zone for w in windows.values()} or not isinstance(item, Mapping):
continue
event = item.get("open_event")
# An open event continues only while one of its windows is remembered open.
if isinstance(event, dict) and zone in remembered_open and _valid_iso(event.get("opened")):
self._open_event[zone] = dict(event)
if _finite(item.get("closed_at")):
self._closed_at[zone] = float(item["closed_at"])
self._recovery_confirmed[zone] = item.get("recovery_confirmed") is True
if _finite(item.get("t_ref")):
self._t_ref[zone] = float(item["t_ref"])
if self._open_event and _finite(state.get("time")):
self._restored_at = float(state["time"])
def _finite(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
def _valid_iso(value: Any) -> bool:
try:
datetime.fromisoformat(value)
return True
except (TypeError, ValueError):
return False
def _parse(iso: str) -> float:
+34
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())
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()
+139
View File
@@ -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)