Files
heating-mpc-ha/custom_components/heating_mpc/data_quality.py
T
buzeraandClaude Opus 5.5 d73e32a4b9 Never publish a plan computed from inputs that have since changed
Confirmed: the check run after the optimizer compared only zone sensors,
outdoor temperature, thermostat and relay. A window opening, a UPS load
change or a wind change during the calculation went unnoticed; nothing
prevented an older update that finished late from overwriting a newer
result; and an update in flight during unload still saved.

- Every input a prediction uses is captured as a normalized snapshot
  (zone and outdoor temperatures, wind speed/direction/gust, heat-source
  power, window states, relay state and switch time, thermostat mode and
  target) and compared after the calculation with tolerances (0.25 °C,
  50 W, 1 m/s, 2 m/s gusts, 20° direction around the circle; exact for
  relay, thermostat and windows). A meaningful change discards the plan
  and lists the inputs in changed_inputs; noise does not. No immediate
  recalculation: an off-schedule poll would break the 5-minute learning
  interval; the next regular poll computes a fresh plan.
- Updates are numbered; one overtaken by a newer update, or running when
  the entry unloads, keeps the newer data and skips saving and
  optimizing. Configuration changes reload the entry, so they are covered.
- A per-entry storage lock makes a reloaded coordinator restore only
  after the previous one's save finished, and integration removal waits
  for it too, so a late save cannot recreate a deleted model file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:37:51 +02:00

74 lines
3.0 KiB
Python

"""Pure timestamp validation for reported sensor snapshots."""
from __future__ import annotations
from collections import deque
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)}
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)
# How much an input may move while a prediction is computed before the result no
# longer describes the current situation. Below these, changes are sensor noise.
INPUT_TOLERANCES = {
"temperature": 0.25, # °C, zone sensors and outdoor sources
"power": 50.0, # W, heat-source sensors
"wind": 1.0, # m/s
"wind_gust": 2.0, # m/s
"wind_direction": 20.0, # degrees, compared around the circle
}
def inputs_differ(before: dict, after: dict) -> list:
"""Inputs whose change is meaningful: keys are (kind, entity), values normalized.
Kinds without a tolerance (relay, thermostat mode and target, window states,
configuration revision) must match exactly; a value appearing or disappearing
(for example a sensor becoming unavailable) always counts.
"""
changed = []
for key in sorted(set(before) | set(after), key=str):
old, new = before.get(key), after.get(key)
tolerance = INPUT_TOLERANCES.get(key[0])
if old is None or new is None or tolerance is None:
if old != new:
changed.append(key)
continue
difference = abs(new - old)
if key[0] == "wind_direction":
difference = min(difference, 360.0 - difference)
if difference > tolerance:
changed.append(key)
return changed