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:
buzeraandClaude Opus 5.5 committed 2026-10-26 21:51:35 +02:00
1 parent c0e4de832b
commit 4daba5028b
6 files changed
+64 -3

No files matched your search

+8 -3
View File
@@ -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:
@@ -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)
+3
View File
@@ -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)