Count rejected samples and poll outcomes; pin rollback contract

Status showed accepted sample counts but not why learning was slow.
Each room's model now counts samples it rejected (outliers, implausible
inputs), persisted as an optional rejected_count key and shown as
models.<room>.rejected_samples. Status also counts every poll outcome
since start (sample_results_since_start).

Add tests that a model in the exact shape 0.2.1 wrote restores with its
coefficients, and that today's saved model still has the keys and types
0.2.1's restore code reads, so rolling back keeps the learned model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 22:30:16 +02:00
1 parent ace6d0061b
commit 43915ba810
4 files changed
+69 -14

No files matched your search

@@ -45,6 +45,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self._retired: dict[str, dict[str, Any]] = {} self._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory() self._report_ages = ReportAgeHistory()
# How each poll was handled since start, to explain slow learning.
self._sample_results: dict[str, int] = {}
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
self._previous: dict[str, Any] | None = None self._previous: dict[str, Any] | None = None
# Radiator-lag proxy at the last poll with a valid relay state. Tracked # Radiator-lag proxy at the last poll with a valid relay state. Tracked
@@ -291,6 +293,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"outside_source": active_sources["temperature"], "outside_source": active_sources["temperature"],
}) })
result = status["sample_result"]
self._sample_results[result] = self._sample_results.get(result, 0) + 1
status["sample_results_since_start"] = dict(self._sample_results)
self._previous = { self._previous = {
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on, "time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
"outside_source": active_sources["temperature"], "outside_source": active_sources["temperature"],
@@ -320,6 +325,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"bias": round(model.theta[2], 6), "bias": round(model.theta[2], 6),
"samples": model.count, "samples": model.count,
"on_samples": model.on_count, "off_samples": model.off_count, "on_samples": model.on_count, "off_samples": model.off_count,
"rejected_samples": model.rejected_count,
"mae_5m": round(model.mae, 4) if model.mae is not None else None} "mae_5m": round(model.mae, 4) if model.mae is not None else None}
for room, model in self.learners.items() for room, model in self.learners.items()
}, },
+18 -12
View File
@@ -90,6 +90,8 @@ class Learner:
error_sum: float = 0.0 error_sum: float = 0.0
on_count: int = 0 on_count: int = 0
off_count: int = 0 off_count: int = 0
# Samples offered but not fitted (outliers, implausible inputs).
rejected_count: int = 0
def predict_delta(self, temp: float, outside: float, heat: float) -> float: def predict_delta(self, temp: float, outside: float, heat: float) -> float:
a, b, c = self.theta a, b, c = self.theta
@@ -97,15 +99,13 @@ class Learner:
def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float, def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float,
duration_seconds: float = 300, command: bool | None = None) -> bool: duration_seconds: float = 300, command: bool | None = None) -> bool:
if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)): if (not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat))
return False or not 0 <= effective_heat <= 1
if not 0 <= effective_heat <= 1: or not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330):
return False return self._reject()
if not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330:
return False
y = (temp_after - temp_before) * 300 / duration_seconds y = (temp_after - temp_before) * 300 / duration_seconds
if abs(y) > 0.8: # sensor glitch/door opened/restarted device if abs(y) > 0.8: # sensor glitch/door opened/restarted device
return False return self._reject()
x = [outside - temp_before, effective_heat, 1.0] x = [outside - temp_before, effective_heat, 1.0]
predicted = sum(t * v for t, v in zip(self.theta, x)) predicted = sum(t * v for t, v in zip(self.theta, x))
residual = y - predicted residual = y - predicted
@@ -113,7 +113,7 @@ class Learner:
forgetting = 0.999 forgetting = 0.999
denominator = forgetting + sum(x[i] * px[i] for i in range(3)) denominator = forgetting + sum(x[i] * px[i] for i in range(3))
if not isfinite(denominator) or denominator <= 0: if not isfinite(denominator) or denominator <= 0:
return False return self._reject()
k = [value / denominator for value in px] k = [value / denominator for value in px]
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)] self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
for i in range(3): for i in range(3):
@@ -135,13 +135,18 @@ class Learner:
self.off_count += 1 self.off_count += 1
return True return True
def _reject(self) -> bool:
self.rejected_count += 1
return False
@property @property
def mae(self) -> float | None: def mae(self) -> float | None:
return self.error_sum / self.count if self.count else None return self.error_sum / self.count if self.count else None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum, return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
"on_count": self.on_count, "off_count": self.off_count} "on_count": self.on_count, "off_count": self.off_count,
"rejected_count": self.rejected_count}
@classmethod @classmethod
def from_dict(cls, source: dict[str, Any]) -> "Learner": def from_dict(cls, source: dict[str, Any]) -> "Learner":
@@ -175,14 +180,15 @@ class Learner:
lower[i][j] = value ** .5 lower[i][j] = value ** .5
else: else:
lower[i][j] = value / lower[j][j] lower[i][j] = value / lower[j][j]
counts = [source.get(key, 0) for key in ("count", "on_count", "off_count")] 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): if any(type(v) is not int or v < 0 for v in counts):
return obj return obj
count, on_count, off_count = counts count, on_count, off_count, rejected_count = counts
error_sum = float(source.get("error_sum", 0)) error_sum = float(source.get("error_sum", 0))
if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count: if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count:
return obj return obj
return cls(theta, p, count, error_sum, on_count, off_count) return cls(theta, p, count, error_sum, on_count, off_count, rejected_count)
except (KeyError, TypeError, ValueError, OverflowError): except (KeyError, TypeError, ValueError, OverflowError):
return obj return obj
+39
View File
@@ -615,3 +615,42 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
data = await self.sample() data = await self.sample()
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom']) self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
self.assertEqual(data['status'], 'learning') self.assertEqual(data['status'], 'learning')
async def test_poll_outcomes_and_rejections_are_counted(self):
await self.sample()
self.states['sensor.room'].state = '25' # implausible 5 °C jump in five minutes
data = await self.sample()
self.assertEqual(data['sample_result'], 'partly_rejected')
self.assertEqual(data['models']['sensor.room']['rejected_samples'], 1)
self.fresh('sensor.room', 'sensor.outside')
data = await self.sample()
self.assertEqual(data['sample_results_since_start'],
{'first_sample': 1, 'partly_rejected': 1, 'learned': 1})
async def test_restores_model_saved_by_v021(self):
# Exact shape written by 0.2.1: no schema version, per-room counts or new keys.
legacy = {'learners': {'sensor.room': {
'theta': [0.004, 0.11, 0.0005], 'p': [[1e-4, 0, 0], [0, 0.01, 0], [0, 0, 1e-3]],
'count': 900, 'error_sum': 27.0}},
'on_samples': 300, 'off_samples': 600, 'heater_entity': 'switch.boiler',
'outside_source': 'sensor.outside'}
self.c._store.async_load.return_value = legacy
await self.c.async_restore_model()
model = self.c.learners['sensor.room']
self.assertEqual((model.theta, model.count), ([0.004, 0.11, 0.0005], 900))
self.assertEqual((model.on_count, model.off_count, model.rejected_count), (0, 0, 0))
async def test_saved_model_stays_readable_by_v021(self):
"""Rollback contract: the keys and types 0.2.1's restore code reads."""
await self.sample()
await self.sample()
payload = self.c._store.async_save.call_args.args[0]
self.assertIsInstance(payload['heater_entity'], str)
for key in ('on_samples', 'off_samples'):
self.assertIs(type(payload[key]), int)
for data in payload['learners'].values():
self.assertEqual(len(data['theta']), 3)
self.assertTrue(all(len(row) == 3 for row in data['p']))
self.assertIs(type(data['count']), int)
float(data['error_sum'])
[float(v) for v in data['theta'] + [v for row in data['p'] for v in row]]
+6 -2
View File
@@ -85,13 +85,17 @@ class ModelTests(unittest.TestCase):
for a, b in zip(nominal.theta, delayed.theta): for a, b in zip(nominal.theta, delayed.theta):
self.assertAlmostEqual(a, b) self.assertAlmostEqual(a, b)
self.assertFalse(delayed.learn(20, 25, 5, .5, command=False)) self.assertFalse(delayed.learn(20, 25, 5, .5, command=False))
self.assertEqual((delayed.count, delayed.on_count, delayed.off_count), (1, 1, 0)) self.assertFalse(delayed.learn(20, 20.1, 5, 1.5, command=False))
self.assertEqual((delayed.count, delayed.on_count, delayed.off_count, delayed.rejected_count),
(1, 1, 0, 2))
self.assertEqual(m.Learner.from_dict(delayed.as_dict()).rejected_count, 2)
def test_corrupt_model_restore_is_atomic(self): def test_corrupt_model_restore_is_atomic(self):
for changes in ({'theta': [-1, .1, 0]}, {'p': [[-1, 0, 0], [0, 1, 0], [0, 0, 1]]}, for changes in ({'theta': [-1, .1, 0]}, {'p': [[-1, 0, 0], [0, 1, 0], [0, 0, 1]]},
{'count': float('inf')}, {'count': '5'}, {'error_sum': float('nan')}, {'count': float('inf')}, {'count': '5'}, {'error_sum': float('nan')},
{'off_count': 1}, {'theta': [float('nan'), 0, 0]}, {'p': None}, {'off_count': 1}, {'theta': [float('nan'), 0, 0]}, {'p': None},
{'p': [[1, .5, 0], [0, 1, 0], [0, 0, 1]]}): {'p': [[1, .5, 0], [0, 1, 0], [0, 0, 1]]}, {'rejected_count': -1},
{'rejected_count': '3'}):
with self.subTest(changes=changes): with self.subTest(changes=changes):
data = m.Learner().as_dict() data = m.Learner().as_dict()
data.update(changes) data.update(changes)