Keep unknown window states from passing as closed
Confirmed: when an open window's contact sensor became unknown or unavailable, the tracker ended the ventilation event as a close at the poll time, started recovery at full weight and forecasts assumed the window closed. (Learning was already excluded for that zone.) Each window is now OPEN, CLOSED or UNKNOWN with its last known state, since when, and since when it is unknown: - an unknown sensor keeps an open window's event (and the zone's "open" status) going; forecasts use the last known state; - a close observed only after an outage is recorded as unconfirmed: closed=None with the reported time and a maximum duration; recovery is assumed from that report but not used for any learning; - opening seen only after an outage starts an event without a temperature-before, so its recovery is not learned either; - events count minutes with an unknown sensor; Status shows each window's last known state and unknown minutes; - one unknown sensor only blocks learning for its own zone. Predictions with an unavailable wind sensor now keep an open window's natural ventilation (calm air) instead of dropping the term; learning still refuses unknown inputs. Regression tests for every OPEN/CLOSED/UNKNOWN transition, a six-hour outage, multiple windows with one unknown, and missing wind during ventilation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17dbe76223
commit
2bddc74d37
6 files changed
+236
-34
No files matched your search
@@ -321,9 +321,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
info = self._ventilation.window_state.get(window.key, {})
|
||||
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")
|
||||
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,
|
||||
"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"),
|
||||
|
||||
@@ -24,7 +24,7 @@ from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from .physics import Learner
|
||||
from .windows import ventilation_vector
|
||||
from .windows import ventilation_vector_for_prediction
|
||||
from .zone_model import feature_vector
|
||||
|
||||
HORIZONS = (15, 30, 60, 120) # minutes
|
||||
@@ -67,7 +67,7 @@ def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any]
|
||||
ventilation = inputs.get("ventilation")
|
||||
if snapshot["ventilation"] and ventilation:
|
||||
features, theta = snapshot["ventilation"]
|
||||
x = ventilation_vector(features, temp, inputs["outside"], ventilation)
|
||||
x = ventilation_vector_for_prediction(features, temp, inputs["outside"], ventilation)
|
||||
if x is not None:
|
||||
main += _dot(theta, x)
|
||||
return main
|
||||
|
||||
@@ -190,6 +190,23 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
||||
return values
|
||||
|
||||
|
||||
def ventilation_vector_for_prediction(features: Iterable[str], temp: float, outside: float,
|
||||
inputs: Mapping[str, Any]) -> list[float] | None:
|
||||
"""Like ventilation_vector, but an unknown wind or gust counts as calm air.
|
||||
|
||||
For predictions only: an open window keeps its natural ventilation when the wind
|
||||
sensor is unavailable, instead of silently being treated as closed. Learning
|
||||
always uses ventilation_vector, which refuses unknown inputs.
|
||||
"""
|
||||
features = list(features)
|
||||
x = ventilation_vector(features, temp, outside, inputs)
|
||||
if x is None and inputs.get("opening") is not None:
|
||||
x = ventilation_vector(features, temp, outside, {
|
||||
**inputs, "wind": inputs.get("wind") or 0.0, "gust": inputs.get("gust") or inputs.get("wind") or 0.0,
|
||||
"facing": inputs.get("facing") or 0.0})
|
||||
return x
|
||||
|
||||
|
||||
def _iso(timestamp: float) -> str:
|
||||
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
@@ -206,6 +223,9 @@ class VentilationTracker:
|
||||
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
|
||||
self._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.
|
||||
self._memory: dict[str, dict[str, Any]] = {}
|
||||
self._recovery_confirmed: dict[str, bool] = {}
|
||||
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(
|
||||
@@ -215,7 +235,13 @@ class VentilationTracker:
|
||||
|
||||
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)."""
|
||||
"""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 = {}
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
for window in self.windows:
|
||||
@@ -228,9 +254,11 @@ 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)
|
||||
zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True,
|
||||
"open_now": False, "open_windows": [], "last_close": None,
|
||||
"first_open": None})
|
||||
memory = self._memory.setdefault(window.key, {"last_known": None, "since": 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,
|
||||
"open_confirmed": True, "unknown_windows": []})
|
||||
if fraction is None:
|
||||
zone["known"] = False
|
||||
else:
|
||||
@@ -238,15 +266,31 @@ class VentilationTracker:
|
||||
zone["facing_unit"] += window.opening * fraction * (
|
||||
facing_cos(weather["wind_direction"], window.azimuth)
|
||||
if weather.get("wind_direction") is not None else 0.0)
|
||||
if is_open:
|
||||
zone["open_now"] = True
|
||||
zone["open_windows"].append(window.name)
|
||||
if changed is not None:
|
||||
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
||||
elif is_open is False and previous and changed is not None and changed > previous[0]:
|
||||
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
||||
if is_open is None:
|
||||
if memory["unknown_since"] is None:
|
||||
memory["unknown_since"] = previous[0] if previous and previous[1] is not None else now
|
||||
zone["unknown_windows"].append(window.name)
|
||||
if memory["last_known"]:
|
||||
zone["possibly_open"] = True # last seen open: still treated as open
|
||||
else:
|
||||
after_outage = memory["unknown_since"] is not None
|
||||
if memory["last_known"] is not is_open:
|
||||
memory["since"] = changed
|
||||
if is_open and after_outage:
|
||||
zone["open_confirmed"] = False # opened at some point during the outage
|
||||
if is_open is False and memory["last_known"] and after_outage:
|
||||
zone["close_confirmed"] = False # closed at some point during the outage
|
||||
memory["last_known"], memory["unknown_since"] = is_open, None
|
||||
if is_open:
|
||||
zone["open_now"] = True
|
||||
zone["open_windows"].append(window.name)
|
||||
if changed is not None:
|
||||
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
||||
elif previous and changed is not None and changed > previous[0]:
|
||||
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
||||
self.window_state[window.key] = {
|
||||
"open": is_open, "since": changed,
|
||||
"open": is_open, "since": changed, "last_known_open": memory["last_known"],
|
||||
"last_known_since": memory["since"], "unknown_since": memory["unknown_since"],
|
||||
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
|
||||
if weather.get("wind_direction") is not None else None)}
|
||||
for key, zone in zones.items():
|
||||
@@ -257,30 +301,46 @@ class VentilationTracker:
|
||||
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
|
||||
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
|
||||
was_open = key in self._open_event
|
||||
ventilating = zone["open_now"] or zone["possibly_open"]
|
||||
if zone["open_now"] and not was_open:
|
||||
# Temperature at the previous poll approximates the room before ventilation.
|
||||
self._t_ref[key] = self._last_temps.get(key)
|
||||
self._closed_at.pop(key, None)
|
||||
self._open_event[key] = {
|
||||
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)),
|
||||
"temperature_before": self._t_ref[key],
|
||||
"opened_confirmed": zone["open_confirmed"],
|
||||
"temperature_before": self._t_ref[key] if zone["open_confirmed"] else None,
|
||||
"outside": weather.get("temperature"), "wind": weather.get("wind"),
|
||||
"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,
|
||||
"lowest_temperature": temp, "sensor_unknown_minutes": 0.0,
|
||||
}
|
||||
elif zone["open_now"] and temp is not None:
|
||||
if not zone["open_confirmed"]:
|
||||
self._t_ref[key] = None # the room may already have cooled before we saw it
|
||||
elif ventilating and was_open:
|
||||
event = self._open_event[key]
|
||||
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
||||
elif was_open and not zone["open_now"]:
|
||||
if 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)
|
||||
elif was_open and not ventilating:
|
||||
event = self._open_event.pop(key)
|
||||
closed = zone["last_close"] or now
|
||||
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))
|
||||
self._closed_at[key] = closed
|
||||
event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1),
|
||||
temperature_at_close=temp)
|
||||
self._recovery_confirmed[key] = zone["close_confirmed"] and event.get("opened_confirmed", True)
|
||||
event["temperature_at_close"] = temp
|
||||
self.events.append(event)
|
||||
elif not zone["open_now"] and key in self._closed_at and temp is not None:
|
||||
elif not ventilating and key in self._closed_at and temp is not None:
|
||||
# Fill in the temperature half an hour after closing, for recovery review.
|
||||
for event in reversed(self.events):
|
||||
if event.get("zone") == key and "temperature_after" not in event:
|
||||
@@ -289,36 +349,46 @@ class VentilationTracker:
|
||||
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
|
||||
break
|
||||
closed = self._closed_at.get(key)
|
||||
recovery = exp(-(now - closed) / self.tau) if closed is not None and not zone["open_now"] else 0.0
|
||||
recovery = exp(-(now - closed) / self.tau) if closed is not None and not ventilating else 0.0
|
||||
if recovery < RECOVERY_THRESHOLD:
|
||||
recovery = 0.0
|
||||
zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
|
||||
recovery_confirmed=self._recovery_confirmed.get(key, True),
|
||||
other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"]))
|
||||
# Learning of the closed-window model is unsafe while anything ventilation-related goes on.
|
||||
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or zone["open_now"] or recovery > 0
|
||||
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or ventilating or recovery > 0
|
||||
|
||||
def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None:
|
||||
"""Ventilation inputs of the interval that just ended; None for zones without windows."""
|
||||
"""Ventilation inputs of the interval that just ended; None for zones without windows.
|
||||
|
||||
`known` is False while a window state is unknown, and during a recovery whose
|
||||
start is not confirmed: those intervals are not used for any learning.
|
||||
"""
|
||||
zone = self.zone_state.get(key)
|
||||
if zone is None:
|
||||
return None
|
||||
return {"opening": zone["opening"] if zone["known"] else None, "facing": zone["facing_unit"],
|
||||
known = zone["known"] and (zone["recovery"] == 0 or zone["recovery_confirmed"])
|
||||
return {"opening": zone["opening"] if known else None, "facing": zone["facing_unit"],
|
||||
"other_opening": zone["other_opening"], "recovery": zone["recovery"],
|
||||
"t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"),
|
||||
"affected": zone["affected"], "known": zone["known"]}
|
||||
"affected": zone["affected"], "known": known,
|
||||
"unknown_windows": list(zone["unknown_windows"])}
|
||||
|
||||
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
|
||||
now: float) -> list[dict[str, Any]]:
|
||||
"""Assumed ventilation inputs per forecast step.
|
||||
|
||||
Windows open now are assumed to close after `open_minutes` (at least one
|
||||
step); after that the zone recovers. Weather is held at current values.
|
||||
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.
|
||||
"""
|
||||
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")]
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any, Callable, Mapping
|
||||
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
|
||||
from .learning import FeatureLearner
|
||||
from .physics import Learner
|
||||
from .windows import ventilation_vector
|
||||
from .windows import ventilation_vector_for_prediction
|
||||
|
||||
BASE_FEATURES = ["loss", "heat", "bias"]
|
||||
|
||||
@@ -86,7 +86,7 @@ class ZoneModel:
|
||||
ventilation = inputs.get("ventilation")
|
||||
if self.ventilation is None or not ventilation:
|
||||
return 0.0
|
||||
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
return self.ventilation.predict(x) if x is not None else 0.0
|
||||
|
||||
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||
@@ -98,7 +98,7 @@ class ZoneModel:
|
||||
terms = self._main_contributions(temp, inputs)
|
||||
ventilation = inputs.get("ventilation")
|
||||
if self.ventilation is not None and ventilation:
|
||||
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
if x is not None:
|
||||
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
|
||||
group = "recovery" if name == "recovery" else "ventilation"
|
||||
|
||||
Reference in new issue
Block a user