Files
heating-mpc-ha/custom_components/heating_mpc/learning.py
T
buzeraandClaude Opus 5.5 099c63cd02 Recalibrate only the model parts whose configuration changed
Confirmed: learned models were matched only by zone key, so replacing a
zone's sensors, changing sensor weights, moving or replacing a heat
source or turning a window kept coefficients learned for a different
configuration, silently.

compatibility.py fingerprints exactly what each learned component
depends on: the zone measurement (sensors and relative weights; equal
scaling changes nothing), each zone's heat sources, the neighbour's
measurement, the zone's windows, window orientation (direction term
only), the recovery time (recovery only) and other zones' openings
(cross term only). Comfort weight, target offset and names are not
included. Heat-source baselines have their own fingerprint (sensor and
included sources).

On restore, a changed measurement recalibrates the zone: base and
extended loss/heat/bias keep their values as a warm start, but their
uncertainty, counts and readiness start over, other terms return to the
prior, and the zone's forecast statistics are dropped. Otherwise only
the changed terms are reset; everything else, including other zones,
is kept. Each recalibration is logged (Status model_changes, info log)
and saved at once. Data from before fingerprints (0.4.0) is assumed to
match its current configuration and is fingerprinted at the first
start, so the next edit is detected even before a learned sample.

Tests cover comfort changes, sensor replacement and weights, window
orientation and size, moved and replaced heat sources, re-added zones,
0.4.0 data and a second restart, plus an HA Core test through real
subentry updates.

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

322 lines
15 KiB
Python

