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>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 03:37:51 +02:00
1 parent 099c63cd02
commit d73e32a4b9
5 files changed
+236 -27

No files matched your search

+78 -25
View File
@@ -1,6 +1,7 @@
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
from __future__ import annotations
import asyncio
import logging
from collections import deque
from datetime import datetime, timedelta, timezone
@@ -21,7 +22,7 @@ from .const import (
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
)
from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints
from .data_quality import ReportAgeHistory, freshness
from .data_quality import ReportAgeHistory, freshness, inputs_differ
from .evaluation import ForecastEvaluator, model_snapshot
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
from .learning import FeatureLearner
@@ -30,7 +31,9 @@ from .physics import (
lag_from_time_in_state, optimize, relay_segments,
)
from .weather_sources import choose_weather_sources, sources_for, normalize
from .windows import SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, facing_cos, ventilation_vector
from .windows import (
SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, contact_open, facing_cos, ventilation_vector,
)
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
@@ -43,6 +46,14 @@ MAX_MODEL_CHANGES = 20
BASE_TERMS = ("loss", "heat", "bias")
def storage_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
"""One lock per entry, shared by successive coordinators across reloads.
A reload's new coordinator restores only after the old one's save finished.
"""
return hass.data.setdefault(f"{DOMAIN}_storage_locks", {}).setdefault(entry_id, asyncio.Lock())
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
"""Zones from the entry's zone subentries; one zone per legacy room sensor otherwise."""
subentries = getattr(entry, "subentries", None) or {}
@@ -130,8 +141,22 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._relay_events_dropped = False
self._on_samples = 0
self._off_samples = 0
# Increases with every update; an older update finishing late never publishes.
self._run = 0
async def async_restore_model(self) -> None:
async with storage_lock(self.hass, self.entry.entry_id):
await self._async_restore_model()
async def _async_save(self, outside_source: str | None) -> None:
async with storage_lock(self.hass, self.entry.entry_id):
await self._store.async_save(self._payload(outside_source))
def _stale(self, run: int) -> bool:
"""True when a newer update started or the entry is unloading."""
return run != self._run or getattr(self, "_shutdown_requested", False)
async def _async_restore_model(self) -> None:
stored = await self._store.async_load() or {}
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
_LOGGER.warning("Ignoring unsupported Heating MPC model data")
@@ -189,7 +214,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Save at once: a recalibration is not redone after a restart, and data
# from before fingerprints gets them now, so the next edit is detected
# even if it happens before the next learned sample.
await self._store.async_save(self._payload(stored.get("outside_source")))
await self._store.async_save(self._payload(stored.get("outside_source"))) # lock held
def _check_compatibility(self, stored: dict[str, Any] | None, base_prints: dict[str, str | None]) -> bool:
"""Recalibrate exactly the components whose configuration changed."""
@@ -318,6 +343,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._relay_events_dropped = False
return steady
def _superseded(self, status: dict[str, Any]) -> dict[str, Any]:
"""Result of an update that a newer one (or unloading) overtook: keep what is shown."""
current = getattr(self, "data", None)
return current if current is not None else {**status, "status": "inputs_changed", "proposal": None}
def _count_result(self, status: dict[str, Any], result: str) -> None:
"""Record how this poll was handled for learning, and the learning progress."""
status["sample_result"] = result
@@ -435,22 +465,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return {"windows": windows, "ventilation": zones,
"ventilation_events": list(self._ventilation.events)[-5:]}
def _prediction_inputs(self) -> tuple:
"""Values the prediction depends on, used to detect changes during calculation."""
heater = self.config[CONF_HEATER]
entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) |
{self.config[CONF_CLIMATE], heater})
values = []
for entity_id in entities:
def _input_snapshot(self) -> dict[tuple[str, str], Any]:
"""Normalized value of every input a prediction uses, keyed by (kind, entity).
Compared (with tolerances, see data_quality.inputs_differ) before publishing,
so a result computed from inputs that have since changed is not shown.
"""
def value(entity_id: str, metric: str):
state = self.hass.states.get(entity_id)
if state is None:
values.append((entity_id, None))
continue
attrs = state.attributes
values.append((entity_id, state.state, attrs.get("unit_of_measurement"),
attrs.get("temperature"), attrs.get("temperature_unit"),
state.last_changed if entity_id == heater else None))
return tuple(values)
return normalize(metric, state.state, state.attributes.get("unit_of_measurement")) if state else None
snapshot: dict[tuple[str, str], Any] = {}
for entity_id in set(self.sensors) | set(sources_for(self.config, "temperature")):
snapshot[("temperature", entity_id)] = value(entity_id, "temperature")
for metric in ("wind", "wind_direction", "wind_gust"):
for entity_id in sources_for(self.config, metric):
snapshot[(metric, entity_id)] = value(entity_id, metric)
for source in self.sources:
state = self.hass.states.get(source.sensor)
snapshot[("power", source.sensor)] = normalize_power(
state.state, state.attributes.get("unit_of_measurement")) if state else None
for window in self.windows:
state = self.hass.states.get(window.sensor)
snapshot[("window", window.sensor)] = contact_open(state.state if state else None, window.inverted)
heater = self.hass.states.get(self.config[CONF_HEATER])
snapshot[("relay", self.config[CONF_HEATER])] = (heater.state, heater.last_changed) if heater else None
climate = self.hass.states.get(self.config[CONF_CLIMATE])
snapshot[("thermostat", self.config[CONF_CLIMATE])] = (
climate.state, climate.attributes.get("temperature"), climate.attributes.get("temperature_unit")
) if climate else None
return snapshot
async def _async_update_data(self) -> dict[str, Any]:
now = datetime.now(timezone.utc)
@@ -464,7 +508,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
}
snapshot = {entity: self.hass.states.get(entity)
for entity in set(self.sensors) | weather_entity_ids}
inputs_before = self._prediction_inputs()
self._run += 1
run = self._run
inputs_before = self._input_snapshot()
sensor_quality = {}
readings: dict[str, float | None] = {}
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
@@ -649,9 +695,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned:
if learned and not self._stale(run):
# Persist at each sample so restart does not erase hours of training.
await self._store.async_save(self._payload(active_sources["temperature"]))
await self._async_save(active_sources["temperature"])
if self._stale(run):
return self._superseded(status)
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
# Left-out zones take their weight with them; the rest are re-normalized.
@@ -718,12 +766,17 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
weather, now.timestamp()),
)
# Discard a result if a value used by the prediction changed while
# saving or while the worker calculated the hypothetical plan. Optional
# diagnostic weather sensors are not prediction inputs.
output = await self.hass.async_add_executor_job(job)
if self._prediction_inputs() != inputs_before:
if self._stale(run):
return self._superseded(status)
# Discard a result if an input it used changed meaningfully while saving
# or while the worker calculated the hypothetical plan. Not recomputed at
# once: an off-schedule poll would break the 5-minute learning interval;
# the next regular poll (at most 5 minutes) computes a fresh plan.
changed = inputs_differ(inputs_before, self._input_snapshot())
if changed:
status["status"] = "inputs_changed"
status["changed_inputs"] = [f"{kind}: {entity}" for kind, entity in changed[:10]]
return status
except (ValueError, RuntimeError, OverflowError) as err:
_LOGGER.warning("MPC prediction failed: %s", err)