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>
545 lines
28 KiB
Python
545 lines
28 KiB
Python
"""Window contact sensors, ventilation, wind exposure and recovery per zone.
|
|
|
|
Pure Python; no Home Assistant imports.
|
|
|
|
Opening a window adds heat exchange with outdoor air on top of the normal
|
|
envelope loss. Mixing the two would make a well-insulated room look leaky, so:
|
|
|
|
* zones with an open window, an unknown window state, or still recovering after
|
|
closing are left out of the closed-window model's learning;
|
|
* a separate ventilation model is fitted to what the closed-window model cannot
|
|
explain during those periods.
|
|
|
|
Its terms, per 5-minute step, with O = sum of open windows' opening factors
|
|
(weighted by the share of the interval they were open):
|
|
|
|
vent_natural x O (T_out - T) buoyancy, always present
|
|
vent_wind x O v (T_out - T) wind speed v (m/s)
|
|
vent_wind_dir x sum_w O_w v cos(a_w) (T_out - T) a_w: wind direction vs the
|
|
window's facing; bounded so a
|
|
leeward window still ventilates
|
|
vent_gust x O max(0, gust - v) (T_out - T) optional
|
|
vent_cross x O O_other (T_out - T) optional: windows open elsewhere
|
|
recovery x R (T_before - T) after closing, R decays 1 -> 0;
|
|
air recovers towards the walls
|
|
|
|
Wind direction is meteorological (where the wind comes FROM, clockwise from north).
|
|
A window's azimuth is the direction its outside face points to, so wind from the
|
|
azimuth blows straight at it (cos = 1) and wind from behind the building gives -1.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from math import atan2, cos, degrees, exp, hypot, isfinite, radians, sin
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
SUBENTRY_WINDOW = "window"
|
|
MAX_WINDOWS = 24
|
|
DEFAULT_AZIMUTH = 345.0
|
|
OPENING_RANGE = (0.1, 3.0)
|
|
DEFAULT_RECOVERY_MINUTES = 20.0
|
|
# Recovery below this weight no longer counts as ventilation-affected (~3 time constants).
|
|
RECOVERY_THRESHOLD = 0.05
|
|
MAX_EVENTS = 20
|
|
EVENT_AFTER_MINUTES = 30
|
|
|
|
WINDOW_KEY = "key"
|
|
WINDOW_NAME = "name"
|
|
WINDOW_SENSOR = "sensor"
|
|
WINDOW_ZONE = "zone"
|
|
WINDOW_INVERTED = "inverted"
|
|
WINDOW_AZIMUTH = "azimuth"
|
|
WINDOW_OPENING = "opening_factor"
|
|
WINDOW_ENABLED = "enabled"
|
|
|
|
VENTILATION_FEATURES = ("vent_natural", "vent_wind", "vent_wind_dir", "vent_gust", "vent_cross", "recovery")
|
|
|
|
|
|
def angle_difference(a: float, b: float) -> float:
|
|
"""Signed smallest difference a - b in degrees, in [-180, 180)."""
|
|
return (a - b + 180.0) % 360.0 - 180.0
|
|
|
|
|
|
def facing_cos(wind_from: float, azimuth: float) -> float:
|
|
"""1 when the wind blows straight at a window facing `azimuth`, -1 from behind."""
|
|
return cos(radians(angle_difference(wind_from, azimuth)))
|
|
|
|
|
|
def circular_mean(angles: Iterable[float], weights: Iterable[float] | None = None) -> float | None:
|
|
"""Mean direction in [0, 360); None when the directions cancel out."""
|
|
angles = list(angles)
|
|
weights = list(weights) if weights is not None else [1.0] * len(angles)
|
|
x = sum(w * cos(radians(a)) for a, w in zip(angles, weights))
|
|
y = sum(w * sin(radians(a)) for a, w in zip(angles, weights))
|
|
if hypot(x, y) < 1e-9 * max(1.0, sum(abs(w) for w in weights)):
|
|
return None
|
|
return degrees(atan2(y, x)) % 360.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Window:
|
|
key: str
|
|
name: str
|
|
sensor: str
|
|
zone: str
|
|
inverted: bool = False
|
|
azimuth: float = DEFAULT_AZIMUTH
|
|
opening: float = 1.0
|
|
enabled: bool = True
|
|
subentry_id: str | None = None
|
|
|
|
|
|
def window_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> Window | None:
|
|
key = data.get(WINDOW_KEY) or subentry_id
|
|
sensor, zone = data.get(WINDOW_SENSOR), data.get(WINDOW_ZONE)
|
|
if not (isinstance(key, str) and isinstance(sensor, str) and sensor.startswith("binary_sensor.")
|
|
and isinstance(zone, str)):
|
|
return None
|
|
name = data.get(WINDOW_NAME)
|
|
try:
|
|
azimuth = float(data.get(WINDOW_AZIMUTH, DEFAULT_AZIMUTH)) % 360.0
|
|
opening = float(data.get(WINDOW_OPENING, 1.0))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not isfinite(azimuth) or not isfinite(opening) or not OPENING_RANGE[0] <= opening <= OPENING_RANGE[1]:
|
|
return None
|
|
return Window(key=key, name=name if isinstance(name, str) and name.strip() else key, sensor=sensor,
|
|
zone=zone, inverted=data.get(WINDOW_INVERTED) is True, azimuth=azimuth, opening=opening,
|
|
enabled=data.get(WINDOW_ENABLED, True) is not False, subentry_id=subentry_id)
|
|
|
|
|
|
def build_windows(items: Iterable[tuple[Mapping[str, Any], str | None]], zone_keys: Iterable[str]) -> list[Window]:
|
|
zone_keys = set(zone_keys)
|
|
windows: list[Window] = []
|
|
for data, subentry_id in items:
|
|
window = window_from_data(data, subentry_id)
|
|
if (window is None or window.zone not in zone_keys or len(windows) == MAX_WINDOWS
|
|
or any(w.key == window.key or w.sensor == window.sensor for w in windows)):
|
|
continue
|
|
windows.append(window)
|
|
return windows
|
|
|
|
|
|
def contact_open(state: str | None, inverted: bool = False) -> bool | None:
|
|
"""Open/closed from a binary sensor state; unknown and unavailable stay unknown."""
|
|
if state not in ("on", "off"):
|
|
return None
|
|
return (state == "on") != inverted
|
|
|
|
|
|
def open_fraction(was_open: bool | None, is_open: bool | None, last_changed: float,
|
|
start: float, end: float) -> float | None:
|
|
"""Share of [start, end] a window was open; None when that is unknown.
|
|
|
|
One change inside the interval is located by its timestamp; a change to the
|
|
same state (an open/close round trip) or a missing state is unknown.
|
|
"""
|
|
if was_open is None or is_open is None or end <= start:
|
|
return None
|
|
if last_changed <= start:
|
|
return float(is_open) if was_open == is_open else None
|
|
if was_open != is_open and last_changed <= end:
|
|
share = (end - last_changed) / (end - start)
|
|
return share if is_open else 1.0 - share
|
|
return None
|
|
|
|
|
|
def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
|
inputs: Mapping[str, Any]) -> list[float] | None:
|
|
"""Ventilation feature values for one zone and step; None if an input is unknown.
|
|
|
|
`inputs`: opening, facing (sum of opening x cos), other_opening, recovery,
|
|
t_ref (pre-ventilation temperature), wind, gust.
|
|
"""
|
|
difference = outside - temp
|
|
values = []
|
|
for name in features:
|
|
opening = inputs.get("opening")
|
|
if opening is None:
|
|
return None
|
|
if name == "vent_natural":
|
|
values.append(opening * difference)
|
|
elif name in ("vent_wind", "vent_wind_dir", "vent_gust"):
|
|
wind = inputs.get("wind")
|
|
if wind is None:
|
|
return None
|
|
if name == "vent_wind":
|
|
values.append(opening * wind * difference)
|
|
elif name == "vent_wind_dir":
|
|
facing = inputs.get("facing")
|
|
if facing is None:
|
|
return None
|
|
values.append(facing * wind * difference)
|
|
else:
|
|
gust = inputs.get("gust")
|
|
if gust is None:
|
|
return None
|
|
values.append(opening * max(0.0, gust - wind) * difference)
|
|
elif name == "vent_cross":
|
|
values.append(opening * (inputs.get("other_opening") or 0.0) * difference)
|
|
elif name == "recovery":
|
|
recovery = inputs.get("recovery") or 0.0
|
|
t_ref = inputs.get("t_ref")
|
|
if recovery > 0 and t_ref is None:
|
|
return None
|
|
values.append(recovery * ((t_ref if t_ref is not None else temp) - temp))
|
|
else:
|
|
raise KeyError(name)
|
|
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")
|
|
|
|
|
|
class VentilationTracker:
|
|
"""Window states, ventilation inputs per zone, recovery after closing, event log.
|
|
|
|
Each window is OPEN, CLOSED or UNKNOWN (unknown, unavailable or missing).
|
|
Unknown never means closed. The last known state is kept apart from the
|
|
current one, and an opening or closing counts as confirmed only when it was
|
|
seen: the previous reading was the other state, with no unknown period in
|
|
between. Last known states, open events and recovery are saved so a restart
|
|
or reload continues them; they come back as unknown until the sensor reports.
|
|
"""
|
|
|
|
def __init__(self, windows: list[Window], recovery_minutes: float = DEFAULT_RECOVERY_MINUTES,
|
|
saved: Any = None) -> None:
|
|
self.windows = windows
|
|
self.tau = max(1.0, recovery_minutes) * 60.0
|
|
self._previous: dict[str, tuple[float, bool | None]] = {} # window -> (poll time, open)
|
|
self._closed_at: dict[str, float] = {} # zone -> when its last window closed
|
|
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, when it was last seen
|
|
# and since when it is unknown (None while known).
|
|
self._memory: dict[str, dict[str, Any]] = {}
|
|
self._recovery_confirmed: dict[str, bool] = {}
|
|
self._last_update: float | None = None
|
|
self._restored_at: float | None = None
|
|
self.changed = False # something worth saving happened since the last save
|
|
saved = saved if isinstance(saved, Mapping) else {}
|
|
events = saved.get("events") if isinstance(saved.get("events"), list) else []
|
|
self.events: deque[dict[str, Any]] = deque(
|
|
(e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS)
|
|
self.window_state: dict[str, dict[str, Any]] = {}
|
|
self.zone_state: dict[str, dict[str, Any]] = {}
|
|
self._restore(saved.get("state"))
|
|
|
|
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
|
|
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
|
|
"""One poll. `states`: window key -> (binary sensor state, last_changed timestamp)."""
|
|
self.window_state = {}
|
|
zones: dict[str, dict[str, Any]] = {}
|
|
for window in self.windows:
|
|
if not window.enabled:
|
|
continue
|
|
state, changed = states.get(window.key, (None, None))
|
|
is_open = contact_open(state, window.inverted)
|
|
previous = self._previous.get(window.key)
|
|
fraction = None
|
|
if previous and changed is not None and 0 < now - previous[0] <= 900:
|
|
fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
|
|
self._previous[window.key] = (now, is_open)
|
|
memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "seen": None,
|
|
"unknown_since": None})
|
|
zone = zones.setdefault(window.zone, {
|
|
"opening": 0.0, "facing_unit": 0.0, "known": True, "open_now": False, "possibly_open": False,
|
|
"open_windows": [], "last_close": None, "close_confirmed": True, "first_open": None,
|
|
"open_confirmed": True, "unknown_windows": []})
|
|
if fraction is None:
|
|
zone["known"] = False
|
|
else:
|
|
zone["opening"] += window.opening * fraction
|
|
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 is None:
|
|
if memory["unknown_since"] is None:
|
|
memory["unknown_since"] = memory["seen"] if memory["seen"] is not None else now
|
|
self.changed = True
|
|
zone["unknown_windows"].append(window.name)
|
|
if memory["last_known"]:
|
|
zone["possibly_open"] = True # last seen open: still treated as open
|
|
else:
|
|
if memory["last_known"] is not is_open: # a change, or the first reading
|
|
memory["since"] = changed
|
|
# Seen only if the previous reading was the other state, with no gap.
|
|
witnessed = memory["last_known"] is not None and memory["unknown_since"] is None
|
|
if not witnessed:
|
|
zone["open_confirmed" if is_open else "close_confirmed"] = False
|
|
self.changed = True
|
|
elif memory["unknown_since"] is not None:
|
|
self.changed = True # the same state again after a gap
|
|
memory.update(last_known=is_open, seen=now, unknown_since=None)
|
|
if is_open:
|
|
zone["open_now"] = True
|
|
zone["open_windows"].append(window.name)
|
|
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, "last_known_open": memory["last_known"],
|
|
"last_known_since": memory["since"], "seen": memory["seen"],
|
|
"unknown_since": memory["unknown_since"],
|
|
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
|
|
if weather.get("wind_direction") is not None else None)}
|
|
# Time the integration was not running counts as unknown for events it interrupted.
|
|
gap = now - self._restored_at if self._restored_at is not None else 0.0
|
|
self._restored_at = None
|
|
for key, zone in zones.items():
|
|
if gap > 330 and key in self._open_event:
|
|
event = self._open_event[key]
|
|
event["sensor_unknown_minutes"] = round(event.get("sensor_unknown_minutes", 0.0) + gap / 60, 1)
|
|
self._update_zone(now, key, zone, temps.get(key), weather, zones)
|
|
self.zone_state = zones
|
|
self._last_temps = dict(temps)
|
|
self._last_update = now
|
|
|
|
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
|
|
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
|
|
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)),
|
|
"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, "sensor_unknown_minutes": 0.0, "last_seen_open": _iso(now),
|
|
}
|
|
if not zone["open_confirmed"]:
|
|
self._t_ref[key] = None # the room may already have cooled before we saw it
|
|
self.changed = True
|
|
elif ventilating and was_open:
|
|
event = self._open_event[key]
|
|
if temp is not None:
|
|
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
|
if zone["unknown_windows"]:
|
|
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1)
|
|
if zone["open_now"]:
|
|
event["last_seen_open"] = _iso(now)
|
|
elif was_open and not ventilating:
|
|
event = self._open_event.pop(key)
|
|
opened = _parse(event["opened"]) # the confirmed opening, or when it was first seen open
|
|
seen_open = _parse(event.get("last_seen_open", event["opened"]))
|
|
closed = zone["last_close"] or now if zone["close_confirmed"] else now
|
|
confirmed = zone["close_confirmed"] and event.get("opened_confirmed", True)
|
|
# Uncertain periods give a range instead of a duration.
|
|
event.update(
|
|
closed=_iso(closed) if zone["close_confirmed"] else None,
|
|
closed_confirmed=zone["close_confirmed"],
|
|
duration_minutes=round((closed - opened) / 60, 1) if confirmed else None,
|
|
duration_min_minutes=round(((closed if zone["close_confirmed"] else seen_open) - opened) / 60, 1),
|
|
duration_max_minutes=(round((now - opened) / 60, 1)
|
|
if not zone["close_confirmed"] and event.get("opened_confirmed", True)
|
|
else None))
|
|
if not zone["close_confirmed"]:
|
|
event["closed_reported"] = _iso(now) # closed at some point before this
|
|
self._closed_at[key] = closed
|
|
self._recovery_confirmed[key] = confirmed
|
|
event["temperature_at_close"] = temp
|
|
self.events.append(event)
|
|
self.changed = True
|
|
elif not ventilating and key in self._closed_at and temp is not None:
|
|
# Fill in the temperature half an hour after closing, for recovery review.
|
|
for event in reversed(self.events):
|
|
if event.get("zone") == key and "temperature_after" not in event:
|
|
if now - self._closed_at[key] >= EVENT_AFTER_MINUTES * 60:
|
|
event["temperature_after"] = temp
|
|
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 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 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.
|
|
|
|
`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
|
|
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": known,
|
|
"unknown_windows": list(zone["unknown_windows"])}
|
|
|
|
def assumption(self, window: Window, open_minutes: float, now: float) -> dict[str, Any]:
|
|
"""What forecasts assume for one window, and on what basis.
|
|
|
|
Observed open: open for `open_minutes` more. Unknown but last seen open: open
|
|
until `open_minutes` after it was last seen, never renewed, so an unavailable
|
|
sensor does not keep a window open forever. Unknown otherwise: no ventilation,
|
|
marked uncertain (`last_known_closed`, or `unobserved` with no reading at all).
|
|
"""
|
|
state = self.window_state.get(window.key, {})
|
|
if state.get("open"):
|
|
return {"basis": "observed", "open_minutes": float(open_minutes), "uncertain": False}
|
|
if state.get("open") is False:
|
|
return {"basis": "observed", "open_minutes": 0.0, "uncertain": False}
|
|
if state.get("last_known_open"):
|
|
seen = state.get("seen")
|
|
left = open_minutes - (now - seen) / 60 if seen is not None else 0.0
|
|
return {"basis": "last_known_open", "open_minutes": round(max(0.0, left), 1), "uncertain": True,
|
|
"assumed_closed_at": seen + open_minutes * 60 if seen is not None else now}
|
|
basis = "last_known_closed" if state.get("last_known_open") is False else "unobserved"
|
|
return {"basis": basis, "open_minutes": 0.0, "uncertain": True}
|
|
|
|
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
|
|
now: float) -> list[dict[str, Any]]:
|
|
"""Assumed ventilation inputs per forecast step (see `assumption`).
|
|
|
|
Each window open now, or assumed open, closes after its assumed time; once
|
|
the last one has closed the zone recovers. Weather is held at current values.
|
|
"""
|
|
zone = self.zone_state.get(key)
|
|
if zone is None:
|
|
return [{} for _ in range(steps)]
|
|
windows = [w for w in self.windows if w.zone == key and w.enabled]
|
|
assumed = {w.key: self.assumption(w, open_minutes, now) for w in windows}
|
|
# Steps each window stays open: at least one for an observed open window.
|
|
open_steps = {w.key: (max(1, round(open_minutes / 5)) if self.window_state.get(w.key, {}).get("open")
|
|
else round(assumed[w.key]["open_minutes"] / 5)) for w in windows}
|
|
direction = weather.get("wind_direction")
|
|
last_open = max(open_steps.values(), default=0)
|
|
if last_open:
|
|
start = now + last_open * 300.0
|
|
else:
|
|
# Recovery from the last close: observed, or assumed for a window last seen open.
|
|
closes = [a["assumed_closed_at"] for a in assumed.values() if "assumed_closed_at" in a]
|
|
closed_at = self._closed_at.get(key)
|
|
start = max([t for t in (closed_at, *closes) if t is not None], default=None)
|
|
result = []
|
|
for k in range(steps):
|
|
t = now + (k + 1) * 300.0
|
|
open_now = [w for w in windows if k < open_steps[w.key]]
|
|
if open_now:
|
|
result.append({
|
|
"opening": sum(w.opening for w in open_now),
|
|
"facing": (sum(w.opening * facing_cos(direction, w.azimuth) for w in open_now)
|
|
if direction is not None else 0.0),
|
|
"recovery": 0.0, "t_ref": zone["t_ref"], "other_opening": zone["other_opening"],
|
|
"wind": weather.get("wind") or 0.0,
|
|
"gust": weather.get("wind_gust") or weather.get("wind") or 0.0})
|
|
continue
|
|
recovery = exp(-(t - start) / self.tau) if start is not None and t > start else 0.0
|
|
result.append({"opening": 0.0, "facing": 0.0, "other_opening": 0.0, "wind": 0.0, "gust": 0.0,
|
|
"recovery": recovery if recovery >= RECOVERY_THRESHOLD else 0.0,
|
|
"t_ref": zone["t_ref"]})
|
|
return result
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
"""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:
|
|
return datetime.fromisoformat(iso).timestamp()
|
|
|
|
|
|
def validate_window(data: Mapping[str, Any], others: Iterable[Window]) -> str | None:
|
|
others = list(others)
|
|
if not str(data.get(WINDOW_NAME, "")).strip():
|
|
return "window_name_required"
|
|
if any(w.sensor == data.get(WINDOW_SENSOR) for w in others):
|
|
return "sensor_in_other_window"
|
|
if len(others) >= MAX_WINDOWS:
|
|
return "too_many_windows"
|
|
return None
|