"""Recursive least squares over named, physically bounded features.
Pure Python; no Home Assistant imports. Every coefficient is a change in room
temperature per 5-minute step per unit of its feature, so all of them share the
same target: the measured 5-minute temperature change (or, for the ventilation
layer, the part of it the closed-window model cannot explain).
New effects start at a prior of 0 ("no effect until the data shows one"). In
this unscaled RLS form a prior variance is worth 1 / (variance x typical value^2)
samples of data, so each prior below is chosen as "worth N typical samples": a
handful of samples cannot produce a confident coefficient. Confidence is the share
of a coefficient's information that comes from data rather than the prior; the
displayed standard deviation uses a running estimate of the measurement noise.
"""
from __future__ import annotations
from math import isfinite, sqrt
from typing import Any, Iterable, NamedTuple
FORGETTING = 0.999 # about 3.5 days of 5-minute samples
MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics
# A coefficient counts as identified after this many exercising samples and once
# at least 80 % of its information comes from data.
MIN_ACTIVE_SAMPLES = 30
IDENTIFIED_CONFIDENCE = 0.8
INITIAL_NOISE_STD = 0.02 # °C per 5 minutes, about one sensor resolution step
class FeatureSpec(NamedTuple):
prior: float
# Unscaled RLS prior variance: worth 1 / (variance x typical^2) typical samples.
variance: float
lo: float
hi: float
# |feature value| at or above which a sample exercises this coefficient.
active: float
unit: str
def _worth(samples: float, typical: float) -> float:
return 1.0 / (samples * typical * typical)
# x = feature value for one zone at the start of a 5-minute step. The first three
# match the base model's priors.
FEATURES: dict[str, FeatureSpec] = {
# Envelope loss: x = T_out - T (°C).
"loss": FeatureSpec(0.002, 0.0005, 0.0, 0.04, 1.0, "°C/5 min per °C outdoor difference"),
# Boiler: x = radiator-lag proxy (0..1).
"heat": FeatureSpec(0.045, 0.8, 0.0, 0.25, 0.1, "°C/5 min at full radiator heat"),
# Constant gains/losses: x = 1.
"bias": FeatureSpec(0.0, 0.08, -0.06, 0.06, 0.5, "°C/5 min"),
# Electrical heat above baseline after its thermal response: x in kW (typ. 0.4).
"internal": FeatureSpec(0.0, _worth(20, 0.4), 0.0, 0.5, 0.05, "°C/5 min per kW"),
# Exchange with an adjacent zone j: x = T_j - T (°C). Shrunk hard towards 0:
# zones heated by one boiler move together, so this is weakly identifiable.
"couple": FeatureSpec(0.0, _worth(200, 1.0), 0.0, 0.05, 0.3, "°C/5 min per °C difference"),
# Ventilation, fitted to what the closed-window model cannot explain. x scales
# with the open-window factor O and T_out - T (typ. 15 °C):
"vent_natural": FeatureSpec(0.0, _worth(20, 15), 0.0, 0.2, 0.5, "°C/5 min per °C x opening"),
# Wind speed v in m/s (typ. 4 m/s).
"vent_wind": FeatureSpec(0.0, _worth(20, 60), 0.0, 0.03, 0.5, "°C/5 min per °C x opening x m/s"),
# Directional part, bounded by vent_wind so a leeward window still ventilates.
"vent_wind_dir": FeatureSpec(0.0, _worth(20, 60), -0.03, 0.03, 0.5,
"°C/5 min per °C x opening x m/s x cos(angle)"),
"vent_gust": FeatureSpec(0.0, _worth(20, 45), 0.0, 0.03, 0.5,
"°C/5 min per °C x opening x m/s of gust above mean wind"),
"vent_cross": FeatureSpec(0.0, _worth(100, 15), 0.0, 0.1, 0.5,
"°C/5 min per °C x opening x openings elsewhere"),
# After closing: x = recovery weight (1 -> 0) x (T before opening - T), typ. 0.5.
"recovery": FeatureSpec(0.0, _worth(20, 0.5), 0.0, 0.5, 0.05,
"°C/5 min per °C below the pre-ventilation temperature"),
}
def spec_for(name: str) -> FeatureSpec:
"""Specification of a feature; "couple:<zone>" shares the "couple" spec."""
return FEATURES[name.split(":", 1)[0]]
def bound_covariance(p: list[list[float]], prior_variance: list[float]) -> None:
"""Keep P symmetric and no less certain than the prior, in place.
With forgetting, unexcited directions grow by 1/forgetting each step without
limit. Scaling row and column i together is a congruence transform, so positive
definiteness is preserved.
"""
n = len(p)
for i in range(n):
for j in range(i):
p[i][j] = p[j][i] = (p[i][j] + p[j][i]) / 2
for i in range(n):
if p[i][i] > prior_variance[i]:
scale = (prior_variance[i] / p[i][i]) ** .5
for j in range(n):
p[i][j] *= scale
for j in range(n):
p[j][i] *= scale
p[i][i] = prior_variance[i]
def positive_definite(p: list[list[float]]) -> bool:
"""Cholesky check for a symmetric matrix."""
n = len(p)
lower = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(i + 1):
value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
if i == j:
if value <= 0 or not isfinite(value):
return False
lower[i][j] = value ** .5
else:
lower[i][j] = value / lower[j][j]
return True
def rls_update(theta: list[float], p: list[list[float]], x: list[float], y: float,
forgetting: float = FORGETTING) -> tuple[list[float], float] | None:
"""One exponentially weighted RLS step, updating P in place.
Returns the new parameters and the a priori residual, or None when the update
would be numerically invalid (P is left untouched then).
"""
n = len(theta)
residual = y - sum(t * v for t, v in zip(theta, x))
px = [sum(p[i][j] * x[j] for j in range(n)) for i in range(n)]
denominator = forgetting + sum(x[i] * px[i] for i in range(n))
if not isfinite(denominator) or denominator <= 0:
return None
gain = [value / denominator for value in px]
for i in range(n):
for j in range(n):
p[i][j] = (p[i][j] - gain[i] * px[j]) / forgetting
return [t + gain[i] * residual for i, t in enumerate(theta)], residual
class FeatureLearner:
"""RLS over a configurable list of named features (see FEATURES)."""
def __init__(self, features: list[str]) -> None:
if not features or len(set(features)) != len(features):
raise ValueError("Features must be unique and non-empty")
self.features = list(features)
self._specs = [spec_for(name) for name in self.features]
self.theta = [spec.prior for spec in self._specs]
self.p = [[spec.variance if i == j else 0.0 for j in range(len(self._specs))]
for i, spec in enumerate(self._specs)]
self.count = 0
self.error_sum = 0.0
self.rejected_count = 0
self.on_count = 0
self.off_count = 0
self.active = [0] * len(self.features)
# Bias-corrected running mean of squared a priori residuals.
self._residual_sq = 0.0
self._residual_weight = 0.0
def predict(self, x: list[float]) -> float:
return sum(t * v for t, v in zip(self.theta, x))
def learn(self, x: list[float], y: float, command: bool | None = None) -> bool:
"""Fit one sample of features `x` to the 5-minute change `y`."""
if (len(x) != len(self.features) or not all(isfinite(v) for v in x)
or not isfinite(y) or abs(y) > MAX_STEP_CHANGE):
self.rejected_count += 1
return False
result = rls_update(self.theta, self.p, x, y)
if result is None:
self.rejected_count += 1
return False
theta, residual = result
bound_covariance(self.p, [spec.variance for spec in self._specs])
self.theta = self._bounded(theta)
self.count += 1
self.error_sum += abs(residual)
self._residual_sq = FORGETTING * self._residual_sq + (1 - FORGETTING) * residual * residual
self._residual_weight = FORGETTING * self._residual_weight + (1 - FORGETTING)
if command is not None:
if command:
self.on_count += 1
else:
self.off_count += 1
for i, (value, spec) in enumerate(zip(x, self._specs)):
if abs(value) >= spec.active:
self.active[i] += 1
return True
def _bounded(self, theta: list[float]) -> list[float]:
bounded = [min(spec.hi, max(spec.lo, value)) for value, spec in zip(theta, self._specs)]
if "vent_wind_dir" in self.features:
# The directional term may at most cancel the isotropic wind term.
limit = bounded[self.features.index("vent_wind")] if "vent_wind" in self.features else 0.0
i = self.features.index("vent_wind_dir")
bounded[i] = min(limit, max(-limit, bounded[i]))
return bounded
def reset(self, features: Iterable[str], keep_values: Iterable[str] = ()) -> None:
"""Forget what was learned for `features` (after a configuration change).
Their variance returns to the prior and their correlations with the other
coefficients are cleared; values go back to the prior unless listed in
`keep_values` (used as a warm start). Other coefficients are untouched.
"""
keep = set(keep_values)
for name in features:
if name not in self.features:
continue
i = self.features.index(name)
spec = self._specs[i]
for j in range(len(self.features)):
self.p[i][j] = self.p[j][i] = 0.0
self.p[i][i] = spec.variance
if name not in keep:
self.theta[i] = spec.prior
self.active[i] = 0
self.theta = self._bounded(self.theta)
def reset_counts(self) -> None:
"""Readiness and error statistics start over (the measured quantity changed)."""
self.count = self.rejected_count = self.on_count = self.off_count = 0
self.error_sum = self._residual_sq = self._residual_weight = 0.0
@property
def noise_var(self) -> float:
"""Estimated variance of the unexplained 5-minute change (°C²)."""
if self._residual_weight <= 0:
return INITIAL_NOISE_STD ** 2
return self._residual_sq / self._residual_weight
@property
def mae(self) -> float | None:
return self.error_sum / self.count if self.count else None
def coefficient(self, name: str) -> float:
return self.theta[self.features.index(name)] if name in self.features else 0.0
def coefficients(self) -> dict[str, dict[str, Any]]:
"""Value, uncertainty and support of each coefficient, for diagnostics.
`confidence` is the share of the coefficient's information that comes from
data rather than the prior (0 = prior only); `std` is its standard error.
"""
result = {}
for i, (name, spec) in enumerate(zip(self.features, self._specs)):
variance = min(spec.variance, max(0.0, self.p[i][i]))
confidence = 1 - variance / spec.variance
result[name] = {
"value": round(self.theta[i], 6),
"std": round(sqrt(self.noise_var * variance), 6),
"confidence": round(confidence, 3),
"samples": self.active[i],
"identified": (self.active[i] >= MIN_ACTIVE_SAMPLES
and confidence >= IDENTIFIED_CONFIDENCE),
}
return result
def as_dict(self) -> dict[str, Any]:
return {"features": self.features, "theta": self.theta, "p": self.p,
"count": self.count, "error_sum": self.error_sum,
"rejected_count": self.rejected_count, "on_count": self.on_count,
"off_count": self.off_count, "active": self.active,
"residual_sq": self._residual_sq, "residual_weight": self._residual_weight}
@classmethod
def from_dict(cls, source: Any, features: list[str]) -> "FeatureLearner":
"""Restore what is valid for `features`; other features start from the prior.
Coefficients are matched by name, so adding or removing a heat source,
window or neighbour retrains only the affected coefficients. Anything
malformed yields a fresh learner.
"""
fresh = cls(features)
try:
if not isinstance(source, dict):
return fresh
names = source["features"]
theta = [float(v) for v in source["theta"]]
p = [[float(v) for v in row] for row in source["p"]]
n = len(names)
if (not all(isinstance(name, str) for name in names) or len(set(names)) != n
or len(theta) != n or len(p) != n or any(len(row) != n for row in p)):
return fresh
if not all(isfinite(v) for v in theta + [v for row in p for v in row]):
return fresh
for i in range(n):
for j in range(i):
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
return fresh
if not positive_definite(p):
return fresh
counts = [source.get(key, 0) for key in
("count", "rejected_count", "on_count", "off_count")]
active = source.get("active", [0] * n)
if (any(type(v) is not int or v < 0 for v in counts + list(active))
or len(active) != n or counts[2] + counts[3] > counts[0]):
return fresh
error_sum = float(source.get("error_sum", 0))
residual_sq = float(source.get("residual_sq", 0.0))
residual_weight = float(source.get("residual_weight", 0.0))
if (not all(isfinite(v) and v >= 0 for v in (error_sum, residual_sq, residual_weight))
or residual_weight > 1):
return fresh
index = {name: i for i, name in enumerate(names)}
kept = [(j, index[name]) for j, name in enumerate(features) if name in index]
obj = cls(features)
for j, i in kept:
obj.theta[j] = theta[i]
obj.active[j] = active[i]
for k, m in kept:
obj.p[j][k] = p[i][m]
obj.theta = obj._bounded(obj.theta)
bound_covariance(obj.p, [spec.variance for spec in obj._specs])
if not positive_definite(obj.p):
return fresh
obj.count, obj.rejected_count, obj.on_count, obj.off_count = counts
obj.error_sum = error_sum
obj._residual_sq, obj._residual_weight = residual_sq, residual_weight
return obj
except (KeyError, TypeError, ValueError, OverflowError):
return fresh