diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 9bf6f0f..a491eff 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -19,7 +19,7 @@ from .const import ( CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS, ) -from .data_quality import freshness +from .data_quality import ReportAgeHistory, freshness from .physics import ( LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag, lag_from_time_in_state, optimize, relay_segments, @@ -44,6 +44,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.rooms: list[str] = self.config[CONF_ROOMS] self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self._retired: dict[str, dict[str, Any]] = {} + self._report_ages = ReportAgeHistory() 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 @@ -95,8 +96,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): if state.state in ("unknown", "unavailable"): return {"status": state.state, "age_seconds": None} # last_updated is a conservative compatibility fallback for older HA. - return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)), - now, limit * 60) + quality = freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)), + now, limit * 60) + if quality["status"] in ("valid", "stale"): + quality["max_age_seconds_24h"] = round( + self._report_ages.observe(entity_id, now, quality["age_seconds"]), 1) + return quality @callback def async_relay_changed(self, event: Event) -> None: diff --git a/custom_components/heating_mpc/data_quality.py b/custom_components/heating_mpc/data_quality.py index 8ff73fc..fefc6cd 100644 --- a/custom_components/heating_mpc/data_quality.py +++ b/custom_components/heating_mpc/data_quality.py @@ -1,6 +1,7 @@ """Pure timestamp validation for reported sensor snapshots.""" from __future__ import annotations +from collections import deque from datetime import datetime from math import isfinite @@ -16,3 +17,23 @@ def freshness(reported: datetime | None, now: datetime, max_age_seconds: float) return {"status": "future_timestamp", "age_seconds": round(age, 1)} return {"status": "stale" if age > max_age_seconds else "valid", "age_seconds": round(age, 1)} + + +class ReportAgeHistory: + """Largest report age seen at polls per entity over the last 24 hours. + + Helps choose freshness limits: a limit below a sensor's normal maximum + marks it stale during ordinary operation. Hourly buckets keep memory bounded. + """ + + def __init__(self) -> None: + self._buckets: dict[str, deque[list[float]]] = {} + + def observe(self, entity_id: str, now: datetime, age_seconds: float) -> float: + hour = now.timestamp() // 3600 + buckets = self._buckets.setdefault(entity_id, deque(maxlen=25)) + if buckets and buckets[-1][0] == hour: + buckets[-1][1] = max(buckets[-1][1], age_seconds) + else: + buckets.append([hour, age_seconds]) + return max(age for start, age in buckets if hour - 24 < start <= hour) diff --git a/custom_components/heating_mpc/sensor.py b/custom_components/heating_mpc/sensor.py index 9437294..7456f93 100644 --- a/custom_components/heating_mpc/sensor.py +++ b/custom_components/heating_mpc/sensor.py @@ -19,6 +19,9 @@ class MPCStatus(CoordinatorEntity, SensorEntity): _attr_has_entity_name = True _attr_name = "Heating MPC Status" _attr_icon = "mdi:brain" + # Per-source report ages change every poll; recording them adds a new + # attribute row to the database every five minutes. Current values stay visible. + _unrecorded_attributes = frozenset({"input_quality", "weather_comparison"}) def __init__(self, coordinator, entry_id): super().__init__(coordinator) diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index 137149b..236edab 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -27,6 +27,11 @@ async def test_setup_reload_unload(hass, entry, inputs): assert len(entities) == 5 ids = {e.unique_id: e.entity_id for e in entities} assert coordinator.data["status"] == "learning" + status = hass.states.get(ids[f"{entry.entry_id}_status"]) + assert status.attributes["input_quality"]["rooms"]["sensor.room"]["max_age_seconds_24h"] == 0 + # Volatile diagnostics stay visible but are not written to the Recorder. + assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"] + assert "models" not in status.state_info["unrecorded_attributes"] proposal_id = registry.async_get_entity_id("binary_sensor", DOMAIN, f"{entry.entry_id}_proposal") assert hass.states.get(proposal_id).state == "unavailable" assert await hass.config_entries.async_reload(entry.entry_id) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 353526f..564f233 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -524,3 +524,18 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.c._store.async_load.return_value = {'retired_learners': retired} await self.c.async_restore_model() self.assertEqual(list(self.c._retired), [f'sensor.old{i}' for i in range(6, 30)]) + + async def test_report_gap_maximum_is_exposed_per_sensor(self): + await self.sample(2400) # the room then stays silent for 40 minutes + self.states['sensor.outside'].last_reported = self.now + data = await self.sample() + room = data['input_quality']['rooms']['sensor.room'] + self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400)) + self.states['sensor.room'].last_reported = self.now + self.states['sensor.outside'].last_reported = self.now + data = await self.sample() + room = data['input_quality']['rooms']['sensor.room'] + self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']), + ('valid', 0, 2400)) + weather = data['input_quality']['weather']['temperature']['sensor.outside'] + self.assertEqual(weather['max_age_seconds_24h'], 0) # kept reporting; tracked separately diff --git a/tests/test_data_quality.py b/tests/test_data_quality.py index 6979166..310ff30 100644 --- a/tests/test_data_quality.py +++ b/tests/test_data_quality.py @@ -31,6 +31,18 @@ class FreshnessTests(unittest.TestCase): with self.assertRaises(ValueError): quality.freshness(now, now, limit) + def test_report_age_history_keeps_24_hour_maximum(self): + history = quality.ReportAgeHistory() + start = datetime(2026, 10, 26, tzinfo=timezone.utc) + self.assertEqual(history.observe('sensor.a', start, 2400), 2400) + self.assertEqual(history.observe('sensor.b', start, 60), 60) + for minutes in range(5, 24 * 60, 5): + result = history.observe('sensor.a', start + timedelta(minutes=minutes), 300) + self.assertEqual(result, 2400) # still inside the 24-hour window + later = start + timedelta(hours=24, minutes=5) + self.assertEqual(history.observe('sensor.a', later, 300), 300) + self.assertEqual(history.observe('sensor.b', later, 30), 30) + def test_freshness_settings_translated(self): for name in ('strings.json', 'translations/en.json', 'translations/ro.json'): data = json.loads((ROOT / name).read_text())