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

+125 -18
View File
@@ -17,7 +17,8 @@ from .const import (
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES,
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES,
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST,
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
)
from .data_quality import ReportAgeHistory, freshness
from .evaluation import ForecastEvaluator
@@ -28,6 +29,7 @@ from .physics import (
lag_from_time_in_state, optimize, relay_segments,
)
from .weather_sources import choose_weather_sources, sources_for, normalize
from .windows import SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, facing_cos, ventilation_vector
from .zone_model import ZoneModel, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
@@ -49,6 +51,29 @@ def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
return build_zones(items)
def entry_window_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[Window]:
subentries = getattr(entry, "subentries", None) or {}
return build_windows(((sub.data, sub.subentry_id) for sub in subentries.values()
if sub.subentry_type == SUBENTRY_WINDOW), zones)
def ventilation_features(config: dict[str, Any], windows: list[Window], zone: str) -> list[str]:
"""Ventilation terms for a zone with windows; optional ones need their inputs."""
if not any(w.zone == zone and w.enabled for w in windows):
return []
wind = config.get(CONF_VENT_WIND) is True and bool(sources_for(config, "wind"))
features = ["vent_natural", "recovery"]
if wind:
features.append("vent_wind")
if sources_for(config, "wind_direction"):
features.append("vent_wind_dir")
if config.get(CONF_VENT_GUST) is True and sources_for(config, "wind_gust"):
features.append("vent_gust")
if config.get(CONF_VENT_CROSS) is True and len({w.zone for w in windows if w.enabled}) > 1:
features.append("vent_cross")
return features
def entry_source_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[HeatSource]:
subentries = getattr(entry, "subentries", None) or {}
return build_sources(((sub.data, sub.subentry_id) for sub in subentries.values()
@@ -76,6 +101,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for key, zone in self.zones.items()}
self.extended: dict[str, FeatureLearner] = {
key: FeatureLearner(features) for key, features in self._extended_features.items() if features}
self.windows = entry_window_list(entry, self.zones)
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]))
self._ventilation_features = {key: ventilation_features(self.config, self.windows, key)
for key in self.zones}
self.ventilation: dict[str, FeatureLearner] = {
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features}
self._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory()
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
@@ -109,6 +140,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if features and key in saved_models:
# Matched by feature name: a new source or neighbour retrains only its term.
self.extended[key] = FeatureLearner.from_dict(saved_models[key], features)
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]),
stored.get("ventilation"))
saved_vent = stored.get("ventilation_models") if isinstance(stored.get("ventilation_models"), dict) else {}
for key, features in self._ventilation_features.items():
if features and key in saved_vent:
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
# Models carry room identity, so changing the room list is safe. Models of
# rooms no longer configured are retained, most recently removed last.
for key in ("retired_learners", "learners"): # current learners take precedence
@@ -238,21 +275,63 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
}
return result
def _step_model(self, temps: dict[str, float], outside: float, horizon: int):
"""Multi-zone step for the optimizer, or None when only base models are in use."""
models = {key: ZoneModel(self.learners[key], self.extended.get(key)) for key in temps}
if not any(model.uses_extended for model in models.values()):
return None
def _step_model(self, temps: dict[str, float], outside: float, horizon: int,
weather: dict[str, Any], now: float):
"""Multi-zone step for the optimizer, or None when only base models apply."""
models = {key: ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
for key in temps}
steps = horizon // 5
ventilation = {key: self._ventilation.forecast(
key, steps, float(self.config[CONF_WINDOW_OPEN_MINUTES]), weather, now) for key in temps}
venting = any(models[key].ventilation is not None
and any(s.get("opening") or s.get("recovery") for s in ventilation[key]) for key in temps)
if not venting and not any(model.uses_extended for model in models.values()):
return None
half_life = float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30))
forecast = {key: self._heat_sources.zone_forecast(key, steps, half_life) for key in temps}
def step(state: dict[str, float], lag: float, k: int) -> dict[str, float]:
k = min(k, steps - 1)
return {key: value + models[key].delta(value, {
"outside": outside, "lag": lag, "internal": forecast[key][min(k, steps - 1)], "temps": state})
for key, value in state.items()}
"outside": outside, "lag": lag, "internal": forecast[key][k], "temps": state,
"ventilation": ventilation[key][k]}) for key, value in state.items()}
return step
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
vent_inputs: dict[str, Any], internal: dict[str, float | None]) -> dict[str, Any]:
windows = {}
for window in self.windows:
info = self._ventilation.window_state.get(window.key, {})
is_open, since = info.get("open"), info.get("since")
direction = weather.get("wind_direction")
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",
"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"),
"wind_exposure": round(facing_cos(direction, window.azimuth), 3) if direction is not None else None,
}
zones = {}
for key, inputs in vent_inputs.items():
model = self.ventilation.get(key)
if inputs is None or model is None:
continue
temp, outside = temps.get(key), weather.get("temperature")
contributions = {}
if temp is not None and outside is not None:
terms = ZoneModel(self.learners[key], self.extended.get(key), model).contributions(temp, {
"outside": outside, "lag": (self._lag or {}).get("lag", 0.0), "internal": internal[key],
"temps": temps, "ventilation": inputs})
contributions = {name: terms.get(name, 0.0) for name in ("ventilation", "recovery")}
zones[self.zones[key].name] = {
"opening": inputs["opening"], "recovery": round(inputs["recovery"], 3),
"temperature_before": inputs["t_ref"], "affected": inputs["affected"],
"contribution_c_per_h": contributions, "samples": model.count,
"coefficients": model.coefficients()}
return {"windows": windows, "ventilation": zones,
"ventilation_events": list(self._ventilation.events)[-5:]}
def _prediction_inputs(self) -> tuple:
"""Values the prediction depends on, used to detect changes during calculation."""
heater = self.config[CONF_HEATER]
@@ -363,6 +442,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
internal = {key: self._heat_sources.zone_input(key) for key in self.zones}
if self.sources:
status["heat_sources"] = self._source_diagnostics(source_quality)
# Windows: tracked every poll; unknown states stay unknown, never "closed".
window_states = {}
for window in self.windows:
state = self.hass.states.get(window.sensor)
window_states[window.key] = ((state.state, state.last_changed.timestamp()) if state else (None, None))
self._ventilation.update(now.timestamp(), window_states, temps, weather)
vent_inputs = {key: self._ventilation.zone_inputs(key, weather) for key in self.zones}
if self.windows:
status.update(self._window_diagnostics(now.timestamp(), weather, temps, vent_inputs, internal))
heater_valid = heater is not None and heater.state in ("on", "off")
heater_on = heater_valid and heater.state == "on"
steady = False
@@ -407,24 +495,40 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY:
status["sample_result"] = "lag_uncertain"
else:
# Window open, unknown or recovering: not envelope loss; keep it out of
# the closed-window models and give it to the ventilation model.
vented = [room for room in pairs if (vent_inputs.get(room) or {}).get("affected")]
clean = [room for room in pairs if room not in vented]
accepted = [
self.learners[room].learn(
self._previous["temps"][room], vals[room],
self._previous["outside"], self._lag["lag"], dt, heater_on)
for room in pairs
for room in clean
]
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts()
learned = True
# Extended models learn the same interval when all their inputs are known.
before = {"outside": self._previous["outside"], "lag": self._lag["lag"],
"temps": self._previous["temps"]}
for room in pairs:
model = self.extended.get(room)
x = model and feature_vector(model.features, self._previous["temps"][room],
{**before, "internal": internal[room]})
start = self._previous["temps"][room]
change = (vals[room] - start) * 300 / dt
inputs = {**before, "internal": internal[room]}
if room in clean:
# Extended models learn the same interval when all their inputs are known.
model = self.extended.get(room)
x = model and feature_vector(model.features, start, inputs)
if x:
model.learn(x, change, heater_on)
continue
model = self.ventilation.get(room)
x = model and ventilation_vector(model.features, start, before["outside"], vent_inputs[room])
if x:
model.learn(x, (vals[room] - self._previous["temps"][room]) * 300 / dt, heater_on)
main = ZoneModel(self.learners[room], self.extended.get(room)).main_delta(start, inputs)
model.learn(x, change - main, heater_on)
if clean:
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts()
else:
status["sample_result"] = "ventilation"
learned = True
self._count_result(status, status["sample_result"])
self._previous = {
@@ -444,6 +548,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"retired_learners": self._retired,
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
"heat_sources": self._heat_sources.as_dict(),
"ventilation_models": {key: model.as_dict() for key, model in self.ventilation.items()},
"ventilation": self._ventilation.as_dict(),
"evaluation": self._evaluator.as_dict(),
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
@@ -511,7 +617,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
heating_cost=float(self.config[CONF_HEAT_COST]),
comfort_band=float(self.config[CONF_BAND]),
target_offsets={room: self.zones[room].target_offset for room in vals},
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON])),
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
weather, now.timestamp()),
)
# Discard a result if a value used by the prediction changed while
# saving or while the worker calculated the hypothetical plan. Optional