Files
heating-mpc-ha/custom_components/heating_mpc/coordinator.py
T
buzeraandClaude Opus 5.5 a44163a57d Report how well the base coefficients are identified
The base layer (loss, heat, bias) showed values only, so a zone with weeks of
samples looked equally trustworthy whether or not the data ever varied its
inputs. Each base coefficient now reports a confidence from its own posterior
variance, as the extended and ventilation layers already did: it stays at 0
for `heat` while the relay never cycles and below 0.8 for `loss` and `bias`
while the indoor-outdoor difference is steady. Readiness is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:49:13 +02:00

829 lines
48 KiB
Python

"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections import deque
from datetime import datetime, timedelta, timezone
from functools import partial
from math import exp
from typing import Any
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES,
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, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST,
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
)
from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints
from .data_quality import ReportAgeHistory, freshness, inputs_differ
from .evaluation import ForecastEvaluator, OperationalForecastLedger, model_snapshot
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
from .learning import FeatureLearner
from .physics import (
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
lag_from_time_in_state, optimize, relay_segments,
)
from .weather_sources import choose_weather_sources, sources_for, normalize
from .windows import (
SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, contact_open, facing_cos, ventilation_vector,
)
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
_LOGGER = logging.getLogger(__name__)
# Relay changes kept between polls; more than this in five minutes is chatter.
MAX_RELAY_EVENTS = 256
# Models of zones removed in the UI, kept in case a zone is added back.
MAX_RETIRED_ROOMS = 24
MAX_MODEL_CHANGES = 20
BASE_TERMS = ("loss", "heat", "bias")
def storage_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
"""One lock per entry, shared by successive coordinators across reloads.
A reload's new coordinator restores only after the old one's save finished.
"""
return hass.data.setdefault(f"{DOMAIN}_storage_locks", {}).setdefault(entry_id, asyncio.Lock())
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
"""Zones from the entry's zone subentries; one zone per legacy room sensor otherwise."""
subentries = getattr(entry, "subentries", None) or {}
items = [(sub.data, sub.subentry_id) for sub in subentries.values()
if sub.subentry_type == SUBENTRY_ZONE]
if not items: # not migrated yet (or offline tests): same zones migration creates
items = [(data, None) for data in legacy_zone_data(
config.get(CONF_ROOMS, []), config.get(CONF_WEIGHTS))]
return build_zones(items)
def entry_window_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[Window]:
subentries = getattr(entry, "subentries", None) or {}
return build_windows(((sub.data, sub.subentry_id) for sub in subentries.values()
if sub.subentry_type == SUBENTRY_WINDOW), zones)
def ventilation_features(config: dict[str, Any], windows: list[Window], zone: str) -> list[str]:
"""Ventilation terms for a zone with windows; optional ones need their inputs."""
if not any(w.zone == zone and w.enabled for w in windows):
return []
wind = config.get(CONF_VENT_WIND) is True and bool(sources_for(config, "wind"))
features = ["vent_natural", "recovery"]
if wind:
features.append("vent_wind")
if sources_for(config, "wind_direction"):
features.append("vent_wind_dir")
if config.get(CONF_VENT_GUST) is True and sources_for(config, "wind_gust"):
features.append("vent_gust")
if config.get(CONF_VENT_CROSS) is True and len({w.zone for w in windows if w.enabled}) > 1:
features.append("vent_cross")
return features
def entry_source_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[HeatSource]:
subentries = getattr(entry, "subentries", None) or {}
return build_sources(((sub.data, sub.subentry_id) for sub in subentries.values()
if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones)
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, config_entry=entry, name=DOMAIN,
update_interval=timedelta(minutes=5))
self.entry = entry
self.config = {**DEFAULTS, **entry.data, **entry.options}
self.zones: dict[str, Zone] = {zone.key: zone for zone in entry_zone_list(entry, self.config)}
# Zone keys; models, forecasts and statistics are keyed by these.
self.rooms: list[str] = list(self.zones)
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self.sources = entry_source_list(entry, self.zones)
self._baseline_prints = baseline_fingerprints(self.sources)
self._heat_sources = HeatSourceTracker(self.sources, fingerprints=self._baseline_prints)
# Extended model per zone: base terms plus electrical heat and adjacent zones.
self._extended_features = {
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors)
for key, zone in self.zones.items()}
self.extended: dict[str, FeatureLearner] = {
key: FeatureLearner(features) for key, features in self._extended_features.items() if features}
self.windows = entry_window_list(entry, self.zones)
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]))
self._ventilation_features = {key: ventilation_features(self.config, self.windows, key)
for key in self.zones}
self.ventilation: dict[str, FeatureLearner] = {
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features}
# What each learned component depends on; a change recalibrates only that component.
self._fingerprints = zone_fingerprints(self.zones, self.sources, self.windows,
float(self.config[CONF_RECOVERY_MINUTES]))
self._model_changes: deque[dict[str, Any]] = deque(maxlen=MAX_MODEL_CHANGES)
self._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory()
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
self._forecasts = OperationalForecastLedger()
# Seconds the relay was on during the last interval (None if unknown), and its length.
self._relay_interval: tuple[float | None, float | None] = (None, None)
# How each poll was handled since start, to explain slow learning.
self._sample_results: dict[str, int] = {}
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
self._previous: dict[str, Any] | None = None
# Radiator-lag proxy at the last poll with a valid relay state. Tracked
# independently of room sensors so lag survives room outages and transitions.
self._lag: dict[str, Any] | None = None
# (time, new state) of relay changes since the last poll, from a read-only listener.
self._relay_events: deque[tuple[datetime, str]] = deque(maxlen=MAX_RELAY_EVENTS)
self._relay_events_dropped = False
self._on_samples = 0
self._off_samples = 0
# Increases with every update; an older update finishing late never publishes.
self._run = 0
async def async_restore_model(self) -> None:
async with storage_lock(self.hass, self.entry.entry_id):
await self._async_restore_model()
async def _async_save(self, outside_source: str | None) -> None:
async with storage_lock(self.hass, self.entry.entry_id):
await self._store.async_save(self._payload(outside_source))
def _stale(self, run: int) -> bool:
"""True when a newer update started or the entry is unloading."""
return run != self._run or getattr(self, "_shutdown_requested", False)
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
self._evaluator.load(stored.get("evaluation"))
self._forecasts.load(stored.get("operational_evaluation"))
changes = stored.get("model_changes")
self._model_changes.extend(c for c in (changes if isinstance(changes, list) else []) if isinstance(c, dict))
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"), self._baseline_prints)
# None for 0.4.0 data: learned before fingerprints existed, assumed to match.
stored_prints = stored.get("fingerprints") if isinstance(stored.get("fingerprints"), dict) else None
saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
for key, features in self._extended_features.items():
if features and key in saved_models:
# Matched by feature name: a new source or neighbour retrains only its term.
self.extended[key] = FeatureLearner.from_dict(saved_models[key], features)
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]),
stored.get("ventilation"))
saved_vent = stored.get("ventilation_models") if isinstance(stored.get("ventilation_models"), dict) else {}
for key, features in self._ventilation_features.items():
if features and key in saved_vent:
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
# Models carry zone identity, so changing the zone list is safe. Models of
# zones no longer configured are retained, most recently removed last, with
# the measurement fingerprint they were learned against.
base_prints: dict[str, str | None] = {}
for key in ("retired_learners", "learners"): # current learners take precedence
saved = stored.get(key)
if not isinstance(saved, dict):
continue
for room, data in saved.items():
if not isinstance(room, str):
continue
learner = Learner.from_dict(data)
if key == "learners":
fingerprint = (stored_prints or {}).get(room, {}).get("base") if stored_prints is not None else None
else:
fingerprint = data.get("fingerprint") if isinstance(data, dict) else None
if room in self.learners:
self.learners[room] = learner
base_prints[room] = fingerprint
elif learner.count:
self._retired.pop(room, None)
self._retired[room] = {**learner.as_dict(), **({"fingerprint": fingerprint} if fingerprint else {})}
while len(self._retired) > MAX_RETIRED_ROOMS:
self._retired.pop(next(iter(self._retired)))
recalibrated = self._check_compatibility(stored_prints, base_prints)
self._update_counts()
first_prints = stored_prints is None and bool(stored.get("learners"))
if recalibrated or self._heat_sources.reset_baselines or first_prints:
# Save at once: a recalibration is not redone after a restart, and data
# from before fingerprints gets them now, so the next edit is detected
# even if it happens before the next learned sample.
await self._store.async_save(self._payload(stored.get("outside_source"))) # lock held
def _check_compatibility(self, stored: dict[str, Any] | None, base_prints: dict[str, str | None]) -> bool:
"""Recalibrate exactly the components whose configuration changed."""
any_change = False
for room in self.rooms:
current, old = self._fingerprints[room], (stored or {}).get(room) if stored is not None else None
resets: list[str] = []
measured = base_prints.get(room) is not None and base_prints[room] != current["base"]
if measured:
# The zone temperature is defined differently: re-learn it from a warm start.
self.learners[room].recalibrate()
self._evaluator.forget(room)
self._forecasts.forget(room)
resets.append("base")
for layer in (self.extended.get(room), self.ventilation.get(room)):
if layer is None:
continue
features = list(layer.features) if measured else changed(old, current, layer.features)
if features:
layer.reset(features, keep_values=BASE_TERMS)
# loss/heat/bias change only with the measurement, reported as "base".
resets.extend(f for f in features if f not in BASE_TERMS)
if measured:
layer.reset_counts()
if resets:
any_change = True
# A changed measurement explains every reset in the zone.
reasons = sorted({reason("base")} if measured else {reason(c) for c in resets})
self._model_changes.append({
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"zone": self.zones[room].name, "recalibrated": sorted(set(resets)), "reasons": reasons})
_LOGGER.info("Heating MPC: recalibrating %s for zone %s (%s)", ", ".join(sorted(set(resets))),
self.zones[room].name, "; ".join(reasons))
for key in self._heat_sources.reset_baselines:
name = next(s.name for s in self.sources if s.key == key)
self._model_changes.append({"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"heat_source": name, "recalibrated": ["baseline"],
"reasons": ["power sensor or included sources changed"]})
return any_change
def _payload(self, outside_source: str | None) -> dict[str, Any]:
return {
"schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
# Optional keys; older 0.2.x readers ignore them and keep working.
"retired_learners": self._retired,
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
"heat_sources": self._heat_sources.as_dict(),
"ventilation_models": {key: model.as_dict() for key, model in self.ventilation.items()},
"ventilation": self._ventilation.as_dict(),
"evaluation": self._evaluator.as_dict(),
"operational_evaluation": self._forecasts.as_dict(),
"fingerprint_version": FINGERPRINT_VERSION,
"fingerprints": self._fingerprints,
"model_changes": list(self._model_changes),
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
"outside_source": outside_source,
}
def _update_counts(self) -> None:
# Without zones (all deleted in the UI) there is nothing to count.
self._on_samples = min((m.on_count for m in self.learners.values()), default=0)
self._off_samples = min((m.off_count for m in self.learners.values()), default=0)
def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict:
state = self.hass.states.get(entity_id)
if state is None:
return {"status": "missing", "age_seconds": None}
if state.state in ("unknown", "unavailable"):
return {"status": state.state, "age_seconds": None}
# last_updated is a conservative compatibility fallback for older HA.
quality = freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
now, limit * 60)
if quality["status"] in ("valid", "stale"):
quality["max_age_seconds_24h"] = round(
self._report_ages.observe(entity_id, now, quality["age_seconds"]), 1)
return quality
@callback
def async_relay_changed(self, event: Event) -> None:
"""Record relay state changes as they happen; read-only state listener."""
old, new = event.data.get("old_state"), event.data.get("new_state")
if new is None:
self.record_relay_state(event.time_fired, "removed")
elif old is None or old.state != new.state:
self.record_relay_state(new.last_changed, new.state)
def record_relay_state(self, when: datetime, state: str) -> None:
if len(self._relay_events) == self._relay_events.maxlen:
self._relay_events_dropped = True
self._relay_events.append((when, state))
def _track_lag(self, now: datetime, heater_on: bool, last_changed: datetime) -> bool:
"""Advance the lag proxy to `now`; return True if the relay held one state throughout.
Relay changes since the last poll are integrated at their timestamps. Anything
ambiguous (missed changes, an outage, clock jumps) restarts the estimate from
the time spent in the current state, with an explicit error bound.
"""
tracker = self._lag
steady = False
lag = uncertainty = None
self._relay_interval = (None, None) # unknown unless the relay history is known below
if tracker is not None and now > tracker["time"]:
start = tracker["time"]
changes = [(when, state) for when, state in self._relay_events if start < when <= now]
# The current state is authoritative; its change event may still be queued,
# and without a listener it is the only change visible.
if last_changed > start and (not changes or changes[-1][0] < last_changed):
changes.append((last_changed, "on" if heater_on else "off"))
segments = None if self._relay_events_dropped else relay_segments(
tracker["heater_on"], start.timestamp(),
[(when.timestamp(), state) for when, state in changes], now.timestamp())
self._relay_interval = (None, (now - start).total_seconds())
if segments is not None and segments[-1][0] == heater_on:
lag = tracker["lag"]
for command, seconds in segments:
lag = advance_lag(lag, command, seconds)
self._relay_interval = (sum(sec for command, sec in segments if command),
(now - start).total_seconds())
# The lag update is a contraction, so an initial error decays the same way.
uncertainty = tracker["uncertainty"] * exp(
-(now - start).total_seconds() / LAG_TAU_SECONDS)
steady = len(segments) == 1
if lag is None:
lag, uncertainty = lag_from_time_in_state(
heater_on, (now - last_changed).total_seconds())
self._lag = {"time": now, "heater_on": heater_on, "lag": lag, "uncertainty": uncertainty}
self._relay_events = deque((e for e in self._relay_events if e[0] > now),
maxlen=MAX_RELAY_EVENTS)
self._relay_events_dropped = False
return steady
def _revision(self) -> str:
"""Short identity of the model configuration a forecast was made with."""
return hashlib.sha1(json.dumps(self._fingerprints, sort_keys=True).encode()).hexdigest()[:10]
def _superseded(self, status: dict[str, Any]) -> dict[str, Any]:
"""Result of an update that a newer one (or unloading) overtook: keep what is shown."""
current = getattr(self, "data", None)
return current if current is not None else {**status, "status": "inputs_changed", "proposal": None}
def _count_result(self, status: dict[str, Any], result: str) -> None:
"""Record how this poll was handled for learning, and the learning progress."""
status["sample_result"] = result
self._sample_results[result] = self._sample_results.get(result, 0) + 1
status["sample_results_since_start"] = dict(self._sample_results)
# Least trained room: mean of its three readiness requirements (100 = ready).
status["learning_progress"] = round(100 * min(
((min(1.0, m.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES)
+ min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3
for m in self.learners.values()), default=0.0), 1)
def _extended_diagnostics(self, room: str) -> dict[str, Any]:
model = self.extended.get(room)
if model is None:
return {"model_in_use": "base"}
return {"model_in_use": "extended" if ZoneModel(self.learners[room], model).uses_extended else "base",
"extended": {"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,
"coefficients": model.coefficients()}}
def _source_diagnostics(self, quality: dict[str, dict]) -> dict[str, Any]:
names = {s.key: s.name for s in self.sources}
result = {}
for source in self.sources:
info = self._heat_sources.state.get(source.key, {})
def watts(kw):
return None if kw is None else round(kw * 1000, 1)
result[source.name] = {
"zone": self.zones[source.zone].name, "sensor": source.sensor,
"status": quality[source.key]["status"], "enabled": source.enabled,
"power_w": info.get("power_w"), "effective_w": info.get("effective_w"),
"included_in": names.get(source.included_in),
"baseline_w": info.get("baseline_w"), "baseline_mode": source.baseline_mode,
"variable_w": watts(info.get("variable_kw")), "thermal_response_w": watts(info.get("response_kw")),
"settled": info.get("settled", False), "heat_fraction": source.fraction,
"response_minutes": source.response_minutes,
}
return result
def _step_model(self, temps: dict[str, float], outside: float, horizon: int,
weather: dict[str, Any], now: float):
"""Multi-zone step for the optimizer, or None when only base models apply."""
models = {key: ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
for key in temps}
steps = horizon // 5
ventilation = {key: self._ventilation.forecast(
key, steps, float(self.config[CONF_WINDOW_OPEN_MINUTES]), weather, now) for key in temps}
venting = any(models[key].ventilation is not None
and any(s.get("opening") or s.get("recovery") for s in ventilation[key]) for key in temps)
if not venting and not any(model.uses_extended for model in models.values()):
return None
half_life = float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30))
forecast = {key: self._heat_sources.zone_forecast(key, steps, half_life) for key in temps}
return compile_step_model(models, list(temps), steps, lambda key, k: {
"outside": outside, "internal": forecast[key][k], "ventilation": ventilation[key][k]})
def _zone_details(self, temps: dict[str, float], outside: float, internal: dict[str, float | None],
vent_inputs: dict[str, Any]) -> dict[str, Any]:
"""What drives each zone right now (°C/h per effect) and how sure each layer is."""
details = {}
lag = self._lag["lag"]
for key, temp in temps.items():
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
"ventilation": vent_inputs.get(key) or {}}
base = self.learners[key]
confidence = base.confidence()
coefficients = {"base": {name: {"value": round(value, 6), "confidence": confidence[name]}
for name, value in zip(("loss", "heat", "bias"), base.theta)}}
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
if learner is not None:
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
for name, info in learner.coefficients().items()}
details[key] = {"temperature": round(temp, 3),
"model_in_use": "extended" if model.uses_extended else "base",
"contributions_c_per_h": model.contributions(temp, inputs),
"coefficients": coefficients}
return details
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
vent_inputs: dict[str, Any], internal: dict[str, float | None]) -> dict[str, Any]:
windows = {}
for window in self.windows:
info = self._ventilation.window_state.get(window.key, {})
is_open, since = info.get("open"), info.get("since")
direction = weather.get("wind_direction")
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
windows[window.name] = {
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
"state": "unknown" if is_open is None else "open" if is_open else "closed",
"last_known_state": None if last_known is None else "open" if last_known else "closed",
"unknown_minutes": round((now - unknown_since) / 60, 1) if unknown_since else None,
"open_minutes": round((now - since) / 60, 1) if is_open and since else None,
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
"relative_wind_direction": info.get("relative_wind_direction"),
"wind_exposure": round(facing_cos(direction, window.azimuth), 3) if direction is not None else None,
}
zones = {}
for key, inputs in vent_inputs.items():
model = self.ventilation.get(key)
if inputs is None or model is None:
continue
temp, outside = temps.get(key), weather.get("temperature")
contributions = {}
if temp is not None and outside is not None:
terms = ZoneModel(self.learners[key], self.extended.get(key), model).contributions(temp, {
"outside": outside, "lag": (self._lag or {}).get("lag", 0.0), "internal": internal[key],
"temps": temps, "ventilation": inputs})
contributions = {name: terms.get(name, 0.0) for name in ("ventilation", "recovery")}
zones[self.zones[key].name] = {
"opening": inputs["opening"], "recovery": round(inputs["recovery"], 3),
"temperature_before": inputs["t_ref"], "affected": inputs["affected"],
"contribution_c_per_h": contributions, "samples": model.count,
"coefficients": model.coefficients()}
return {"windows": windows, "ventilation": zones,
"ventilation_events": list(self._ventilation.events)[-5:]}
def _input_snapshot(self) -> dict[tuple[str, str], Any]:
"""Normalized value of every input a prediction uses, keyed by (kind, entity).
Compared (with tolerances, see data_quality.inputs_differ) before publishing,
so a result computed from inputs that have since changed is not shown.
"""
def value(entity_id: str, metric: str):
state = self.hass.states.get(entity_id)
return normalize(metric, state.state, state.attributes.get("unit_of_measurement")) if state else None
snapshot: dict[tuple[str, str], Any] = {}
for entity_id in set(self.sensors) | set(sources_for(self.config, "temperature")):
snapshot[("temperature", entity_id)] = value(entity_id, "temperature")
for metric in ("wind", "wind_direction", "wind_gust"):
for entity_id in sources_for(self.config, metric):
snapshot[(metric, entity_id)] = value(entity_id, metric)
for source in self.sources:
state = self.hass.states.get(source.sensor)
snapshot[("power", source.sensor)] = normalize_power(
state.state, state.attributes.get("unit_of_measurement")) if state else None
for window in self.windows:
state = self.hass.states.get(window.sensor)
snapshot[("window", window.sensor)] = contact_open(state.state if state else None, window.inverted)
heater = self.hass.states.get(self.config[CONF_HEATER])
snapshot[("relay", self.config[CONF_HEATER])] = (heater.state, heater.last_changed) if heater else None
climate = self.hass.states.get(self.config[CONF_CLIMATE])
snapshot[("thermostat", self.config[CONF_CLIMATE])] = (
climate.state, climate.attributes.get("temperature"), climate.attributes.get("temperature_unit")
) if climate else None
return snapshot
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)
}
snapshot = {entity: self.hass.states.get(entity)
for entity in set(self.sensors) | weather_entity_ids}
self._run += 1
run = self._run
inputs_before = self._input_snapshot()
sensor_quality = {}
readings: dict[str, float | None] = {}
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
for sensor in self.sensors:
quality = self._sensor_quality(sensor, now, self.config[CONF_ROOM_MAX_AGE])
value = None
# Optionally trust sensors that report only on change, but never a
# sensor silent for a day: it is most likely dead.
if quality["status"] == "valid" or (
keep_stale and quality["status"] == "stale"
and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60):
state = snapshot[sensor]
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
if value is None or not 0 <= value <= 45:
quality["status"] = "invalid_value_or_unit"
value = None
quality["used"] = value is not None
sensor_quality[sensor] = quality
readings[sensor] = value
# A zone is usable while at least one of its sensors is; sensor weights
# combine them. Which sensors were used matters for learning (see below).
temps: dict[str, float | None] = {}
compositions: dict[str, frozenset[str]] = {}
zone_quality = {}
for key, zone in self.zones.items():
temps[key], compositions[key] = zone_temperature(zone, readings)
zone_quality[key] = {"name": zone.name, "used": temps[key] is not None,
"sensors": {s: sensor_quality[s] for s in zone.sensors}}
weather_states = {}
weather_quality = {}
for entity_id in weather_entity_ids:
quality = self._sensor_quality(entity_id, now, self.config[CONF_WEATHER_MAX_AGE])
weather_quality[entity_id] = quality
if quality["status"] == "valid":
state = snapshot[entity_id]
weather_states[entity_id] = (state.state, state.attributes.get("unit_of_measurement"))
weather, active_sources, weather_comparisons = choose_weather_sources(
self.config, weather_states
)
metric_quality = {}
for metric in WEATHER_SOURCES:
metric_quality[metric] = {}
for entity_id in sources_for(self.config, metric):
quality = dict(weather_quality[entity_id])
if quality["status"] == "valid" and entity_id not in weather_comparisons[metric]:
quality["status"] = "invalid_value_or_unit"
metric_quality[metric][entity_id] = quality
outside = weather["temperature"]
status: dict[str, Any] = {
"input_quality": {"zones": zone_quality, "weather": metric_quality},
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
"temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None},
"zone_names": {key: zone.name for key, zone in self.zones.items()},
"on_samples": self._on_samples, "off_samples": self._off_samples,
"samples": min((learner.count for learner in self.learners.values()), default=0),
"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"],
"retired_zone_models": sorted(self._retired),
"model_changes": list(self._model_changes)[-5:],
"excluded_zones": [room for room in self.rooms if temps[room] is None],
}
# Electrical heat sources: processed every poll so their responses stay continuous.
source_readings: dict[str, float | None] = {}
source_quality = {}
for source in self.sources:
quality = self._sensor_quality(source.sensor, now, self.config[CONF_ROOM_MAX_AGE])
watts = None
if quality["status"] == "valid":
state = self.hass.states.get(source.sensor)
watts = normalize_power(state.state, state.attributes.get("unit_of_measurement"))
if watts is None:
quality["status"] = "invalid_value_or_unit"
source_readings[source.key] = watts
source_quality[source.key] = quality
self._heat_sources.update(now.timestamp(), source_readings)
internal = {key: self._heat_sources.zone_input(key) for key in self.zones}
if self.sources:
status["heat_sources"] = self._source_diagnostics(source_quality)
# Windows: tracked every poll; unknown states stay unknown, never "closed".
window_states = {}
for window in self.windows:
state = self.hass.states.get(window.sensor)
window_states[window.key] = ((state.state, state.last_changed.timestamp()) if state else (None, None))
self._ventilation.update(now.timestamp(), window_states, temps, weather)
vent_inputs = {key: self._ventilation.zone_inputs(key, weather) for key in self.zones}
if self.windows:
status.update(self._window_diagnostics(now.timestamp(), weather, temps, vent_inputs, internal))
heater_valid = heater is not None and heater.state in ("on", "off")
heater_on = heater_valid and heater.state == "on"
steady = False
if heater_valid:
steady = self._track_lag(now, heater_on, heater.last_changed)
status["heat_lag"] = round(self._lag["lag"], 3)
status["heat_lag_uncertainty"] = round(self._lag["uncertainty"], 3)
else:
self._lag = None
self._relay_events.clear()
self._relay_events_dropped = False
self._relay_interval = (None, None)
# Published forecasts that are due are scored against these readings; nothing
# here is fed back into what those forecasts assumed.
self._forecasts.observe(now.timestamp(), {k: v for k, v in temps.items() if v is not None},
*self._relay_interval)
status["operational_forecast"] = self._forecasts.summary(self.rooms)
# Rooms without a usable reading are left out and the others carry on;
# only the shared inputs, or losing every room, pause the integration.
vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None}
if not heater_valid or climate is None or outside is None or not vals:
self._previous = None
self._evaluator.reset_window()
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
self._count_result(status, "waiting_for_inputs")
if not self.zones:
status["status"] = "no_zones"
return status
# A zone learns only across a poll pair where it was usable at both ends with
# the same sensors: a sensor dropping out shifts the mean by its offset.
pairs = [room for room in vals if self._previous and room in self._previous["temps"]
and self._previous["compositions"].get(room) == compositions[room]]
learned = False
if not pairs:
changed = self._previous and any(
room in self._previous["temps"] and self._previous["compositions"].get(room)
!= compositions[room] for room in vals)
status["sample_result"] = "sensors_changed" if changed else "first_sample"
else:
dt = (now - self._previous["time"]).total_seconds()
# Do not fit across a relay change, a provider/fallback switch or an
# uncertain lag estimate (restart, relay outage, several changes).
if not 270 <= dt <= 330:
status["sample_result"] = "interval_out_of_range"
elif not steady or heater_on != self._previous["heater_on"]:
status["sample_result"] = "relay_changed"
elif self._previous["outside_source"] != active_sources["temperature"]:
status["sample_result"] = "outside_source_changed"
elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY:
status["sample_result"] = "lag_uncertain"
else:
# Window open, unknown or recovering: not envelope loss; keep it out of
# the closed-window models and give it to the ventilation model.
vented = [room for room in pairs if (vent_inputs.get(room) or {}).get("affected")]
clean = [room for room in pairs if room not in vented]
accepted = [
self.learners[room].learn(
self._previous["temps"][room], vals[room],
self._previous["outside"], self._lag["lag"], dt, heater_on)
for room in clean
]
before = {"outside": self._previous["outside"], "lag": self._lag["lag"],
"temps": self._previous["temps"]}
for room in pairs:
start = self._previous["temps"][room]
change = (vals[room] - start) * 300 / dt
inputs = {**before, "internal": internal[room]}
if room in clean:
# Extended models learn the same interval when all their inputs are known.
model = self.extended.get(room)
x = model and feature_vector(model.features, start, inputs)
if x:
model.learn(x, change, heater_on)
continue
model = self.ventilation.get(room)
x = model and ventilation_vector(model.features, start, before["outside"], vent_inputs[room])
if x:
main = ZoneModel(self.learners[room], self.extended.get(room)).main_delta(start, inputs)
model.learn(x, change - main, heater_on)
if clean:
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts()
else:
status["sample_result"] = "ventilation"
learned = True
self._count_result(status, status["sample_result"])
self._previous = {
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
"outside_source": active_sources["temperature"], "compositions": compositions,
}
# Parameters after this poll's learning: forecasts from here use only data so far.
self._evaluator.observe(
now, vals, outside, self._lag["lag"], self._lag["uncertainty"],
{room: tuple(self.learners[room].theta) for room in vals},
models={room: model_snapshot(ZoneModel(self.learners[room], self.extended.get(room),
self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned and not self._stale(run):
# Persist at each sample so restart does not erase hours of training.
await self._async_save(active_sources["temperature"])
if self._stale(run):
return self._superseded(status)
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
# Left-out zones take their weight with them; the rest are re-normalized.
used_weights = {room: weights[room] for room in vals}
weighted_temp = sum(used_weights[r] * vals[r] for r in vals) / sum(used_weights.values())
status.update({
"weighted_temperature": round(weighted_temp, 3),
"weight_coverage": round(sum(used_weights.values()) / sum(weights.values()), 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()), default=0),
"models": {
room: {"name": self.zones[room].name,
"loss": round(model.theta[0], 6),
"heat": round(model.theta[1], 6),
"bias": round(model.theta[2], 6),
# How well the data pins each coefficient down, not how many samples.
"confidence": model.confidence(),
"samples": model.count,
"on_samples": model.on_count, "off_samples": model.off_count,
"rejected_samples": model.rejected_count,
"mae_5m": round(model.mae, 4) if model.mae is not None else None,
**self._extended_diagnostics(room)}
for room, model in self.learners.items()
},
})
status["zone_details"] = self._zone_details(vals, outside, internal, vent_inputs)
if climate.state != "heat":
status["status"] = "paused"
return status
try:
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"
return status
status["target"] = target
# Do not act on a cold model: every room used needs ~17 hours of samples
# plus both relay conditions.
used_models = [self.learners[room] for room in vals]
if (min(m.count for m in used_models) < READY_SAMPLES
or min(m.on_count for m in used_models) < READY_ON_SAMPLES
or min(m.off_count for m in used_models) < READY_OFF_SAMPLES):
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._lag["lag"], target, used_weights,
{room: self.learners[room] for room in vals},
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]),
target_offsets={room: self.zones[room].target_offset for room in vals},
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
weather, now.timestamp()),
)
output = await self.hass.async_add_executor_job(job)
if self._stale(run):
return self._superseded(status)
# Discard a result if an input it used changed meaningfully while saving
# or while the worker calculated the hypothetical plan. Not recomputed at
# once: an off-schedule poll would break the 5-minute learning interval;
# the next regular poll (at most 5 minutes) computes a fresh plan.
changed = inputs_differ(inputs_before, self._input_snapshot())
if changed:
status["status"] = "inputs_changed"
status["changed_inputs"] = [f"{kind}: {entity}" for kind, entity in changed[:10]]
return status
except (ValueError, RuntimeError, OverflowError) as err:
_LOGGER.warning("MPC prediction failed: %s", err)
status["status"] = "prediction_error"
return status
self._forecasts.record(now.timestamp(), vals, output["trajectory_15min"], output["plan_15min"],
self._revision(), {
"strategy": "mpc_plan", "outside": outside, "wind": weather.get("wind"),
"internal_half_life_minutes": float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30)),
"window_open_minutes": float(self.config[CONF_WINDOW_OPEN_MINUTES]),
"internal_kw": {room: internal[room] for room in vals},
"windows_open": [w.name for w in self.windows
if self._ventilation.window_state.get(w.key, {}).get("open")]})
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"],
"prediction_trajectory": output["trajectory_15min"],
"model_quality_note": ("mae_5m is the in-sample 5-minute error; Model Error sensors replay the "
"model with actual inputs; Forecast Error scores published forecasts"),
})
return status