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>
120 lines
5.4 KiB
Python
120 lines
5.4 KiB
Python
"""Per-zone thermal model: base model plus optional extended features.
|
|
|
|
Pure Python; no Home Assistant imports. Per 5-minute step, for zone i:
|
|
|
|
dT_i = loss x (T_out - T_i) + heat x lag + bias (base, always learned)
|
|
+ internal x q_i (electrical heat above baseline, kW)
|
|
+ sum_j couple:j x (T_j - T_i) (adjacent zones)
|
|
|
|
The base model is the 3-parameter model of earlier versions, kept as fallback
|
|
and as the comparison that shows whether the extra features earn their keep.
|
|
The extended model learns the same terms plus the configured extras, only from
|
|
samples where all of its inputs are known.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Mapping
|
|
|
|
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
|
|
from .learning import FeatureLearner
|
|
from .physics import Learner
|
|
from .windows import ventilation_vector
|
|
|
|
BASE_FEATURES = ["loss", "heat", "bias"]
|
|
|
|
|
|
def ready(model: Learner | FeatureLearner) -> bool:
|
|
return (model.count >= READY_SAMPLES and model.on_count >= READY_ON_SAMPLES
|
|
and model.off_count >= READY_OFF_SAMPLES)
|
|
|
|
|
|
def extended_features(has_internal: bool, neighbors: tuple[str, ...]) -> list[str]:
|
|
"""Features of a zone's extended model; empty when it would equal the base."""
|
|
extra = (["internal"] if has_internal else []) + [f"couple:{n}" for n in neighbors]
|
|
return BASE_FEATURES + extra if extra else []
|
|
|
|
|
|
def feature_vector(features: list[str], temp: float, inputs: Mapping[str, Any]) -> list[float] | None:
|
|
"""Values of `features` at the start of a step; None if an input is unknown.
|
|
|
|
`inputs`: outside, lag, internal (kW, may be None) and temps (all zones).
|
|
"""
|
|
values = []
|
|
for name in features:
|
|
if name == "loss":
|
|
values.append(inputs["outside"] - temp)
|
|
elif name == "heat":
|
|
values.append(inputs["lag"])
|
|
elif name == "bias":
|
|
values.append(1.0)
|
|
elif name == "internal":
|
|
if inputs.get("internal") is None:
|
|
return None
|
|
values.append(inputs["internal"])
|
|
elif name.startswith("couple:"):
|
|
neighbor = inputs.get("temps", {}).get(name.split(":", 1)[1])
|
|
if neighbor is None:
|
|
return None
|
|
values.append(neighbor - temp)
|
|
else:
|
|
raise KeyError(name)
|
|
return values
|
|
|
|
|
|
class ZoneModel:
|
|
def __init__(self, base: Learner | None = None, extended: FeatureLearner | None = None,
|
|
ventilation: FeatureLearner | None = None) -> None:
|
|
self.base = base or Learner()
|
|
self.extended = extended
|
|
self.ventilation = ventilation
|
|
|
|
@property
|
|
def uses_extended(self) -> bool:
|
|
"""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)
|
|
|
|
def main_delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
|
"""Closed-window change. Unknown extended inputs fall back to the base model."""
|
|
if self.uses_extended:
|
|
x = feature_vector(self.extended.features, temp, inputs)
|
|
if x is not None:
|
|
return self.extended.predict(x)
|
|
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]:
|
|
"""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:
|
|
x = feature_vector(self.extended.features, temp, inputs)
|
|
if x is not None:
|
|
terms: dict[str, float] = {}
|
|
for name, theta, value in zip(self.extended.features, self.extended.theta, x):
|
|
group = "neighbors" if name.startswith("couple:") else name
|
|
terms[group] = terms.get(group, 0.0) + 12 * theta * value
|
|
return {name: round(value, 3) for name, value in terms.items()}
|
|
a, b, c = self.base.theta
|
|
return {"loss": round(12 * a * (inputs["outside"] - temp), 3),
|
|
"heat": round(12 * b * inputs["lag"], 3), "bias": round(12 * c, 3)}
|