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
+353 -55

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), details[key] = {"temperature": round(temp, 3),
"model_in_use": "extended" if model.uses_extended else "base", "model_in_use": "extended" if model.uses_extended else "base",
"contributions_c_per_h": model.contributions(temp, inputs), "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 return details
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None], 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") 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") 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] = { 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", "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, "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, "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"),
@@ -799,8 +804,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.ventilation.get(room))) for room in vals}, self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.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) 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, and when
# Persist at each sample so restart does not erase hours of training. # 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"]) await self._async_save(active_sources["temperature"])
if self._stale(run): if self._stale(run):
return self._superseded(status) return self._superseded(status)
+2
View File
@@ -103,6 +103,8 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
"forecast_15min": trajectory or None, "forecast_15min": trajectory or None,
"plan_15min": data.get("plan_15min"), "plan_15min": data.get("plan_15min"),
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay", "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"), "model_in_use": details.get("model_in_use"),
"contributions_c_per_h": details.get("contributions_c_per_h"), "contributions_c_per_h": details.get("contributions_c_per_h"),
"coefficients": details.get("coefficients"), "coefficients": details.get("coefficients"),
+167 -51
View File
@@ -212,7 +212,15 @@ def _iso(timestamp: float) -> str:
class VentilationTracker: 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, def __init__(self, windows: list[Window], recovery_minutes: float = DEFAULT_RECOVERY_MINUTES,
saved: Any = None) -> None: saved: Any = None) -> None:
@@ -223,25 +231,24 @@ 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. # 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._memory: dict[str, dict[str, Any]] = {}
self._recovery_confirmed: dict[str, bool] = {} 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 {} 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(
(e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS) (e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS)
self.window_state: dict[str, dict[str, Any]] = {} self.window_state: dict[str, dict[str, Any]] = {}
self.zone_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]], 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:
@@ -254,7 +261,8 @@ 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)
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, { zone = zones.setdefault(window.zone, {
"opening": 0.0, "facing_unit": 0.0, "known": True, "open_now": False, "possibly_open": False, "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_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 weather.get("wind_direction") is not None else 0.0)
if is_open is None: if is_open is None:
if memory["unknown_since"] 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) zone["unknown_windows"].append(window.name)
if memory["last_known"]: if memory["last_known"]:
zone["possibly_open"] = True # last seen open: still treated as open zone["possibly_open"] = True # last seen open: still treated as open
else: else:
after_outage = memory["unknown_since"] is not None if memory["last_known"] is not is_open: # a change, or the first reading
if memory["last_known"] is not is_open:
memory["since"] = changed memory["since"] = changed
if is_open and after_outage: # Seen only if the previous reading was the other state, with no gap.
zone["open_confirmed"] = False # opened at some point during the outage witnessed = memory["last_known"] is not None and memory["unknown_since"] is None
if is_open is False and memory["last_known"] and after_outage: if not witnessed:
zone["close_confirmed"] = False # closed at some point during the outage zone["open_confirmed" if is_open else "close_confirmed"] = False
memory["last_known"], memory["unknown_since"] = is_open, None 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: if is_open:
zone["open_now"] = True zone["open_now"] = True
zone["open_windows"].append(window.name) zone["open_windows"].append(window.name)
@@ -290,13 +301,21 @@ class VentilationTracker:
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, "last_known_open": memory["last_known"], "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) "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)}
# 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(): 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._update_zone(now, key, zone, temps.get(key), weather, zones)
self.zone_state = zones self.zone_state = zones
self._last_temps = dict(temps) self._last_temps = dict(temps)
self._last_update = now
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:
@@ -314,32 +333,41 @@ class VentilationTracker:
"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, "sensor_unknown_minutes": 0.0, "lowest_temperature": temp, "sensor_unknown_minutes": 0.0, "last_seen_open": _iso(now),
} }
if not zone["open_confirmed"]: if not zone["open_confirmed"]:
self._t_ref[key] = None # the room may already have cooled before we saw it self._t_ref[key] = None # the room may already have cooled before we saw it
self.changed = True
elif ventilating and was_open: elif ventilating and was_open:
event = self._open_event[key] event = self._open_event[key]
if temp is not None: 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)
if zone["unknown_windows"]: if zone["unknown_windows"]:
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1) 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: elif was_open and not ventilating:
event = self._open_event.pop(key) event = self._open_event.pop(key)
if zone["close_confirmed"]: opened = _parse(event["opened"]) # the confirmed opening, or when it was first seen open
closed = zone["last_close"] or now seen_open = _parse(event.get("last_seen_open", event["opened"]))
event.update(closed=_iso(closed), closed_confirmed=True, closed = zone["last_close"] or now if zone["close_confirmed"] else now
duration_minutes=round((closed - _parse(event["opened"])) / 60, 1)) confirmed = zone["close_confirmed"] and event.get("opened_confirmed", True)
else: # Uncertain periods give a range instead of a duration.
# Closed while the sensor was unknown: no confirmed time, only a range. event.update(
closed = now closed=_iso(closed) if zone["close_confirmed"] else None,
event.update(closed=None, closed_confirmed=False, closed_reported=_iso(now), closed_confirmed=zone["close_confirmed"],
duration_minutes=None, duration_minutes=round((closed - opened) / 60, 1) if confirmed else None,
duration_max_minutes=round((now - _parse(event["opened"])) / 60, 1)) 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._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 event["temperature_at_close"] = temp
self.events.append(event) self.events.append(event)
self.changed = True
elif not ventilating 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):
@@ -374,43 +402,131 @@ class VentilationTracker:
"affected": zone["affected"], "known": known, "affected": zone["affected"], "known": known,
"unknown_windows": list(zone["unknown_windows"])} "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], 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 (see `assumption`).
Windows open now (or unknown but last seen open) are assumed to close after Each window open now, or assumed open, closes after its assumed time; once
`open_minutes` (at least one step); after that the zone recovers. Weather the last one has closed the zone recovers. Weather is held at current values.
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") assumed = {w.key: self.assumption(w, open_minutes, now) for w in windows}
or (self.window_state.get(w.key, {}).get("open") is None # Steps each window stays open: at least one for an observed open window.
and self.window_state.get(w.key, {}).get("last_known_open")))] open_steps = {w.key: (max(1, round(open_minutes / 5)) if self.window_state.get(w.key, {}).get("open")
opening = sum(w.opening for w in windows) else round(assumed[w.key]["open_minutes"] / 5)) for w in windows}
facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \ direction = weather.get("wind_direction")
if weather.get("wind_direction") is not None else 0.0 last_open = max(open_steps.values(), default=0)
open_steps = max(1, round(open_minutes / 5)) if windows else 0 if last_open:
closed_at = self._closed_at.get(key) 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 = [] result = []
for k in range(steps): for k in range(steps):
t = now + (k + 1) * 300.0 t = now + (k + 1) * 300.0
if k < open_steps: open_now = [w for w in windows if k < open_steps[w.key]]
result.append({"opening": opening, "facing": facing, "recovery": 0.0, "t_ref": zone["t_ref"], if open_now:
"other_opening": zone["other_opening"], "wind": weather.get("wind") or 0.0, result.append({
"gust": weather.get("wind_gust") or weather.get("wind") or 0.0}) "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 continue
start = now + open_steps * 300.0 if windows else closed_at recovery = exp(-(t - start) / self.tau) if start is not None and t > start else 0.0
recovery = exp(-(t - start) / self.tau) if start is not None else 0.0
result.append({"opening": 0.0, "facing": 0.0, "other_opening": 0.0, "wind": 0.0, "gust": 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, "recovery": recovery if recovery >= RECOVERY_THRESHOLD else 0.0,
"t_ref": zone["t_ref"]}) "t_ref": zone["t_ref"]})
return result return result
def as_dict(self) -> dict[str, Any]: 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: def _parse(iso: str) -> float:
+35 -1
View File
@@ -1219,6 +1219,35 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
base = self.c.learners['office'] base = self.c.learners['office']
base.count, base.on_count, base.off_count = 200, 100, 100 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 change_during_optimization(self, change):
async def worker(job): async def worker(job):
output = job() output = job()
@@ -1261,7 +1290,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
with patch.object(coordinator, 'datetime') as clock: with patch.object(coordinator, 'datetime') as clock:
clock.now.return_value = self.now clock.now.return_value = self.now
older = asyncio.ensure_future(self.c._async_update_data()) 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) clock.now.return_value = self.now + timedelta(seconds=60)
newer = await self.c._async_update_data() newer = await self.c._async_update_data()
self.c.data = newer # what DataUpdateCoordinator publishes 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): async def test_nothing_is_learned_saved_or_recorded_once_unloading_began(self):
self.ready_office() self.ready_office()
await self.sample() 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} shown = self.c.data = {'status': 'learning', 'proposal': None}
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power') self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
self.c.async_begin_shutdown() self.c.async_begin_shutdown()
+139
View File
@@ -253,3 +253,142 @@ class UnknownStateTests(unittest.TestCase):
self.assertTrue(tracker.events[-1]['closed_confirmed']) self.assertTrue(tracker.events[-1]['closed_confirmed'])
inputs = tracker.zone_inputs('bed', WEATHER) inputs = tracker.zone_inputs('bed', WEATHER)
self.assertEqual((inputs['known'], inputs['unknown_windows']), (False, ['B'])) 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)