Model windows, ventilation, wind exposure and recovery

Opening a window adds heat exchange that the envelope model mistook for
poor insulation or a constant cooling bias.

windows.py adds windows as config subentries ("Add window"): a contact
sensor (optionally inverted; unknown/unavailable stays unknown, never
"closed"), its zone, the direction its outside faces (default 345°,
editable) and an opening factor. Each poll computes the share of the
interval each window was open (one change located by its timestamp, a
round trip is unknown), the wind exposure with proper circular angles
(meteorological "from" direction vs the window's facing) and a recovery
weight after closing that decays towards 0, pulling the air back
towards its pre-ventilation temperature. A bounded event log records
opening/closing, duration, temperatures before/lowest/at close/30 min
after, weather and other open windows.

While a zone's window is open, unknown or recovering, the base and
extended models skip it; a separate ventilation learner fits the
closed-window model's residual with natural exchange, wind speed, a
directional term bounded by the wind term (a leeward window still
ventilates), recovery, and optional gust excess and cross-ventilation
terms. Forecasts assume an open window closes after a configurable time
(default 15 min), then recovers. New poll outcome "ventilation".

Weather gains optional wind direction (degrees, 360 -> 0) and wind gust
sources with the same priorities and fallback; knots and ft/s accepted.

On a synthetic week with ventilation events every four hours and a
daily outdoor swing, the configured zone keeps loss/heat/bias close to
the truth and recovers the combined natural+wind effect within 15 %,
recovery within 15 % and direction within 30 %. The same data without
the window configured produces a -0.013 °C/5 min cooling bias and an
11 % inflated boiler effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:47:40 +02:00
1 parent 00e70c612b
commit ed8f9fe2e2
13 files changed
+1157 -39

No files matched your search

@@ -14,6 +14,8 @@ LIMITS = {
"temperature": (-45.0, 55.0),
"feels_like": (-65.0, 65.0),
"wind": (0.0, 90.0), # m/s
"wind_gust": (0.0, 120.0), # m/s
"wind_direction": (0.0, 360.0), # degrees the wind comes from
"clouds": (0.0, 100.0), # percentage
"humidity": (0.0, 100.0), # percentage
"pressure": (800.0, 1100.0), # hPa
@@ -52,11 +54,20 @@ def normalize(metric: str, raw: object, unit: str | None = None) -> float | None
value = (value - 32.0) * 5.0 / 9.0
elif unit not in ("", "°c", "c", "celsius"):
return None
elif metric == "wind":
elif metric == "wind_direction":
if unit not in ("", "°", "deg", "degree", "degrees"):
return None
if value == 360.0:
value = 0.0
elif metric in ("wind", "wind_gust"):
if unit in ("km/h", "kmh", "kph"):
value /= 3.6
elif unit in ("mph",):
value *= 0.44704
elif unit in ("kn", "kt", "knots"):
value *= 0.514444
elif unit in ("ft/s",):
value *= 0.3048
elif unit not in ("", "m/s", "mps"):
return None
elif metric == "pressure":