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
+1156 -38

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, DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS, 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, WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
) )
from .heat_sources import ( from .heat_sources import (
@@ -23,6 +24,11 @@ from .heat_sources import (
HeatSource, build_sources, validate_source, HeatSource, build_sources, validate_source,
) )
from .weather_sources import sources_for 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 ( from .zones import (
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE, COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE,
ZONE_COMFORT_WEIGHT, ZONE_KEY, ZONE_NAME, ZONE_NEIGHBORS, ZONE_SENSOR_WEIGHTS, 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_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_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_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(): }.items():
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed 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) return vol.Schema(schema)
@@ -191,7 +201,8 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@classmethod @classmethod
@callback @callback
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry): 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): class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
@@ -377,3 +388,59 @@ class HeatSourceSubentryFlow(config_entries.ConfigSubentryFlow):
existing = dict(subentry.data) existing = dict(subentry.data)
existing.setdefault(SOURCE_KEY, subentry.subentry_id) existing.setdefault(SOURCE_KEY, subentry.subentry_id)
return await self._form("reconfigure", user_input, existing) 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)
+18 -2
View File
@@ -18,11 +18,22 @@ CONF_ROOM_STALE_KEEP = "room_stale_use_last_value"
CONF_BAND = "comfort_band" CONF_BAND = "comfort_band"
# Forecast assumption: extra electrical heat returns to baseline with this half-life. # Forecast assumption: extra electrical heat returns to baseline with this half-life.
CONF_INTERNAL_HALF_LIFE = "internal_heat_half_life_minutes" CONF_INTERNAL_HALF_LIFE = "internal_heat_half_life_minutes"
# Forecast assumption: an open window closes after this many more minutes.
CONF_WINDOW_OPEN_MINUTES = "window_open_minutes"
# Time constant of air recovering towards the walls' temperature after closing.
CONF_RECOVERY_MINUTES = "recovery_minutes"
# Optional ventilation terms (each must earn its keep in the accuracy comparison).
CONF_VENT_WIND = "ventilation_wind"
CONF_VENT_GUST = "ventilation_gust"
CONF_VENT_CROSS = "ventilation_cross"
# Each source list is ordered: first valid sensor wins; remaining are fallbacks. # Each source list is ordered: first valid sensor wins; remaining are fallbacks.
WEATHER_SOURCES = { WEATHER_SOURCES = {
"temperature": "outside_temperature_sources", "temperature": "outside_temperature_sources",
"wind": "outside_wind_sources", "wind": "outside_wind_sources",
# Meteorological: the direction the wind blows FROM, degrees clockwise from north.
"wind_direction": "outside_wind_direction_sources",
"wind_gust": "outside_wind_gust_sources",
"clouds": "outside_clouds_sources", "clouds": "outside_clouds_sources",
"humidity": "outside_humidity_sources", "humidity": "outside_humidity_sources",
"feels_like": "outside_feelslike_sources", "feels_like": "outside_feelslike_sources",
@@ -31,7 +42,7 @@ WEATHER_SOURCES = {
"rain": "outside_rain_sources", "rain": "outside_rain_sources",
"snow": "outside_snow_sources", "snow": "outside_snow_sources",
} }
WEATHER_MAIN = ("temperature", "wind", "clouds") WEATHER_MAIN = ("temperature", "wind", "wind_direction", "wind_gust", "clouds")
WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow") WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow")
DEFAULTS = { DEFAULTS = {
@@ -45,6 +56,11 @@ DEFAULTS = {
CONF_HEAT_COST: 0.025, CONF_HEAT_COST: 0.025,
CONF_BAND: 0.15, CONF_BAND: 0.15,
CONF_INTERNAL_HALF_LIFE: 30, CONF_INTERNAL_HALF_LIFE: 30,
CONF_WINDOW_OPEN_MINUTES: 15,
CONF_RECOVERY_MINUTES: 20,
CONF_VENT_WIND: True,
CONF_VENT_GUST: False,
CONF_VENT_CROSS: False,
} }
# A room sensor silent this long is treated as dead even when keeping last values. # A room sensor silent this long is treated as dead even when keeping last values.
ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60 ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60
@@ -55,7 +71,7 @@ READY_ON_SAMPLES = 30
READY_OFF_SAMPLES = 30 READY_OFF_SAMPLES = 30
# Outcome of each poll for learning (Status sample_result, Last Sample sensor). # Outcome of each poll for learning (Status sample_result, Last Sample sensor).
SAMPLE_RESULTS = ( SAMPLE_RESULTS = (
"waiting_for_inputs", "first_sample", "sensors_changed", "learned", "partly_rejected", "waiting_for_inputs", "first_sample", "sensors_changed", "learned", "ventilation", "partly_rejected",
"interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain", "interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain",
) )
BLOCK_MINUTES = 15 BLOCK_MINUTES = 15
+124 -17
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, 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, 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_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 .data_quality import ReportAgeHistory, freshness
from .evaluation import ForecastEvaluator from .evaluation import ForecastEvaluator
@@ -28,6 +29,7 @@ from .physics import (
lag_from_time_in_state, optimize, relay_segments, lag_from_time_in_state, optimize, relay_segments,
) )
from .weather_sources import choose_weather_sources, sources_for, normalize 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 .zone_model import ZoneModel, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature 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) 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]: def entry_source_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[HeatSource]:
subentries = getattr(entry, "subentries", None) or {} subentries = getattr(entry, "subentries", None) or {}
return build_sources(((sub.data, sub.subentry_id) for sub in subentries.values() 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()} for key, zone in self.zones.items()}
self.extended: dict[str, FeatureLearner] = { self.extended: dict[str, FeatureLearner] = {
key: FeatureLearner(features) for key, features in self._extended_features.items() if features} 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._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory() self._report_ages = ReportAgeHistory()
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY) self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
@@ -109,6 +140,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if features and key in saved_models: if features and key in saved_models:
# Matched by feature name: a new source or neighbour retrains only its term. # Matched by feature name: a new source or neighbour retrains only its term.
self.extended[key] = FeatureLearner.from_dict(saved_models[key], features) 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 # Models carry room identity, so changing the room list is safe. Models of
# rooms no longer configured are retained, most recently removed last. # rooms no longer configured are retained, most recently removed last.
for key in ("retired_learners", "learners"): # current learners take precedence for key in ("retired_learners", "learners"): # current learners take precedence
@@ -238,21 +275,63 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
} }
return result return result
def _step_model(self, temps: dict[str, float], outside: float, horizon: int): 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.""" weather: dict[str, Any], now: float):
models = {key: ZoneModel(self.learners[key], self.extended.get(key)) for key in temps} """Multi-zone step for the optimizer, or None when only base models apply."""
if not any(model.uses_extended for model in models.values()): models = {key: ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
return None for key in temps}
steps = horizon // 5 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)) 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} 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]: 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, { return {key: value + models[key].delta(value, {
"outside": outside, "lag": lag, "internal": forecast[key][min(k, steps - 1)], "temps": state}) "outside": outside, "lag": lag, "internal": forecast[key][k], "temps": state,
for key, value in state.items()} "ventilation": ventilation[key][k]}) for key, value in state.items()}
return step 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: def _prediction_inputs(self) -> tuple:
"""Values the prediction depends on, used to detect changes during calculation.""" """Values the prediction depends on, used to detect changes during calculation."""
heater = self.config[CONF_HEATER] 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} internal = {key: self._heat_sources.zone_input(key) for key in self.zones}
if self.sources: if self.sources:
status["heat_sources"] = self._source_diagnostics(source_quality) 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_valid = heater is not None and heater.state in ("on", "off")
heater_on = heater_valid and heater.state == "on" heater_on = heater_valid and heater.state == "on"
steady = False steady = False
@@ -407,24 +495,40 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY: elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY:
status["sample_result"] = "lag_uncertain" status["sample_result"] = "lag_uncertain"
else: 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 = [ accepted = [
self.learners[room].learn( self.learners[room].learn(
self._previous["temps"][room], vals[room], self._previous["temps"][room], vals[room],
self._previous["outside"], self._lag["lag"], dt, heater_on) 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"], before = {"outside": self._previous["outside"], "lag": self._lag["lag"],
"temps": self._previous["temps"]} "temps": self._previous["temps"]}
for room in pairs: for room in pairs:
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) model = self.extended.get(room)
x = model and feature_vector(model.features, self._previous["temps"][room], x = model and feature_vector(model.features, start, inputs)
{**before, "internal": internal[room]})
if x: if x:
model.learn(x, (vals[room] - self._previous["temps"][room]) * 300 / dt, heater_on) 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:
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._count_result(status, status["sample_result"])
self._previous = { self._previous = {
@@ -444,6 +548,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"retired_learners": self._retired, "retired_learners": self._retired,
"zone_models": {key: model.as_dict() for key, model in self.extended.items()}, "zone_models": {key: model.as_dict() for key, model in self.extended.items()},
"heat_sources": self._heat_sources.as_dict(), "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(), "evaluation": self._evaluator.as_dict(),
"on_samples": self._on_samples, "off_samples": self._off_samples, "on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER], "heater_entity": self.config[CONF_HEATER],
@@ -511,7 +617,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
heating_cost=float(self.config[CONF_HEAT_COST]), heating_cost=float(self.config[CONF_HEAT_COST]),
comfort_band=float(self.config[CONF_BAND]), comfort_band=float(self.config[CONF_BAND]),
target_offsets={room: self.zones[room].target_offset for room in vals}, 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 # Discard a result if a value used by the prediction changed while
# saving or while the worker calculated the hypothetical plan. Optional # saving or while the worker calculated the hypothetical plan. Optional
+2 -1
View File
@@ -35,7 +35,8 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
_attr_icon = "mdi:brain" _attr_icon = "mdi:brain"
# Per-source report ages change every poll; recording them adds a new # Per-source report ages change every poll; recording them adds a new
# attribute row to the database every five minutes. Current values stay visible. # attribute row to the database every five minutes. Current values stay visible.
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison"}) _unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
"ventilation", "ventilation_events"})
def __init__(self, coordinator, entry_id): def __init__(self, coordinator, entry_id):
super().__init__(coordinator) super().__init__(coordinator)
+73 -4
View File
@@ -23,7 +23,9 @@
"data": { "data": {
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
"outside_wind_sources": "Wind speed sources (optional)", "outside_wind_sources": "Wind speed sources (optional)",
"outside_clouds_sources": "Cloud coverage sources (optional)" "outside_clouds_sources": "Cloud coverage sources (optional)",
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
"outside_wind_gust_sources": "Wind gust sources (optional)"
} }
}, },
"weather_extra": { "weather_extra": {
@@ -68,11 +70,21 @@
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)", "weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting", "room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting",
"internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)" "internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)",
"window_open_minutes": "Open window: assumed remaining open time (minutes)",
"recovery_minutes": "Recovery after closing a window (minutes)",
"ventilation_wind": "Ventilation: use wind speed and direction",
"ventilation_gust": "Ventilation: use wind gusts",
"ventilation_cross": "Ventilation: cross-ventilation between zones"
}, },
"data_description": { "data_description": {
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.", "room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.",
"internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now." "internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now.",
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Forecast Error improves.",
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
} }
}, },
"weather": { "weather": {
@@ -81,7 +93,9 @@
"data": { "data": {
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
"outside_wind_sources": "Wind speed sources (optional)", "outside_wind_sources": "Wind speed sources (optional)",
"outside_clouds_sources": "Cloud coverage sources (optional)" "outside_clouds_sources": "Cloud coverage sources (optional)",
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
"outside_wind_gust_sources": "Wind gust sources (optional)"
} }
}, },
"weather_extra": { "weather_extra": {
@@ -109,6 +123,7 @@
"first_sample": "First sample", "first_sample": "First sample",
"sensors_changed": "Zone sensors changed", "sensors_changed": "Zone sensors changed",
"learned": "Learned", "learned": "Learned",
"ventilation": "Window open or recovering",
"partly_rejected": "Partly rejected (outlier)", "partly_rejected": "Partly rejected (outlier)",
"interval_out_of_range": "Irregular interval", "interval_out_of_range": "Irregular interval",
"relay_changed": "Relay switched", "relay_changed": "Relay switched",
@@ -236,6 +251,60 @@
"reconfigure_successful": "The heat source was updated.", "reconfigure_successful": "The heat source was updated.",
"no_zones": "Add a zone first." "no_zones": "Add a zone first."
} }
},
"window": {
"entry_type": "Window",
"initiate_flow": {
"user": "Add window",
"reconfigure": "Edit window"
},
"step": {
"user": {
"title": "Window",
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
"data": {
"name": "Name",
"sensor": "Contact sensor",
"zone": "Zone",
"inverted": "Sensor reports on when closed",
"azimuth": "Facing direction (°)",
"opening_factor": "Opening factor",
"enabled": "Enabled"
},
"data_description": {
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
}
},
"reconfigure": {
"title": "Window",
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
"data": {
"name": "Name",
"sensor": "Contact sensor",
"zone": "Zone",
"inverted": "Sensor reports on when closed",
"azimuth": "Facing direction (°)",
"opening_factor": "Opening factor",
"enabled": "Enabled"
},
"data_description": {
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
}
}
},
"error": {
"window_name_required": "Enter a name.",
"sensor_in_other_window": "This sensor is already used by another window.",
"too_many_windows": "At most 24 windows are supported."
},
"abort": {
"reconfigure_successful": "The window was updated.",
"no_zones": "Add a zone first."
}
} }
}, },
"selector": { "selector": {
@@ -23,7 +23,9 @@
"data": { "data": {
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
"outside_wind_sources": "Wind speed sources (optional)", "outside_wind_sources": "Wind speed sources (optional)",
"outside_clouds_sources": "Cloud coverage sources (optional)" "outside_clouds_sources": "Cloud coverage sources (optional)",
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
"outside_wind_gust_sources": "Wind gust sources (optional)"
} }
}, },
"weather_extra": { "weather_extra": {
@@ -68,11 +70,21 @@
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)", "weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting", "room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting",
"internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)" "internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)",
"window_open_minutes": "Open window: assumed remaining open time (minutes)",
"recovery_minutes": "Recovery after closing a window (minutes)",
"ventilation_wind": "Ventilation: use wind speed and direction",
"ventilation_gust": "Ventilation: use wind gusts",
"ventilation_cross": "Ventilation: cross-ventilation between zones"
}, },
"data_description": { "data_description": {
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.", "room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.",
"internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now." "internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now.",
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Forecast Error improves.",
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
} }
}, },
"weather": { "weather": {
@@ -81,7 +93,9 @@
"data": { "data": {
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
"outside_wind_sources": "Wind speed sources (optional)", "outside_wind_sources": "Wind speed sources (optional)",
"outside_clouds_sources": "Cloud coverage sources (optional)" "outside_clouds_sources": "Cloud coverage sources (optional)",
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
"outside_wind_gust_sources": "Wind gust sources (optional)"
} }
}, },
"weather_extra": { "weather_extra": {
@@ -109,6 +123,7 @@
"first_sample": "First sample", "first_sample": "First sample",
"sensors_changed": "Zone sensors changed", "sensors_changed": "Zone sensors changed",
"learned": "Learned", "learned": "Learned",
"ventilation": "Window open or recovering",
"partly_rejected": "Partly rejected (outlier)", "partly_rejected": "Partly rejected (outlier)",
"interval_out_of_range": "Irregular interval", "interval_out_of_range": "Irregular interval",
"relay_changed": "Relay switched", "relay_changed": "Relay switched",
@@ -236,6 +251,60 @@
"reconfigure_successful": "The heat source was updated.", "reconfigure_successful": "The heat source was updated.",
"no_zones": "Add a zone first." "no_zones": "Add a zone first."
} }
},
"window": {
"entry_type": "Window",
"initiate_flow": {
"user": "Add window",
"reconfigure": "Edit window"
},
"step": {
"user": {
"title": "Window",
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
"data": {
"name": "Name",
"sensor": "Contact sensor",
"zone": "Zone",
"inverted": "Sensor reports on when closed",
"azimuth": "Facing direction (°)",
"opening_factor": "Opening factor",
"enabled": "Enabled"
},
"data_description": {
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
}
},
"reconfigure": {
"title": "Window",
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
"data": {
"name": "Name",
"sensor": "Contact sensor",
"zone": "Zone",
"inverted": "Sensor reports on when closed",
"azimuth": "Facing direction (°)",
"opening_factor": "Opening factor",
"enabled": "Enabled"
},
"data_description": {
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
}
}
},
"error": {
"window_name_required": "Enter a name.",
"sensor_in_other_window": "This sensor is already used by another window.",
"too_many_windows": "At most 24 windows are supported."
},
"abort": {
"reconfigure_successful": "The window was updated.",
"no_zones": "Add a zone first."
}
} }
}, },
"selector": { "selector": {
@@ -23,7 +23,9 @@
"data": { "data": {
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)", "outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
"outside_wind_sources": "Senzori viteză vânt (opțional)", "outside_wind_sources": "Senzori viteză vânt (opțional)",
"outside_clouds_sources": "Senzori nebulozitate (opțional)" "outside_clouds_sources": "Senzori nebulozitate (opțional)",
"outside_wind_direction_sources": "Surse pentru direcția vântului (opțional; de unde bate, în grade)",
"outside_wind_gust_sources": "Surse pentru rafale de vânt (opțional)"
} }
}, },
"weather_extra": { "weather_extra": {
@@ -68,11 +70,21 @@
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)", "room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)", "weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)",
"room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează", "room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează",
"internal_heat_half_life_minutes": "Căldură electrică suplimentară: timp de înjumătățire în prognoză (minute)" "internal_heat_half_life_minutes": "Căldură electrică suplimentară: timp de înjumătățire în prognoză (minute)",
"window_open_minutes": "Fereastră deschisă: timp rămas presupus (minute)",
"recovery_minutes": "Revenire după închiderea ferestrei (minute)",
"ventilation_wind": "Aerisire: folosește viteza și direcția vântului",
"ventilation_gust": "Aerisire: folosește rafalele de vânt",
"ventilation_cross": "Aerisire: curent între zone"
}, },
"data_description": { "data_description": {
"room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită.", "room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită.",
"internal_heat_half_life_minutes": "Prognozele presupun că puterea peste consumul de bază scade cu acest timp de înjumătățire, de exemplu la finalul unei sesiuni de jocuri. 0 presupune că se oprește acum." "internal_heat_half_life_minutes": "Prognozele presupun că puterea peste consumul de bază scade cu acest timp de înjumătățire, de exemplu la finalul unei sesiuni de jocuri. 0 presupune că se oprește acum.",
"window_open_minutes": "Prognozele presupun că o fereastră deschisă se închide după atâtea minute, apoi camera revine.",
"recovery_minutes": "Constanta de timp a aerului care se încălzește din nou spre temperatura pereților.",
"ventilation_wind": "Necesită surse de vânt; direcția necesită și surse pentru direcția vântului.",
"ventilation_gust": "Adaugă un termen pentru rafalele peste vântul mediu. Păstrează-l doar dacă eroarea de prognoză scade.",
"ventilation_cross": "Adaugă un termen pentru ferestre deschise în mai multe zone deodată. Necesită ferestre în cel puțin două zone."
} }
}, },
"weather": { "weather": {
@@ -81,7 +93,9 @@
"data": { "data": {
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)", "outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
"outside_wind_sources": "Senzori viteză vânt (opțional)", "outside_wind_sources": "Senzori viteză vânt (opțional)",
"outside_clouds_sources": "Senzori nebulozitate (opțional)" "outside_clouds_sources": "Senzori nebulozitate (opțional)",
"outside_wind_direction_sources": "Surse pentru direcția vântului (opțional; de unde bate, în grade)",
"outside_wind_gust_sources": "Surse pentru rafale de vânt (opțional)"
} }
}, },
"weather_extra": { "weather_extra": {
@@ -109,6 +123,7 @@
"first_sample": "Prima măsurătoare", "first_sample": "Prima măsurătoare",
"sensors_changed": "Senzorii zonei s-au schimbat", "sensors_changed": "Senzorii zonei s-au schimbat",
"learned": "Învățat", "learned": "Învățat",
"ventilation": "Fereastră deschisă sau revenire după aerisire",
"partly_rejected": "Respins parțial (valoare aberantă)", "partly_rejected": "Respins parțial (valoare aberantă)",
"interval_out_of_range": "Interval neregulat", "interval_out_of_range": "Interval neregulat",
"relay_changed": "Releul a comutat", "relay_changed": "Releul a comutat",
@@ -236,6 +251,60 @@
"reconfigure_successful": "Sursa de căldură a fost actualizată.", "reconfigure_successful": "Sursa de căldură a fost actualizată.",
"no_zones": "Adaugă mai întâi o zonă." "no_zones": "Adaugă mai întâi o zonă."
} }
},
"window": {
"entry_type": "Fereastră",
"initiate_flow": {
"user": "Adaugă fereastră",
"reconfigure": "Modifică fereastra"
},
"step": {
"user": {
"title": "Fereastră",
"description": "Un senzor de contact pentru fereastră. Perioadele cu fereastra deschisă nu intră în învățarea pierderilor normale de căldură ale zonei și sunt modelate separat, ca aerisire. Stările necunoscute sau indisponibile sunt tratate ca necunoscute, niciodată ca închise.",
"data": {
"name": "Nume",
"sensor": "Senzor de contact",
"zone": "Zonă",
"inverted": "Senzorul raportează pornit când e închis",
"azimuth": "Orientare (°)",
"opening_factor": "Factor de deschidere",
"enabled": "Activă"
},
"data_description": {
"inverted": "Activează pentru senzorii cu polaritate inversă față de un senzor de fereastră obișnuit.",
"azimuth": "Direcția spre care privește exteriorul ferestrei, în sensul acelor de ceasornic față de nord (0 = N, 90 = E, 180 = S, 270 = V). Se folosește împreună cu direcția vântului.",
"opening_factor": "Aerisirea relativă când e deschisă: 1,0 pentru o fereastră obișnuită, mai puțin pentru rabatată, mai mult pentru una mare sau dublă."
}
},
"reconfigure": {
"title": "Fereastră",
"description": "Un senzor de contact pentru fereastră. Perioadele cu fereastra deschisă nu intră în învățarea pierderilor normale de căldură ale zonei și sunt modelate separat, ca aerisire. Stările necunoscute sau indisponibile sunt tratate ca necunoscute, niciodată ca închise.",
"data": {
"name": "Nume",
"sensor": "Senzor de contact",
"zone": "Zonă",
"inverted": "Senzorul raportează pornit când e închis",
"azimuth": "Orientare (°)",
"opening_factor": "Factor de deschidere",
"enabled": "Activă"
},
"data_description": {
"inverted": "Activează pentru senzorii cu polaritate inversă față de un senzor de fereastră obișnuit.",
"azimuth": "Direcția spre care privește exteriorul ferestrei, în sensul acelor de ceasornic față de nord (0 = N, 90 = E, 180 = S, 270 = V). Se folosește împreună cu direcția vântului.",
"opening_factor": "Aerisirea relativă când e deschisă: 1,0 pentru o fereastră obișnuită, mai puțin pentru rabatată, mai mult pentru una mare sau dublă."
}
}
},
"error": {
"window_name_required": "Introdu un nume.",
"sensor_in_other_window": "Acest senzor este folosit deja de altă fereastră.",
"too_many_windows": "Sunt permise cel mult 24 de ferestre."
},
"abort": {
"reconfigure_successful": "Fereastra a fost actualizată.",
"no_zones": "Adaugă mai întâi o zonă."
}
} }
}, },
"selector": { "selector": {
@@ -14,6 +14,8 @@ LIMITS = {
"temperature": (-45.0, 55.0), "temperature": (-45.0, 55.0),
"feels_like": (-65.0, 65.0), "feels_like": (-65.0, 65.0),
"wind": (0.0, 90.0), # m/s "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 "clouds": (0.0, 100.0), # percentage
"humidity": (0.0, 100.0), # percentage "humidity": (0.0, 100.0), # percentage
"pressure": (800.0, 1100.0), # hPa "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 value = (value - 32.0) * 5.0 / 9.0
elif unit not in ("", "°c", "c", "celsius"): elif unit not in ("", "°c", "c", "celsius"):
return None 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"): if unit in ("km/h", "kmh", "kph"):
value /= 3.6 value /= 3.6
elif unit in ("mph",): elif unit in ("mph",):
value *= 0.44704 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"): elif unit not in ("", "m/s", "mps"):
return None return None
elif metric == "pressure": elif metric == "pressure":
+358
View File
@@ -0,0 +1,358 @@
"""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 _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."""
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]] = {}
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]] = {}
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)
zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True,
"open_now": False, "open_windows": [], "last_close": None,
"first_open": None})
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:
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 is_open is False and 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,
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
if weather.get("wind_direction") is not None else None)}
for key, zone in zones.items():
self._update_zone(now, key, zone, temps.get(key), weather, zones)
self.zone_state = zones
self._last_temps = dict(temps)
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
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)),
"temperature_before": self._t_ref[key],
"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,
}
elif zone["open_now"] and temp is not None:
event = self._open_event[key]
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
elif was_open and not zone["open_now"]:
event = self._open_event.pop(key)
closed = zone["last_close"] or now
self._closed_at[key] = closed
event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1),
temperature_at_close=temp)
self.events.append(event)
elif not zone["open_now"] 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 zone["open_now"] else 0.0
if recovery < RECOVERY_THRESHOLD:
recovery = 0.0
zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
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 zone["open_now"] 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."""
zone = self.zone_state.get(key)
if zone is None:
return None
return {"opening": zone["opening"] if zone["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": zone["known"]}
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.
Windows open now are assumed to close after `open_minutes` (at least one
step); after that 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
and self.window_state.get(w.key, {}).get("open")]
opening = sum(w.opening for w in windows)
facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \
if weather.get("wind_direction") is not None else 0.0
open_steps = max(1, round(open_minutes / 5)) if windows else 0
closed_at = self._closed_at.get(key)
result = []
for k in range(steps):
t = now + (k + 1) * 300.0
if k < open_steps:
result.append({"opening": opening, "facing": facing, "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
start = now + open_steps * 300.0 if windows else closed_at
recovery = exp(-(t - start) / self.tau) if start is not None 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]:
return {"events": list(self.events)}
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
+29 -3
View File
@@ -18,6 +18,7 @@ from typing import Any, Mapping
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
from .learning import FeatureLearner from .learning import FeatureLearner
from .physics import Learner from .physics import Learner
from .windows import ventilation_vector
BASE_FEATURES = ["loss", "heat", "bias"] BASE_FEATURES = ["loss", "heat", "bias"]
@@ -61,25 +62,50 @@ def feature_vector(features: list[str], temp: float, inputs: Mapping[str, Any])
class ZoneModel: class ZoneModel:
def __init__(self, base: Learner | None = None, extended: FeatureLearner | None = None) -> None: def __init__(self, base: Learner | None = None, extended: FeatureLearner | None = None,
ventilation: FeatureLearner | None = None) -> None:
self.base = base or Learner() self.base = base or Learner()
self.extended = extended self.extended = extended
self.ventilation = ventilation
@property @property
def uses_extended(self) -> bool: def uses_extended(self) -> bool:
"""Forecast with the extended model once it is as trained as the base needs to be.""" """Forecast with the extended model once it is as trained as the base needs to be."""
return self.extended is not None and ready(self.extended) return self.extended is not None and ready(self.extended)
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float: def main_delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
"""Predicted 5-minute change. Unknown extended inputs fall back to the base model.""" """Closed-window change. Unknown extended inputs fall back to the base model."""
if self.uses_extended: if self.uses_extended:
x = feature_vector(self.extended.features, temp, inputs) x = feature_vector(self.extended.features, temp, inputs)
if x is not None: if x is not None:
return self.extended.predict(x) return self.extended.predict(x)
return self.base.predict_delta(temp, inputs["outside"], inputs["lag"]) return self.base.predict_delta(temp, inputs["outside"], inputs["lag"])
def ventilation_delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
"""Extra change from open windows and recovery; 0 without windows or inputs."""
ventilation = inputs.get("ventilation")
if self.ventilation is None or not ventilation:
return 0.0
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
return self.ventilation.predict(x) if x is not None else 0.0
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
"""Predicted 5-minute change of the zone."""
return self.main_delta(temp, inputs) + self.ventilation_delta(temp, inputs)
def contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]: def contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]:
"""Each term's share of the predicted change, in °C per hour.""" """Each term's share of the predicted change, in °C per hour."""
terms = self._main_contributions(temp, inputs)
ventilation = inputs.get("ventilation")
if self.ventilation is not None and ventilation:
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
if x is not None:
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
group = "recovery" if name == "recovery" else "ventilation"
terms[group] = round(terms.get(group, 0.0) + 12 * theta * value, 3)
return terms
def _main_contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]:
if self.uses_extended: if self.uses_extended:
x = feature_vector(self.extended.features, temp, inputs) x = feature_vector(self.extended.features, temp, inputs)
if x is not None: if x is not None:
+42
View File
@@ -209,3 +209,45 @@ async def test_heat_source_subentry_flow(hass, entry, inputs):
sources = status.attributes["heat_sources"] sources = status.attributes["heat_sources"]
assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice
assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400) assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400)
async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs):
await setup_entry(hass, entry)
hass.states.async_set("binary_sensor.bedroom_window", "off", {"device_class": "window"})
flows = hass.config_entries.subentries
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
form = result["data_schema"]({"name": "Bedroom window", "sensor": "binary_sensor.bedroom_window",
"zone": "sensor.room"})
assert (form["azimuth"], form["opening_factor"], form["inverted"]) == (345.0, 1.0, False)
result = await flows.async_configure(result["flow_id"], {**form, "azimuth": 340})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert [(w.name, w.azimuth) for w in coordinator.windows] == [("Bedroom window", 340.0)]
# No wind sources yet: only the terms whose inputs exist.
assert coordinator.ventilation["sensor.room"].features == ["vent_natural", "recovery"]
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
result = await flows.async_configure(result["flow_id"], {**form, "name": "Again"})
assert result["errors"] == {"base": "sensor_in_other_window"}
flows.async_abort(result["flow_id"])
# Wind, direction and gust sources plus the gust option add their terms.
for sensor, value, unit in (("sensor.wind", "4", "m/s"), ("sensor.wind_dir", "204", "°"),
("sensor.gust", "9", "m/s")):
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
options = hass.config_entries.options
result = await options.async_init(entry.entry_id)
result = await options.async_configure(result["flow_id"], result["data_schema"](
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({
"outside_temperature_sources": ["sensor.outside"], "outside_wind_sources": ["sensor.wind"],
"outside_wind_direction_sources": ["sensor.wind_dir"], "outside_wind_gust_sources": ["sensor.gust"]}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({}))
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
# Cross-ventilation needs windows in two zones, so it stays off.
assert coordinator.ventilation["sensor.room"].features == [
"vent_natural", "recovery", "vent_wind", "vent_wind_dir", "vent_gust"]
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
window = status.attributes["windows"]["Bedroom window"]
assert (window["state"], window["relative_wind_direction"]) == ("closed", -136.0)
assert {"windows", "ventilation"} <= status.state_info["unrecorded_attributes"]
+141 -1
View File
@@ -5,7 +5,7 @@ import types
import unittest import unittest
import unittest.mock import unittest.mock
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import exp, pi, sin from math import cos, exp, pi, radians, sin
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
@@ -823,3 +823,143 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy: with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
await self.sample() await self.sample()
self.assertIsNone(spy.call_args.kwargs['step_model']) self.assertIsNone(spy.call_args.kwargs['step_model'])
def bedroom_with_window(self, with_window=True):
self.entry.data.update(outside_wind_sources=['sensor.wind'],
outside_wind_direction_sources=['sensor.wind_dir'])
self.entry.subentries = {
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.room']})}
if with_window:
self.entry.subentries['w'] = types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
'key': 'bed_window', 'name': 'Bedroom window', 'sensor': 'binary_sensor.bed_window',
'zone': 'bed', 'azimuth': 345})
self.set_state('sensor.wind', '4', unit_of_measurement='m/s')
self.set_state('sensor.wind_dir', '204', unit_of_measurement='°')
self.set_state('binary_sensor.bed_window', 'off')
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
def open_window(self, state, seconds_before_next_sample):
window = self.states['binary_sensor.bed_window']
window.state = state
window.last_changed = self.now - timedelta(seconds=seconds_before_next_sample)
async def test_open_window_goes_to_ventilation_model_not_envelope_loss(self):
self.c = self.bedroom_with_window()
self.assertEqual(self.c.ventilation['bed'].features,
['vent_natural', 'recovery', 'vent_wind', 'vent_wind_dir'])
for _ in range(2):
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
await self.sample()
self.open_window('on', 240) # opened a minute after the last poll
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
base = self.c.learners['bed'].count
data = await self.sample()
self.assertEqual(data['sample_result'], 'ventilation')
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
window = data['windows']['Bedroom window']
self.assertEqual((window['state'], window['open_minutes'], window['relative_wind_direction']),
('open', 4.0, -141.0))
self.assertEqual(window['wind_exposure'], round(cos(radians(141)), 3))
ventilation = data['ventilation']['Bedroom']
self.assertEqual((ventilation['opening'], ventilation['affected']), (0.8, True))
# Unknown contact state: neither model learns, and it never counts as closed.
self.open_window('unavailable', 120)
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
data = await self.sample()
self.assertEqual(data['windows']['Bedroom window']['state'], 'unknown')
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
async def test_ventilation_week_keeps_envelope_loss_clean(self):
"""Same synthetic week through a zone with and without its window configured."""
with_window = self.bedroom_with_window(True)
without = self.bedroom_with_window(False)
a, b, c = 0.004, 0.12, 0.0005
k_nat, k_wind, k_dir, k_rec = 0.01, 0.001, 0.0006, 0.15
advance = coordinator.advance_lag
self.states['climate.reference'].state = 'off'
temp, lag, on, outside = 21.0, 0.0, False, 2.0
window_open, closed_at, t_ref, prev_temp = False, None, None, temp
for k in range(2400):
speed, direction = 2 + 3 * abs(sin(k / 50)), (k * 7) % 360
outside = 2 + 4 * sin(2 * pi * k / 288) # daily swing keeps loss and bias apart
for entity, value in (('sensor.room', temp), ('sensor.outside', outside),
('sensor.wind', speed), ('sensor.wind_dir', direction)):
self.states[entity].state = f'{value:.5f}'
self.states[entity].last_reported = self.now
with patch.object(coordinator, 'datetime') as clock:
clock.now.return_value = self.now
for c_ in (with_window, without):
await c_._async_update_data()
poll = self.now
self.now += timedelta(seconds=300)
# Thermostat: switch 120 s after the poll when needed.
command = temp < 20.7 or (on and temp < 21.3)
if command != on:
self.switch('on' if command else 'off', 180)
lag = advance(advance(lag, on, 120), command, 180)
else:
lag = advance(lag, on, 300)
on = command
# Ventilation: 15-20 minutes every four hours, changes 60 s after a poll.
phase = k % 48
fraction, recovery = (1.0 if window_open else 0.0), 0.0
if phase == 0 and not window_open:
t_ref, window_open, fraction = prev_temp, True, 0.8
self.open_window('on', 240)
elif phase == 4 and window_open:
window_open, fraction, closed_at = False, 0.2, poll.timestamp() + 60
self.open_window('off', 240)
if closed_at is not None and not window_open:
recovery = exp(-(self.now.timestamp() - closed_at) / 1200)
recovery = recovery if recovery >= 0.05 else 0.0
facing = fraction * cos(radians((direction - 345 + 180) % 360 - 180))
difference = outside - temp
prev_temp = temp
temp += (a * difference + b * lag + c
+ (k_nat * fraction + k_wind * fraction * speed) * difference
+ k_dir * facing * speed * difference
+ k_rec * recovery * ((t_ref if t_ref is not None else temp) - temp))
vent = with_window.ventilation['bed']
# Natural and wind terms both scale with opening x (T_out - T); with wind only
# 2-5 m/s their split is uncertain, but their combined effect is identified.
mean_speed = 3.5
learned = vent.coefficient('vent_natural') + vent.coefficient('vent_wind') * mean_speed
self.assertAlmostEqual(learned, k_nat + k_wind * mean_speed, delta=0.15 * (k_nat + k_wind * mean_speed))
self.assertAlmostEqual(vent.coefficient('recovery'), k_rec, delta=0.15 * k_rec)
self.assertAlmostEqual(vent.coefficient('vent_wind_dir'), k_dir, delta=0.3 * k_dir)
# With the window configured the envelope model stays close to the truth...
clean = with_window.learners['bed'].theta
self.assertAlmostEqual(clean[0], a, delta=0.1 * a)
self.assertAlmostEqual(clean[1], b, delta=0.05 * b)
self.assertLess(abs(clean[2] - c), 0.003)
# ...without it, ventilation shows up as a constant cooling bias and an inflated boiler effect.
mixed = without.learners['bed'].theta
self.assertLess(mixed[2], -0.008)
self.assertGreater(mixed[1], 1.05 * b)
async def test_forecast_assumes_window_closes_then_room_recovers(self):
self.c = self.bedroom_with_window()
self.states['climate.reference'].state = 'off'
for _ in range(2):
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
await self.sample()
self.open_window('on', 240)
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
await self.sample()
model = self.c.learners['bed']
model.count, model.on_count, model.off_count = 200, 100, 100
vent = self.c.ventilation['bed']
vent.theta[vent.features.index('vent_natural')] = 0.01
vent.theta[vent.features.index('recovery')] = 0.15
self.states['climate.reference'].state = 'heat'
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
data = await self.sample()
self.assertEqual(data['status'], 'ready')
step = spy.call_args.kwargs['step_model']
base = model.predict_delta(19.0, 5.0, 0.0)
open_change = step({'bed': 19.0}, 0.0, 0)['bed'] - 19.0
recovering = step({'bed': 19.0}, 0.0, 4)['bed'] - 19.0 # 15 min default: closed by step 3
self.assertLess(open_change, base - 0.1) # 0.01 x (5 - 19) = -0.14 °C/5 min
self.assertGreater(recovering, base) # air recovers towards 20 °C
+143
View File
@@ -0,0 +1,143 @@
"""Windows, wind exposure, ventilation inputs and recovery, without Home Assistant."""
import sys
import types
import unittest
from math import cos, exp, radians
from pathlib import Path
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
from heating_mpc import windows as wm # noqa: E402
WEATHER = {'temperature': 2.0, 'wind': 4.0, 'wind_direction': 204.0, 'wind_gust': 9.0}
def window(key, zone='bed', **extra):
return {'key': key, 'name': key.title(), 'sensor': f'binary_sensor.{key}', 'zone': zone, **extra}
class GeometryTests(unittest.TestCase):
def test_wind_relative_to_facade(self):
# The apartment's façade faces about 345° (NNW); OpenWeatherMap wind from 204°.
self.assertAlmostEqual(wm.angle_difference(204, 345), -141)
self.assertAlmostEqual(wm.facing_cos(204, 345), cos(radians(141)))
self.assertAlmostEqual(wm.facing_cos(345, 345), 1) # blowing straight at the window
self.assertAlmostEqual(wm.facing_cos(165, 345), -1) # from behind the building
self.assertAlmostEqual(wm.facing_cos(75, 345), 0, places=9) # crosswind
# Around north, differences wrap instead of jumping by 360°.
self.assertAlmostEqual(wm.angle_difference(5, 355), 10)
self.assertAlmostEqual(wm.angle_difference(355, 5), -10)
self.assertAlmostEqual(wm.facing_cos(5, 355), cos(radians(10)))
def test_circular_mean(self):
self.assertAlmostEqual(wm.circular_mean([350, 10]) % 360 if wm.circular_mean([350, 10]) < 359 else 0, 0)
self.assertAlmostEqual(wm.circular_mean([350, 20], [1, 1]), 5)
self.assertIsNone(wm.circular_mean([90, 270]))
self.assertAlmostEqual(wm.circular_mean([0, 90], [3, 1]), 18.4349488, places=5)
def test_contact_states_and_open_fraction(self):
self.assertEqual([wm.contact_open(s) for s in ('on', 'off', 'unknown', 'unavailable', None)],
[True, False, None, None, None])
self.assertEqual([wm.contact_open(s, inverted=True) for s in ('on', 'off')], [False, True])
self.assertEqual(wm.open_fraction(True, True, 0, 100, 400), 1.0)
self.assertEqual(wm.open_fraction(False, False, 0, 100, 400), 0.0)
self.assertAlmostEqual(wm.open_fraction(False, True, 220, 100, 400), 0.6) # opened 120 s in
self.assertAlmostEqual(wm.open_fraction(True, False, 220, 100, 400), 0.4) # closed 120 s in
self.assertIsNone(wm.open_fraction(False, False, 220, 100, 400)) # round trip: unknown
self.assertIsNone(wm.open_fraction(None, True, 0, 100, 400))
def test_ventilation_vector(self):
features = list(wm.VENTILATION_FEATURES)
inputs = {'opening': 0.5, 'facing': -0.3, 'other_opening': 1.0, 'recovery': 0.0, 't_ref': None,
'wind': 4.0, 'gust': 9.0}
for value, expected in zip(wm.ventilation_vector(features, 20.0, 2.0, inputs),
[-9.0, -36.0, 21.6, -45.0, -9.0, 0.0]):
self.assertAlmostEqual(value, expected)
self.assertIsNone(wm.ventilation_vector(['vent_wind'], 20, 2, {**inputs, 'wind': None}))
self.assertEqual(wm.ventilation_vector(['vent_natural'], 20, 2, {**inputs, 'wind': None}), [-9.0])
self.assertIsNone(wm.ventilation_vector(['recovery'], 20, 2, {**inputs, 'recovery': 0.5}))
self.assertEqual(wm.ventilation_vector(['recovery'], 19, 2, {**inputs, 'recovery': 0.5, 't_ref': 21}),
[1.0])
def test_window_config_validation(self):
built = wm.build_windows([
(window('a', azimuth=705), None), (window('b', opening_factor=9), None),
(window('c', zone='gone'), None), (window('a'), None),
({**window('d'), 'sensor': 'sensor.d'}, None), (window('e', inverted=True), 's5')], ['bed'])
self.assertEqual([(w.key, w.azimuth, w.inverted) for w in built], [('a', 345.0, False), ('e', 345.0, True)])
self.assertEqual(wm.validate_window({'name': ' '}, built), 'window_name_required')
self.assertEqual(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.a'}, built),
'sensor_in_other_window')
self.assertIsNone(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.x'}, built))
class TrackerTests(unittest.TestCase):
def setUp(self):
self.tracker = wm.VentilationTracker(wm.build_windows([(window('bed_window'), None)], ['bed']),
recovery_minutes=20)
self.t = 1_000_000.0
self.changed = 0.0
def poll(self, state, temp, changed_offset=None, weather=WEATHER):
if changed_offset is not None:
self.changed = self.t - changed_offset
self.tracker.update(self.t, {'bed_window': (state, self.changed)}, {'bed': temp}, weather)
inputs = self.tracker.zone_inputs('bed', weather)
self.t += 300
return inputs
def test_ventilation_event_and_recovery(self):
self.assertFalse(self.poll('off', 21.0)['known']) # no previous poll yet
closed = self.poll('off', 21.0)
self.assertEqual((closed['opening'], closed['affected']), (0.0, False))
opened = self.poll('on', 20.6, changed_offset=180) # opened 3 minutes ago
self.assertAlmostEqual(opened['opening'], 0.6)
self.assertAlmostEqual(opened['facing'], 0.6 * cos(radians(141)))
self.assertTrue(opened['affected'])
self.assertEqual(opened['t_ref'], 21.0) # temperature at the poll before opening
for temp in (20.2, 19.9):
self.assertEqual(self.poll('on', temp)['opening'], 1.0)
closing = self.poll('off', 20.0, changed_offset=60) # closed a minute ago
self.assertAlmostEqual(closing['opening'], 0.8)
self.assertAlmostEqual(closing['recovery'], exp(-60 / 1200))
after = [self.poll('off', 20.0 + 0.1 * k) for k in range(12)]
self.assertTrue(after[0]['affected'] and after[0]['recovery'] > 0.7)
self.assertEqual((after[-1]['recovery'], after[-1]['affected']), (0.0, False)) # ~3 time constants
event = self.tracker.events[-1]
self.assertEqual((event['temperature_before'], event['lowest_temperature'], event['temperature_at_close']),
(21.0, 19.9, 20.0))
self.assertEqual((event['duration_minutes'], event['wind_direction'], event['windows']), (17.0, 204.0, ['Bed_Window'])) # 3 min + 15 min - 1 min
self.assertEqual(event['temperature_after_minutes'], 30)
restored = wm.VentilationTracker(self.tracker.windows, saved=self.tracker.as_dict())
self.assertEqual(list(restored.events), list(self.tracker.events))
def test_unknown_state_blocks_learning_and_multiple_windows_add(self):
tracker = wm.VentilationTracker(wm.build_windows(
[(window('w1'), None), (window('w2', opening_factor=0.5), None), (window('w3', zone='living'), None)],
['bed', 'living']))
for t in (0.0, 300.0):
tracker.update(t, {'w1': ('on', -10), 'w2': ('on', -10), 'w3': ('on', -10)}, {'bed': 20, 'living': 21},
WEATHER)
bed = tracker.zone_inputs('bed', WEATHER)
self.assertEqual((bed['opening'], bed['other_opening']), (1.5, 1.0)) # living's window is open too
tracker.update(600.0, {'w1': ('unavailable', 550), 'w2': ('on', -10), 'w3': ('off', 400)},
{'bed': 20, 'living': 21}, WEATHER)
bed = tracker.zone_inputs('bed', WEATHER)
self.assertEqual((bed['opening'], bed['known'], bed['affected']), (None, False, True))
self.assertIsNone(tracker.zone_inputs('kitchen', WEATHER))
def test_forecast_assumes_closing_then_recovery(self):
self.poll('off', 21.0)
self.poll('off', 21.0)
self.poll('on', 20.5, changed_offset=100)
steps = self.tracker.forecast('bed', 12, open_minutes=15, weather=WEATHER, now=self.t)
self.assertEqual([s['opening'] for s in steps[:4]], [1.0, 1.0, 1.0, 0.0])
self.assertAlmostEqual(steps[0]['facing'], cos(radians(141)))
self.assertAlmostEqual(steps[3]['recovery'], exp(-300 / 1200))
self.assertGreater(steps[3]['recovery'], steps[6]['recovery'])
self.assertEqual(steps[5]['t_ref'], 21.0)
if __name__ == '__main__':
unittest.main()