Files
heating-mpc-ha/custom_components/heating_mpc/zone_model.py
T
buzeraandClaude Opus 5.5 b1577a0c78 Score the full model against the base model; compile the search step
Validation: the walk-forward evaluator now replays all zones together
with the full model (electrical heat, neighbour exchange, ventilation
and recovery) using the parameters frozen at the forecast start and the
electrical heat and window openings that actually happened, and scores
the base 3-parameter model on the same spans. Forecast Error sensors
add base_model_mae and improvement_vs_base (share of the base model's
error the new features remove; 0 = no gain, negative = worse). Saved
0.3 statistics load with their own error as the base column.

On synthetic apartments, 60-minute error full vs base: gaming office
0.031 vs 0.182 °C (83 % better), bedroom with ventilation 0.026 vs
0.218 °C (88 %), two coupled rooms 0.024 vs 0.037 °C (34 %; gone by
120 min, where the constant-outdoor assumption dominates). The same
ventilation week without window sensors shows no improvement, as it
should. These check the method, not real-world accuracy.

Performance: every term of a zone's 5-minute change is affine in the
zone temperatures and the radiator lag, and which layer applies depends
only on which inputs exist. compile_step_model reads the coefficients
off the real model per zone and step, so the plan search does a few
multiply-adds per step. It matches the direct model to 1e-12 and makes
a 180-minute, 5-zone search 4.5x faster (0.73 s here, close to the old
base-only search).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:55:57 +02:00

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