Add sensor freshness checks
This commit is contained in:
1 parent
c434f3995d
commit
cea79b6b48
12 files changed
+213
-25
No files matched your search
@@ -9,6 +9,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE,
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
||||
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
||||
@@ -37,6 +38,9 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||
selector.EntitySelectorConfig(domain="switch")),
|
||||
_required(CONF_ROOMS, data): _entity_picker(multiple=True),
|
||||
}
|
||||
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
|
||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
|
||||
vol.Coerce(int), vol.Range(min=5, max=1440))
|
||||
if detailed:
|
||||
for key, allowed in {
|
||||
CONF_HORIZON: vol.In([60, 90, 120, 150, 180]),
|
||||
|
||||
@@ -10,6 +10,8 @@ CONF_MIN_ON = "minimum_on_minutes"
|
||||
CONF_MIN_OFF = "minimum_off_minutes"
|
||||
CONF_SWITCH_COST = "switching_cost"
|
||||
CONF_HEAT_COST = "heating_cost"
|
||||
CONF_ROOM_MAX_AGE = "room_max_age_minutes"
|
||||
CONF_WEATHER_MAX_AGE = "weather_max_age_minutes"
|
||||
CONF_BAND = "comfort_band"
|
||||
|
||||
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
|
||||
@@ -28,6 +30,8 @@ WEATHER_MAIN = ("temperature", "wind", "clouds")
|
||||
WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow")
|
||||
|
||||
DEFAULTS = {
|
||||
CONF_ROOM_MAX_AGE: 30,
|
||||
CONF_WEATHER_MAX_AGE: 120,
|
||||
CONF_HORIZON: 120,
|
||||
CONF_MIN_ON: 15,
|
||||
CONF_MIN_OFF: 10,
|
||||
|
||||
@@ -13,10 +13,12 @@ from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE,
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES,
|
||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
|
||||
CONF_BAND, DEFAULTS,
|
||||
)
|
||||
from .data_quality import freshness
|
||||
from .physics import Learner, optimize
|
||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
||||
|
||||
@@ -61,14 +63,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._on_samples = min(m.on_count for m in self.learners.values())
|
||||
self._off_samples = min(m.off_count for m in self.learners.values())
|
||||
|
||||
def _value(self, entity_id: str, low: float, high: float) -> float | None:
|
||||
def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None or state.state in ("unknown", "unavailable"):
|
||||
return None
|
||||
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||
if value is None or not low <= value <= high:
|
||||
return None
|
||||
return value
|
||||
if state is None:
|
||||
return {"status": "missing", "age_seconds": None}
|
||||
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)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -83,17 +86,41 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
watched = set(self.rooms) | weather_entity_ids | {
|
||||
self.config[CONF_CLIMATE], self.config[CONF_HEATER]}
|
||||
snapshot = {entity: self.hass.states.get(entity) for entity in watched}
|
||||
weather_states = {
|
||||
entity_id: (state.state, state.attributes.get("unit_of_measurement"))
|
||||
for entity_id in weather_entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
}
|
||||
room_quality = {}
|
||||
temps = {}
|
||||
for room in self.rooms:
|
||||
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
value = None
|
||||
if quality["status"] == "valid":
|
||||
state = snapshot[room]
|
||||
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||
if value is None or not 0 <= value <= 45:
|
||||
quality["status"] = "invalid_value_or_unit"
|
||||
value = None
|
||||
room_quality[room] = quality
|
||||
temps[room] = value
|
||||
weather_states = {}
|
||||
weather_quality = {}
|
||||
for entity_id in weather_entity_ids:
|
||||
quality = self._sensor_quality(entity_id, now, self.config[CONF_WEATHER_MAX_AGE])
|
||||
weather_quality[entity_id] = quality
|
||||
if quality["status"] == "valid":
|
||||
state = snapshot[entity_id]
|
||||
weather_states[entity_id] = (state.state, state.attributes.get("unit_of_measurement"))
|
||||
weather, active_sources, weather_comparisons = choose_weather_sources(
|
||||
self.config, weather_states
|
||||
)
|
||||
metric_quality = {}
|
||||
for metric in WEATHER_SOURCES:
|
||||
metric_quality[metric] = {}
|
||||
for entity_id in sources_for(self.config, metric):
|
||||
quality = dict(weather_quality[entity_id])
|
||||
if quality["status"] == "valid" and entity_id not in weather_comparisons[metric]:
|
||||
quality["status"] = "invalid_value_or_unit"
|
||||
metric_quality[metric][entity_id] = quality
|
||||
outside = weather["temperature"]
|
||||
temps = {room: self._value(room, 0.0, 45.0) for room in self.rooms}
|
||||
status: dict[str, Any] = {
|
||||
"input_quality": {"rooms": room_quality, "weather": metric_quality},
|
||||
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
|
||||
"temperatures": {r: t for r, t in temps.items() if t is not None},
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Pure timestamp validation for reported sensor snapshots."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from math import isfinite
|
||||
|
||||
|
||||
def freshness(reported: datetime | None, now: datetime, max_age_seconds: float) -> dict:
|
||||
"""Use report time, not value-change time; reject unknown/future clocks."""
|
||||
if not isfinite(max_age_seconds) or max_age_seconds <= 0:
|
||||
raise ValueError("Freshness limit must be positive and finite")
|
||||
if not isinstance(reported, datetime) or reported.tzinfo is None:
|
||||
return {"status": "invalid_timestamp", "age_seconds": None}
|
||||
age = (now - reported).total_seconds()
|
||||
if age < 0:
|
||||
return {"status": "future_timestamp", "age_seconds": round(age, 1)}
|
||||
return {"status": "stale" if age > max_age_seconds else "valid",
|
||||
"age_seconds": round(age, 1)}
|
||||
@@ -8,7 +8,9 @@
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors"
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -59,7 +61,9 @@
|
||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||
"switching_cost": "Switching cost",
|
||||
"heating_cost": "Heating runtime cost",
|
||||
"comfort_band": "Comfort tolerance (°C)"
|
||||
"comfort_band": "Comfort tolerance (°C)",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors"
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -59,7 +61,9 @@
|
||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||
"switching_cost": "Switching cost",
|
||||
"heating_cost": "Heating runtime cost",
|
||||
"comfort_band": "Comfort tolerance (°C)"
|
||||
"comfort_band": "Comfort tolerance (°C)",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"data": {
|
||||
"climate_entity": "Termostatul Generic Thermostat existent",
|
||||
"heater_entity": "Releul fizic (numai citire)",
|
||||
"room_entities": "Senzorii individuali din camere"
|
||||
"room_entities": "Senzorii individuali din camere",
|
||||
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
|
||||
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -59,7 +61,9 @@
|
||||
"minimum_off_minutes": "Durată minimă OFF (minute)",
|
||||
"switching_cost": "Penalizare comutări",
|
||||
"heating_cost": "Penalizare durată încălzire",
|
||||
"comfort_band": "Toleranță confort (°C)"
|
||||
"comfort_band": "Toleranță confort (°C)",
|
||||
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
|
||||
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
|
||||
Reference in new issue
Block a user