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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user