Fix shadow observer lifecycle, learning and prediction correctness
This commit is contained in:
1 parent
2b9a10979b
commit
c434f3995d
10 files changed
+594
-33
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in new issue
Block a user