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:
1 parent
e59c08f586
commit
220ac570d2
4 files changed
+420
-45
No files matched your search
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user