Fix shadow observer lifecycle, learning and prediction correctness

This commit is contained in:
buzera committed 2026-10-26 17:50:20 +02:00
1 parent 2b9a10979b
commit c434f3995d
10 files changed
+594 -33

No files matched your search

+42 -15
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from functools import partial
from math import isfinite
from math import exp, isfinite
from typing import Any
from homeassistant.core import HomeAssistant
@@ -17,7 +17,7 @@ from .const import (
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
CONF_BAND, DEFAULTS,
)
from .physics import HEAT_LAG, Learner, optimize
from .physics import Learner, optimize
from .weather_sources import choose_weather_sources, sources_for, normalize
_LOGGER = logging.getLogger(__name__)
@@ -40,17 +40,26 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
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")
return
# Replacing the actual heating system invalidates heating coefficients.
# Switching among weather providers does not discard months of learning.
if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]:
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return
# Models carry room identity, so changing the room list is safe.
for room, data in stored.get("learners", {}).items():
saved = stored.get("learners", {})
if not isinstance(saved, dict):
return
for room, data in saved.items():
if room in self.learners:
self.learners[room] = Learner.from_dict(data)
self._on_samples = max(0, int(stored.get("on_samples", 0)))
self._off_samples = max(0, int(stored.get("off_samples", 0)))
self._update_counts()
def _update_counts(self) -> None:
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:
state = self.hass.states.get(entity_id)
@@ -71,6 +80,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
entity_id for metric in WEATHER_SOURCES
for entity_id in sources_for(self.config, metric)
}
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
@@ -109,21 +121,21 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._previous is not None:
dt = (now - self._previous["time"]).total_seconds()
# Do not fit one transition across a provider/fallback switch.
if (240 <= dt <= 420 and
if (270 <= dt <= 330 and
heater.last_changed <= self._previous["time"] and
heater_on == self._previous["heater_on"] and
self._previous["outside_source"] == active_sources["temperature"]):
next_lag = self._heat_lag + HEAT_LAG * (float(self._previous["heater_on"]) - self._heat_lag)
next_lag = self._heat_lag + (1 - exp(-dt / 1200)) * (float(self._previous["heater_on"]) - self._heat_lag)
for room in self.rooms:
self.learners[room].learn(
self._previous["temps"][room], vals[room],
self._previous["outside"], next_lag
self._previous["outside"], next_lag, dt, self._previous["heater_on"]
)
self._heat_lag = next_lag
if self._previous["heater_on"]:
self._on_samples += 1
else:
self._off_samples += 1
self._update_counts()
# Persist at each sample so restart does not erase hours of training.
await self._store.async_save({
"schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
@@ -138,7 +150,13 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"outside_source": active_sources["temperature"],
}
weights = self.config.get(CONF_WEIGHTS, {})
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
try:
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
if any(not isfinite(w) or w <= 0 for w in weights.values()) or not isfinite(sum(weights.values())):
raise ValueError
except (AttributeError, TypeError, ValueError, OverflowError):
status["status"] = "invalid_weights"
return status
weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values())
status.update({
"weighted_temperature": round(weighted_temp, 3),
@@ -151,6 +169,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
room: {"loss": round(model.theta[0], 6),
"heat": round(model.theta[1], 6),
"bias": round(model.theta[2], 6),
"samples": model.count,
"on_samples": model.on_count, "off_samples": model.off_count,
"mae_5m": round(model.mae, 4) if model.mae is not None else None}
for room, model in self.learners.items()
},
@@ -159,8 +179,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
status["status"] = "paused"
return status
try:
target = float(climate.attributes["temperature"])
if not isfinite(target) or not 5 <= target <= 35:
target = normalize("temperature", climate.attributes["temperature"],
climate.attributes.get("temperature_unit",
self.hass.config.units.temperature_unit))
if target is None or not 5 <= target <= 35:
raise ValueError
except (TypeError, KeyError, ValueError):
status["status"] = "waiting_for_target"
@@ -184,7 +206,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
heating_cost=float(self.config[CONF_HEAT_COST]),
comfort_band=float(self.config[CONF_BAND]),
)
# HA State objects are immutable snapshots. Discard a result if any
# input changed while the worker was calculating the hypothetical plan.
output = await self.hass.async_add_executor_job(job)
if any(self.hass.states.get(entity) is not state for entity, state in snapshot.items()):
status["status"] = "inputs_changed"
return status
except (ValueError, RuntimeError, OverflowError) as err:
_LOGGER.warning("MPC prediction failed: %s", err)
status["status"] = "prediction_error"