Show each sensor's longest report gap; stop recording volatile attributes
Choosing room and weather freshness limits required knowing how long each sensor normally goes without reporting, which HA does not show. Add max_age_seconds_24h to every source in input_quality: the largest report age seen at polls over the last 24 hours (hourly buckets, reset on reload, underestimates by up to one poll interval). input_quality and weather_comparison change every poll, so the Recorder stored a new attribute row every five minutes. Mark them unrecorded; they remain visible on the Status entity. Model coefficients and other attributes are still recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0e4de832b
commit
4daba5028b
6 files changed
+63
-2
No files matched your search
@@ -19,7 +19,7 @@ from .const import (
|
|||||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
|
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
|
||||||
CONF_BAND, DEFAULTS,
|
CONF_BAND, DEFAULTS,
|
||||||
)
|
)
|
||||||
from .data_quality import freshness
|
from .data_quality import ReportAgeHistory, freshness
|
||||||
from .physics import (
|
from .physics import (
|
||||||
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
|
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
|
||||||
lag_from_time_in_state, optimize, relay_segments,
|
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.rooms: list[str] = self.config[CONF_ROOMS]
|
||||||
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._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
|
||||||
@@ -95,8 +96,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
if state.state in ("unknown", "unavailable"):
|
if state.state in ("unknown", "unavailable"):
|
||||||
return {"status": state.state, "age_seconds": None}
|
return {"status": state.state, "age_seconds": None}
|
||||||
# last_updated is a conservative compatibility fallback for older HA.
|
# last_updated is a conservative compatibility fallback for older HA.
|
||||||
return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
|
quality = freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
|
||||||
now, limit * 60)
|
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
|
@callback
|
||||||
def async_relay_changed(self, event: Event) -> None:
|
def async_relay_changed(self, event: Event) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Pure timestamp validation for reported sensor snapshots."""
|
"""Pure timestamp validation for reported sensor snapshots."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from math import isfinite
|
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": "future_timestamp", "age_seconds": round(age, 1)}
|
||||||
return {"status": "stale" if age > max_age_seconds else "valid",
|
return {"status": "stale" if age > max_age_seconds else "valid",
|
||||||
"age_seconds": round(age, 1)}
|
"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)
|
||||||
@@ -19,6 +19,9 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
|||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = "Heating MPC Status"
|
_attr_name = "Heating MPC Status"
|
||||||
_attr_icon = "mdi:brain"
|
_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):
|
def __init__(self, coordinator, entry_id):
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ async def test_setup_reload_unload(hass, entry, inputs):
|
|||||||
assert len(entities) == 5
|
assert len(entities) == 5
|
||||||
ids = {e.unique_id: e.entity_id for e in entities}
|
ids = {e.unique_id: e.entity_id for e in entities}
|
||||||
assert coordinator.data["status"] == "learning"
|
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")
|
proposal_id = registry.async_get_entity_id("binary_sensor", DOMAIN, f"{entry.entry_id}_proposal")
|
||||||
assert hass.states.get(proposal_id).state == "unavailable"
|
assert hass.states.get(proposal_id).state == "unavailable"
|
||||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|||||||
@@ -524,3 +524,18 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.c._store.async_load.return_value = {'retired_learners': retired}
|
self.c._store.async_load.return_value = {'retired_learners': retired}
|
||||||
await self.c.async_restore_model()
|
await self.c.async_restore_model()
|
||||||
self.assertEqual(list(self.c._retired), [f'sensor.old{i}' for i in range(6, 30)])
|
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
|
||||||
@@ -31,6 +31,18 @@ class FreshnessTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
quality.freshness(now, now, limit)
|
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):
|
def test_freshness_settings_translated(self):
|
||||||
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
|
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
|
||||||
data = json.loads((ROOT / name).read_text())
|
data = json.loads((ROOT / name).read_text())
|
||||||
|
|||||||
Reference in new issue
Block a user