Confirmed: when an open window's contact sensor became unknown or unavailable, the tracker ended the ventilation event as a close at the poll time, started recovery at full weight and forecasts assumed the window closed. (Learning was already excluded for that zone.) Each window is now OPEN, CLOSED or UNKNOWN with its last known state, since when, and since when it is unknown: - an unknown sensor keeps an open window's event (and the zone's "open" status) going; forecasts use the last known state; - a close observed only after an outage is recorded as unconfirmed: closed=None with the reported time and a maximum duration; recovery is assumed from that report but not used for any learning; - opening seen only after an outage starts an event without a temperature-before, so its recovery is not learned either; - events count minutes with an unknown sensor; Status shows each window's last known state and unknown minutes; - one unknown sensor only blocks learning for its own zone. Predictions with an unavailable wind sensor now keep an open window's natural ventilation (calm air) instead of dropping the term; learning still refuses unknown inputs. Regression tests for every OPEN/CLOSED/UNKNOWN transition, a six-hour outage, multiple windows with one unknown, and missing wind during ventilation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
7.5 KiB
Python
161 lines
7.5 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, Callable, 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_for_prediction
|
|
|
|
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_for_prediction(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_for_prediction(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)}
|
|
|
|
|
|
def _probe(model: ZoneModel, key: str, inputs: Mapping[str, Any], state: Mapping[str, float], lag: float) -> float:
|
|
return model.delta(state[key], {**inputs, "temps": state, "lag": lag})
|
|
|
|
|
|
def compile_step_model(models: Mapping[str, ZoneModel], keys: list[str], steps: int,
|
|
inputs_at: Callable[[str, int], Mapping[str, Any]]):
|
|
"""A fast, exact multi-zone step for the optimizer's plan search.
|
|
|
|
Every term of ZoneModel.delta is affine in the zone temperatures and the
|
|
radiator lag (loss, neighbours, ventilation and recovery all scale with
|
|
temperature differences), and which layer applies depends only on which
|
|
inputs exist, not on their values. So per zone and step the change is
|
|
a + sum_j b_j T_j + h lag, with the coefficients read off the real model at
|
|
a few probe points. The search then does a handful of multiply-adds per step
|
|
instead of rebuilding feature vectors for every candidate plan.
|
|
"""
|
|
zero = {key: 0.0 for key in keys}
|
|
terms: dict[str, list[tuple[float, list[tuple[str, float]], float]]] = {}
|
|
for key in keys:
|
|
model = models[key]
|
|
related = [key] + ([name.split(":", 1)[1] for name in model.extended.features
|
|
if name.startswith("couple:") and name.split(":", 1)[1] in zero]
|
|
if model.extended is not None else [])
|
|
per_step = []
|
|
for k in range(steps):
|
|
inputs = dict(inputs_at(key, k))
|
|
a = _probe(model, key, inputs, zero, 0.0)
|
|
per_step.append((a, [(j, _probe(model, key, inputs, {**zero, j: 1.0}, 0.0) - a) for j in related],
|
|
_probe(model, key, inputs, zero, 1.0) - a))
|
|
terms[key] = per_step
|
|
|
|
def step(state: dict[str, float], lag: float, k: int) -> dict[str, float]:
|
|
k = min(k, steps - 1)
|
|
new = {}
|
|
for key, value in state.items():
|
|
a, coefficients, h = terms[key][k]
|
|
new[key] = value + a + h * lag + sum(b * state[j] for j, b in coefficients)
|
|
return new
|
|
return step
|