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:
1 parent
00e70c612b
commit
ed8f9fe2e2
13 files changed
+1157
-39
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user