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),
"model_in_use": "extended" if model.uses_extended else "base",
"contributions_c_per_h": model.contributions(temp, inputs),
"coefficients": coefficients}
"coefficients": coefficients,
# Windows whose state the forecast had to assume (sensor unknown).
"uncertain_windows": list((vent_inputs.get(key) or {}).get("unknown_windows", []))}
return details
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
@@ -531,11 +533,14 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
is_open, since = info.get("open"), info.get("since")
direction = weather.get("wind_direction")
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
assumed = self._ventilation.assumption(window, float(self.config[CONF_WINDOW_OPEN_MINUTES]), now)
windows[window.name] = {
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
"state": "unknown" if is_open is None else "open" if is_open else "closed",
"last_known_state": None if last_known is None else "open" if last_known else "closed",
"unknown_minutes": round((now - unknown_since) / 60, 1) if unknown_since else None,
# What forecasts assume: observed, or a bounded guess from the last reading.
"forecast_assumption": assumed["basis"], "forecast_open_minutes": assumed["open_minutes"],
"open_minutes": round((now - since) / 60, 1) if is_open and since else None,
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
"relative_wind_direction": info.get("relative_wind_direction"),
@@ -799,8 +804,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned and not self._stale(run):
# Persist at each sample so restart does not erase hours of training.
# Persist at each sample so restart does not erase hours of training, and when
# a window's state changes so a restart continues from it.
window_changed, self._ventilation.changed = self._ventilation.changed, False
if (learned or window_changed) and not self._stale(run):
await self._async_save(active_sources["temperature"])
if self._stale(run):
return self._superseded(status)