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

+68 -1
View File
@@ -14,6 +14,7 @@ from .const import (
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS,
CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST, CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES,
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
)
from .heat_sources import (
@@ -23,6 +24,11 @@ from .heat_sources import (
HeatSource, build_sources, validate_source,
)
from .weather_sources import sources_for
from .windows import (
DEFAULT_AZIMUTH, OPENING_RANGE, SUBENTRY_WINDOW, WINDOW_AZIMUTH, WINDOW_ENABLED, WINDOW_INVERTED,
WINDOW_KEY, WINDOW_NAME, WINDOW_OPENING, WINDOW_SENSOR, WINDOW_ZONE, Window, build_windows,
validate_window,
)
from .zones import (
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE,
ZONE_COMFORT_WEIGHT, ZONE_KEY, ZONE_NAME, ZONE_NEIGHBORS, ZONE_SENSOR_WEIGHTS,
@@ -66,8 +72,12 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)),
CONF_INTERNAL_HALF_LIFE: vol.All(vol.Coerce(int), vol.Range(min=0, max=720)),
CONF_WINDOW_OPEN_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)),
CONF_RECOVERY_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)),
}.items():
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed
for key in (CONF_VENT_WIND, CONF_VENT_GUST, CONF_VENT_CROSS):
schema[vol.Required(key, default=bool(data.get(key, DEFAULTS[key])))] = selector.BooleanSelector()
return vol.Schema(schema)
@@ -191,7 +201,8 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@classmethod
@callback
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry):
return {SUBENTRY_ZONE: ZoneSubentryFlow, SUBENTRY_HEAT_SOURCE: HeatSourceSubentryFlow}
return {SUBENTRY_ZONE: ZoneSubentryFlow, SUBENTRY_HEAT_SOURCE: HeatSourceSubentryFlow,
SUBENTRY_WINDOW: WindowSubentryFlow}
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
@@ -377,3 +388,59 @@ class HeatSourceSubentryFlow(config_entries.ConfigSubentryFlow):
existing = dict(subentry.data)
existing.setdefault(SOURCE_KEY, subentry.subentry_id)
return await self._form("reconfigure", user_input, existing)
def entry_windows(entry: config_entries.ConfigEntry) -> list[Window]:
zones = {zone.key for zone in entry_zones(entry)}
return build_windows(((sub.data, sub.subentry_id) for sub in entry.subentries.values()
if sub.subentry_type == SUBENTRY_WINDOW), zones)
class WindowSubentryFlow(config_entries.ConfigSubentryFlow):
"""Add or edit a window contact sensor with its zone and orientation."""
def _schema(self, data: dict[str, Any]) -> vol.Schema:
zones = entry_zones(self._get_entry())
return vol.Schema({
vol.Required(WINDOW_NAME, default=data.get(WINDOW_NAME, "")): selector.TextSelector(),
vol.Required(WINDOW_SENSOR, **({"default": data[WINDOW_SENSOR]} if data.get(WINDOW_SENSOR) else {})):
selector.EntitySelector(selector.EntitySelectorConfig(domain="binary_sensor")),
vol.Required(WINDOW_ZONE, **({"default": data[WINDOW_ZONE]} if data.get(WINDOW_ZONE) else {})):
selector.SelectSelector(selector.SelectSelectorConfig(
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])),
vol.Required(WINDOW_INVERTED, default=data.get(WINDOW_INVERTED, False)): selector.BooleanSelector(),
vol.Required(WINDOW_AZIMUTH, default=data.get(WINDOW_AZIMUTH, DEFAULT_AZIMUTH)):
_number(0.0, 359.0, 1.0),
vol.Required(WINDOW_OPENING, default=data.get(WINDOW_OPENING, 1.0)): _number(*OPENING_RANGE, 0.1),
vol.Required(WINDOW_ENABLED, default=data.get(WINDOW_ENABLED, True)): selector.BooleanSelector(),
})
async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]):
key = existing[WINDOW_KEY]
if not entry_zones(self._get_entry()):
return self.async_abort(reason="no_zones")
if user_input is not None:
data = {**existing, **user_input, WINDOW_KEY: key}
error = validate_window(data, [w for w in entry_windows(self._get_entry()) if w.key != key])
if error:
return self.async_show_form(step_id=step_id, data_schema=self._schema(data),
errors={"base": error})
data[WINDOW_NAME] = data[WINDOW_NAME].strip()
data[WINDOW_AZIMUTH] = float(data[WINDOW_AZIMUTH])
data[WINDOW_OPENING] = float(data[WINDOW_OPENING])
if self.source == config_entries.SOURCE_RECONFIGURE:
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
title=data[WINDOW_NAME], data=data)
return self.async_create_entry(title=data[WINDOW_NAME], data=data, unique_id=key)
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing))
async def async_step_user(self, user_input: dict[str, Any] | None = None):
if not hasattr(self, "_key"):
self._key = f"window_{uuid4().hex[:10]}"
return await self._form("user", user_input, {WINDOW_KEY: self._key})
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
subentry = self._get_reconfigure_subentry()
existing = dict(subentry.data)
existing.setdefault(WINDOW_KEY, subentry.subentry_id)
return await self._form("reconfigure", user_input, existing)