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

+35 -2
View File
@@ -5,8 +5,10 @@ from homeassistant.const import Platform
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN
from .const import DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
from .weather_sources import sources_for
from .coordinator import HeatingMPCCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
@@ -17,7 +19,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_restore_model()
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
try:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except Exception:
hass.data[DOMAIN].pop(entry.entry_id, None)
raise
# Remove only obsolete room forecasts belonging to this entry/platform.
registry = er.async_get(hass)
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms}
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
if (entity.platform == DOMAIN and
entity.unique_id.startswith(f"{entry.entry_id}_sensor.") and
entity.unique_id.endswith("_predicted_60min") and
entity.unique_id not in expected):
registry.async_remove(entity.entity_id)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
@@ -37,3 +52,21 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove the learned model when this integration instance is deleted."""
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
await store.async_remove()
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate legacy single weather source without changing user selections."""
if entry.version > 2:
return False
if entry.version == 1:
def migrate(config):
result = dict(config)
if CONF_OUTSIDE in result:
result[WEATHER_SOURCES["temperature"]] = sources_for(result, "temperature")
result.pop(CONF_OUTSIDE)
return result
hass.config_entries.async_update_entry(
entry, data=migrate(entry.data), options=migrate(entry.options),
version=2, minor_version=1,
)
return True
+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"
+59 -15
View File
@@ -26,25 +26,32 @@ class Learner:
p: list[list[float]] = field(default_factory=lambda: [[0.0005, 0., 0.], [0., 0.8, 0.], [0., 0., 0.08]])
count: int = 0
error_sum: float = 0.0
on_count: int = 0
off_count: int = 0
def predict_delta(self, temp: float, outside: float, heat: float) -> float:
a, b, c = self.theta
return a * (outside - temp) + b * heat + c
def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float) -> None:
def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float,
duration_seconds: float = 300, command: bool | None = None) -> bool:
if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)):
return
y = temp_after - temp_before
return False
if not 0 <= effective_heat <= 1:
return False
if not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330:
return False
y = (temp_after - temp_before) * 300 / duration_seconds
if abs(y) > 0.8: # sensor glitch/door opened/restarted device
return
return False
x = [outside - temp_before, effective_heat, 1.0]
predicted = sum(t * v for t, v in zip(self.theta, x))
residual = y - predicted
px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)]
forgetting = 0.999
denominator = forgetting + sum(x[i] * px[i] for i in range(3))
if denominator <= 0:
return
if not isfinite(denominator) or denominator <= 0:
return False
k = [value / denominator for value in px]
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
for i in range(3):
@@ -58,30 +65,58 @@ class Learner:
]
self.count += 1
self.error_sum += abs(residual)
if command is not None:
if command:
self.on_count += 1
else:
self.off_count += 1
return True
@property
def mae(self) -> float | None:
return self.error_sum / self.count if self.count else None
def as_dict(self) -> dict[str, Any]:
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum}
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
"on_count": self.on_count, "off_count": self.off_count}
@classmethod
def from_dict(cls, source: dict[str, Any]) -> "Learner":
obj = cls()
try:
if not isinstance(source, dict):
return obj
theta = [float(v) for v in source["theta"]]
p = [[float(v) for v in r] for r in source["p"]]
if len(theta) != 3 or len(p) != 3 or any(len(r) != 3 for r in p):
return obj
if not all(isfinite(v) for v in theta + [v for row in p for v in row]):
return obj
obj.theta, obj.p = theta, p
obj.count = max(0, int(source.get("count", 0)))
obj.error_sum = max(0., float(source.get("error_sum", 0)))
except (KeyError, TypeError, ValueError):
return cls()
return obj
if not (0 <= theta[0] <= .04 and 0 <= theta[1] <= .25 and -.06 <= theta[2] <= .06):
return obj
# A covariance must be symmetric positive definite (Cholesky check).
lower = [[0.0] * 3 for _ in range(3)]
for i in range(3):
for j in range(i + 1):
if abs(p[i][j] - p[j][i]) > 1e-8:
return obj
value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
if i == j:
if value <= 0 or not isfinite(value):
return obj
lower[i][j] = value ** .5
else:
lower[i][j] = value / lower[j][j]
counts = [source.get(key, 0) for key in ("count", "on_count", "off_count")]
if any(type(v) is not int or v < 0 for v in counts):
return obj
count, on_count, off_count = counts
error_sum = float(source.get("error_sum", 0))
if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count:
return obj
return cls(theta, p, count, error_sum, on_count, off_count)
except (KeyError, TypeError, ValueError, OverflowError):
return obj
def simulate_step(temps: dict[str, float], outside: float, lag: float,
@@ -112,9 +147,18 @@ def optimize(
raise ValueError("Invalid temperature")
if any(not isfinite(t) for t in temps.values()):
raise ValueError("Invalid room temperature")
if any(float(weights.get(room, 0)) <= 0 for room in temps):
if any(not isfinite(float(weights.get(room, 0))) or float(weights.get(room, 0)) <= 0 for room in temps):
raise ValueError("All rooms need a positive weight")
if not isfinite(effective_heat) or not 0 <= effective_heat <= 1:
raise ValueError("Invalid heating lag")
if any(not isfinite(v) or v < 0 for v in (
seconds_since_change, minimum_on_minutes, minimum_off_minutes,
switching_cost, heating_cost, comfort_band,
)):
raise ValueError("Invalid optimizer parameter")
total_weight = sum(float(weights[room]) for room in temps)
if not isfinite(total_weight):
raise ValueError("Invalid total weight")
norm = {room: float(weights[room]) / total_weight for room in temps}
blocks = horizon_minutes // 15
nsteps_per_block = 3
@@ -156,7 +200,7 @@ def optimize(
elapsed_s += 15 * 60
if feasible and cost < best:
best = cost
result = (bits, at_60 or dict(state), dict(state))
result = (bits, at_60 or {}, dict(state))
if result is None:
raise RuntimeError("No feasible schedule")
plan, temps_60m, temps_end = result
@@ -44,6 +44,8 @@ def normalize(metric: str, raw: object, unit: str | None = None) -> float | None
return None
if not isfinite(value):
return None
if unit is not None and not isinstance(unit, str):
return None
unit = (unit or "").strip().lower().replace(" ", "")
if metric in ("temperature", "feels_like"):
if unit in ("°f", "f", "fahrenheit"):