Treat window states unknown at startup as unknown, and continue across restarts
Reproduced: with a window sensor already unavailable at startup, the forecast assumed the window closed, exactly as for a window last seen closed, and the first reading of an already open window was logged as a confirmed opening. - An opening or closing is confirmed only when witnessed: the previous reading was the other state with no unknown period between. A first reading, or a change across an outage or restart, is unconfirmed. - Last known states, open events and recovery are saved (also whenever a window's state changes) and restored as unknown until the sensor reports; saved state for another sensor or polarity is ignored. Downtime counts as unknown time of an interrupted event. - Forecast assumption per window: observed; last seen open, assumed open only until the configured open time after it was last seen (never renewed); last seen closed or never observed, no ventilation but marked uncertain. Status shows each window's assumption; zone forecasts list their uncertain windows. - Events report a duration only when both ends are confirmed, otherwise a minimum (and a maximum when the opening was seen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49565d610a
commit
13cb972566
5 files changed
+353
-55
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user