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)