Add a generic named-feature learner for the zone models

Electrical heat, ventilation, wind and inter-zone exchange each need
their own coefficient next to the existing loss/heat/bias, and each must
stay physically bounded and not claim precision it does not have.

learning.py adds FeatureLearner: exponentially weighted RLS over named
features with per-feature bounds, priors at 0 for new effects, the same
covariance windup bound as the base model, and a linked bound that keeps
the directional wind term from cancelling more than the isotropic one.
Each prior is sized as "worth N typical samples" (20 for new effects,
200 for zone coupling, which is weakly identifiable). Confidence is the
share of a coefficient's information that comes from data; the standard
error uses a bias-corrected running noise estimate. Saved models are
matched by feature name, so adding a heat source, window or neighbour
retrains only that coefficient and keeps the covariance of the rest.

The existing 3-parameter learner now uses the same RLS step, covariance
bound and positive-definiteness check (behavior unchanged; all existing
tests pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:25:03 +02:00
1 parent e59c08f586
commit 220ac570d2
4 files changed
+420 -45

No files matched your search

+11 -39
View File
@@ -10,6 +10,8 @@ from itertools import product
from math import exp, isfinite
from typing import Any
from .learning import bound_covariance, positive_definite, rls_update
STEP_MINUTES = 5
# First-order radiator lag between the relay command and the heat reaching rooms.
LAG_TAU_SECONDS = 1200.0
@@ -18,6 +20,7 @@ HEAT_LAG = 1.0 - exp(-STEP_MINUTES * 60 / LAG_TAU_SECONDS)
LAG_LEARN_MAX_UNCERTAINTY = 0.1
# Prior covariance; also the upper bound that stops RLS windup (see _bound_covariance).
PRIOR_P = ((0.0005, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 0.08))
PRIOR_VARIANCE = [PRIOR_P[i][i] for i in range(3)]
def bound(value: float, lo: float, hi: float) -> float:
@@ -61,23 +64,8 @@ def lag_from_time_in_state(command: bool, seconds_in_state: float) -> tuple[floa
def _bound_covariance(p: list[list[float]]) -> None:
"""Keep P symmetric and no less certain than the prior, in place.
With forgetting, unexcited directions (e.g. heat input during a summer with the
relay off) grow by 1/forgetting each step without limit. Scaling row and column i
together is a congruence transform, so positive definiteness is preserved.
"""
for i in range(3):
for j in range(i):
p[i][j] = p[j][i] = (p[i][j] + p[j][i]) / 2
for i in range(3):
if p[i][i] > PRIOR_P[i][i]:
scale = (PRIOR_P[i][i] / p[i][i]) ** .5
for j in range(3):
p[i][j] *= scale
for j in range(3):
p[j][i] *= scale
p[i][i] = PRIOR_P[i][i]
"""Covariance bound for the 3-parameter base model (see learning.bound_covariance)."""
bound_covariance(p, PRIOR_VARIANCE)
@dataclass
@@ -107,18 +95,10 @@ class Learner:
if abs(y) > 0.8: # sensor glitch/door opened/restarted device
return self._reject()
x = [outside - temp_before, effective_heat, 1.0]
predicted = sum(t * v for t, v in zip(self.theta, x))
residual = y - predicted
px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)]
forgetting = 0.999
denominator = forgetting + sum(x[i] * px[i] for i in range(3))
if not isfinite(denominator) or denominator <= 0:
result = rls_update(self.theta, self.p, x, y)
if result is None:
return self._reject()
k = [value / denominator for value in px]
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
for i in range(3):
for j in range(3):
self.p[i][j] = (self.p[i][j] - k[i] * px[j]) / forgetting
self.theta, residual = result
_bound_covariance(self.p)
# Physical constraints; avoids unstable temperature predictions.
self.theta = [
@@ -169,17 +149,9 @@ class Learner:
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
return obj
_bound_covariance(p)
# A covariance must be symmetric positive definite (Cholesky check).
lower = [[0.0] * 3 for _ in range(3)]
for i in range(3):
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 obj
lower[i][j] = value ** .5
else:
lower[i][j] = value / lower[j][j]
# A covariance must be symmetric positive definite.
if not positive_definite(p):
return obj
counts = [source.get(key, 0)
for key in ("count", "on_count", "off_count", "rejected_count")]
if any(type(v) is not int or v < 0 for v in counts):