initial commit v0.2.1
This commit is contained in:
commit
2b9a10979b
19 files changed
+1665
No files matched your search
@@ -0,0 +1,199 @@
|
||||
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES,
|
||||
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 .weather_sources import choose_weather_sources, sources_for, normalize
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""The existing generic thermostat stays the only actuator owner."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=5))
|
||||
self.entry = entry
|
||||
self.config = {**DEFAULTS, **entry.data, **entry.options}
|
||||
self.rooms: list[str] = self.config[CONF_ROOMS]
|
||||
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
self._previous: dict[str, Any] | None = None
|
||||
self._heat_lag: float | None = None
|
||||
self._on_samples = 0
|
||||
self._off_samples = 0
|
||||
|
||||
async def async_restore_model(self) -> None:
|
||||
stored = await self._store.async_load() or {}
|
||||
# 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():
|
||||
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)))
|
||||
|
||||
def _value(self, entity_id: str, low: float, high: float) -> float | None:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None or state.state in ("unknown", "unavailable"):
|
||||
return None
|
||||
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||
if value is None or not low <= value <= high:
|
||||
return None
|
||||
return value
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
heater = self.hass.states.get(self.config[CONF_HEATER])
|
||||
climate = self.hass.states.get(self.config[CONF_CLIMATE])
|
||||
# Choose each weather variable independently from ordered sensor sources.
|
||||
# Sensor priorities are editable in Options without touching integration code.
|
||||
weather_entity_ids = {
|
||||
entity_id for metric in WEATHER_SOURCES
|
||||
for entity_id in sources_for(self.config, metric)
|
||||
}
|
||||
weather_states = {
|
||||
entity_id: (state.state, state.attributes.get("unit_of_measurement"))
|
||||
for entity_id in weather_entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
}
|
||||
weather, active_sources, weather_comparisons = choose_weather_sources(
|
||||
self.config, weather_states
|
||||
)
|
||||
outside = weather["temperature"]
|
||||
temps = {room: self._value(room, 0.0, 45.0) for room in self.rooms}
|
||||
status: dict[str, Any] = {
|
||||
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
|
||||
"temperatures": {r: t for r, t in temps.items() if t is not None},
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"mode": climate.state if climate else "unavailable",
|
||||
"weather": {k: v for k, v in weather.items() if v is not None},
|
||||
"weather_active_sources": {k: v for k, v in active_sources.items() if v},
|
||||
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
||||
"weather_model_inputs": ["temperature"],
|
||||
}
|
||||
if heater is None or heater.state not in ("on", "off") or climate is None:
|
||||
self._previous = None
|
||||
self._heat_lag = None
|
||||
return status
|
||||
if outside is None or any(temp is None for temp in temps.values()):
|
||||
self._previous = None
|
||||
self._heat_lag = None
|
||||
return status
|
||||
|
||||
# No casting surprises: all rooms are valid here.
|
||||
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms}
|
||||
heater_on = heater.state == "on"
|
||||
if self._heat_lag is None:
|
||||
self._heat_lag = float(heater_on)
|
||||
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
|
||||
self._previous["outside_source"] == active_sources["temperature"]):
|
||||
next_lag = self._heat_lag + HEAT_LAG * (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._heat_lag = next_lag
|
||||
if self._previous["heater_on"]:
|
||||
self._on_samples += 1
|
||||
else:
|
||||
self._off_samples += 1
|
||||
# Persist at each sample so restart does not erase hours of training.
|
||||
await self._store.async_save({
|
||||
"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],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
else:
|
||||
# Model lag state becomes uncertain after long gaps.
|
||||
self._heat_lag = float(heater_on)
|
||||
|
||||
self._previous = {
|
||||
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
|
||||
"outside_source": active_sources["temperature"],
|
||||
}
|
||||
weights = self.config.get(CONF_WEIGHTS, {})
|
||||
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
|
||||
weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values())
|
||||
status.update({
|
||||
"weighted_temperature": round(weighted_temp, 3),
|
||||
"outside_temperature": outside,
|
||||
"reference_heat": heater_on,
|
||||
"on_samples": self._on_samples,
|
||||
"off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"models": {
|
||||
room: {"loss": round(model.theta[0], 6),
|
||||
"heat": round(model.theta[1], 6),
|
||||
"bias": round(model.theta[2], 6),
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None}
|
||||
for room, model in self.learners.items()
|
||||
},
|
||||
})
|
||||
if climate.state != "heat":
|
||||
status["status"] = "paused"
|
||||
return status
|
||||
try:
|
||||
target = float(climate.attributes["temperature"])
|
||||
if not isfinite(target) or not 5 <= target <= 35:
|
||||
raise ValueError
|
||||
except (TypeError, KeyError, ValueError):
|
||||
status["status"] = "waiting_for_target"
|
||||
return status
|
||||
status["target"] = target
|
||||
# Do not act on a cold model: need ~17 hours of samples plus both conditions.
|
||||
if status["samples"] < 200 or self._on_samples < 30 or self._off_samples < 30:
|
||||
status["status"] = "learning"
|
||||
return status
|
||||
try:
|
||||
# 180-minute exhaustive search can take several seconds on RPi5;
|
||||
# never block Home Assistant's event loop with CPU work.
|
||||
job = partial(
|
||||
optimize,
|
||||
vals, outside, self._heat_lag, target, weights, self.learners,
|
||||
heater_on, (now - heater.last_changed).total_seconds(),
|
||||
horizon_minutes=int(self.config[CONF_HORIZON]),
|
||||
minimum_on_minutes=int(self.config[CONF_MIN_ON]),
|
||||
minimum_off_minutes=int(self.config[CONF_MIN_OFF]),
|
||||
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
||||
heating_cost=float(self.config[CONF_HEAT_COST]),
|
||||
comfort_band=float(self.config[CONF_BAND]),
|
||||
)
|
||||
output = await self.hass.async_add_executor_job(job)
|
||||
except (ValueError, RuntimeError, OverflowError) as err:
|
||||
_LOGGER.warning("MPC prediction failed: %s", err)
|
||||
status["status"] = "prediction_error"
|
||||
return status
|
||||
status.update({
|
||||
"status": "ready", "proposal": output["proposal"],
|
||||
"prediction_60m": output["predicted_60m"],
|
||||
"prediction_end": output["predicted_end"],
|
||||
"plan_15min": output["plan_15min"], "objective": output["cost"],
|
||||
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error",
|
||||
})
|
||||
return status
|
||||
Reference in new issue
Block a user