Files
heating-mpc-ha/custom_components/heating_mpc/zone_model.py
T
buzeraandClaude Opus 5.5 ed8f9fe2e2 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>
2026-10-27 02:47:40 +02:00

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)}