From 43915ba810813e8823d71574be7ae93a74f01695 Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Mon, 26 Oct 2026 22:30:16 +0200 Subject: [PATCH] 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..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 --- custom_components/heating_mpc/coordinator.py | 6 +++ custom_components/heating_mpc/physics.py | 30 +++++++++------ tests/test_coordinator.py | 39 ++++++++++++++++++++ tests/test_physics.py | 8 +++- 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 1baacfb..bb076bc 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -45,6 +45,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self._retired: dict[str, dict[str, Any]] = {} 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._previous: dict[str, Any] | None = None # 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"], }) + 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 = { "time": now, "temps": vals, "outside": outside, "heater_on": heater_on, "outside_source": active_sources["temperature"], @@ -320,6 +325,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "bias": round(model.theta[2], 6), "samples": model.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} for room, model in self.learners.items() }, diff --git a/custom_components/heating_mpc/physics.py b/custom_components/heating_mpc/physics.py index d765dd3..a93f0eb 100644 --- a/custom_components/heating_mpc/physics.py +++ b/custom_components/heating_mpc/physics.py @@ -90,6 +90,8 @@ class Learner: error_sum: float = 0.0 on_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: 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, duration_seconds: float = 300, command: bool | None = None) -> bool: - if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)): - return False - if not 0 <= effective_heat <= 1: - return False - if not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330: - return False + if (not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)) + or not 0 <= effective_heat <= 1 + or not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330): + return self._reject() y = (temp_after - temp_before) * 300 / duration_seconds if abs(y) > 0.8: # sensor glitch/door opened/restarted device - return False + 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 @@ -113,7 +113,7 @@ class Learner: forgetting = 0.999 denominator = forgetting + sum(x[i] * px[i] for i in range(3)) if not isfinite(denominator) or denominator <= 0: - return False + 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): @@ -135,13 +135,18 @@ class Learner: self.off_count += 1 return True + def _reject(self) -> bool: + self.rejected_count += 1 + return False + @property def mae(self) -> float | None: return self.error_sum / self.count if self.count else None def as_dict(self) -> dict[str, Any]: 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 def from_dict(cls, source: dict[str, Any]) -> "Learner": @@ -175,14 +180,15 @@ class Learner: lower[i][j] = value ** .5 else: 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): return obj - count, on_count, off_count = counts + count, on_count, off_count, rejected_count = counts error_sum = float(source.get("error_sum", 0)) if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count: 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): return obj diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index c1e37d6..80dfbeb 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -615,3 +615,42 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): data = await self.sample() self.assertEqual(data['excluded_rooms'], ['sensor.bedroom']) 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]] diff --git a/tests/test_physics.py b/tests/test_physics.py index 520725e..ecb873a 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -85,13 +85,17 @@ class ModelTests(unittest.TestCase): for a, b in zip(nominal.theta, delayed.theta): self.assertAlmostEqual(a, b) 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): 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')}, {'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): data = m.Learner().as_dict() data.update(changes)