Reproduced: a forecast planning 15 minutes of heat was scored although the relay was on for 12 (the 0.4.1 rule allowed 20 % per 15-minute block), and the same on-time placed elsewhere in a block passed as well. - The relay's on-time per poll interval, integrated at its own switch times, is compared with the plan block by block up to each horizon: the deviation is the time the relay spent in another state than planned. Unobserved relay time counts as possible deviation. - Matched (at most 2 minutes, worst case): Forecast Error figures. Approximate (at most 5 minutes, at most 2 of them unobserved): separate approximate_stats. Counterfactual (more than 5 minutes seen): counted, not scored. Incomplete otherwise, by reason: relay history, assumed inputs at forecast time (an unknown window or electrical heat), no poll near the due time, or a missing reading. - 2 minutes of unplanned boiler heat move a room by roughly half a 0.1 °C sensor step for the learned heat coefficients (0.05-0.12 °C per 5 minutes); 5 minutes by about one step. - Stored ledger statistics are versioned (2); 0.4.1 statistics used the looser rule and are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
923 lines
53 KiB
Python
923 lines
53 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 mark_model_removed(hass: HomeAssistant, entry_id: str) -> None:
|
|
"""Record, under the storage lock, that the entry's model file was deleted for good."""
|
|
hass.data.setdefault(f"{DOMAIN}_removed", set()).add(entry_id)
|
|
|
|
|
|
def model_removed(hass: HomeAssistant, entry_id: str) -> bool:
|
|
return entry_id in hass.data.get(f"{DOMAIN}_removed", ())
|
|
|
|
|
|
# Stored keys holding what was learned. The rest is observation, not model: the
|
|
# heat sources' baseline samples, the window history and the heater's identity.
|
|
LEARNED_KEYS = ("learners", "retired_learners", "zone_models", "ventilation_models", "evaluation",
|
|
"operational_evaluation", "fingerprint_version", "fingerprints", "on_samples", "off_samples")
|
|
|
|
|
|
async def async_reset_learned_model(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Forget everything learned for this entry; the configuration is not touched.
|
|
|
|
The entry is unloaded first, so no running update can save the old model
|
|
afterwards, then set up again from the stripped data. Accuracy statistics go
|
|
too: they describe the model being discarded.
|
|
"""
|
|
await hass.config_entries.async_unload(entry.entry_id)
|
|
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
|
async with storage_lock(hass, entry.entry_id):
|
|
stored = await store.async_load()
|
|
if isinstance(stored, dict):
|
|
kept = {key: value for key, value in stored.items() if key not in LEARNED_KEYS}
|
|
changes = kept.get("model_changes") if isinstance(kept.get("model_changes"), list) else []
|
|
kept["model_changes"] = [*changes, {
|
|
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"), "zone": "all",
|
|
"recalibrated": ["all"], "reasons": ["learned model reset by the user"]}][-MAX_MODEL_CHANGES:]
|
|
await store.async_save(kept)
|
|
_LOGGER.info("Heating MPC: learned model reset by the user")
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
|
|
|
|
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
|
|
self._closing = False
|
|
|
|
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) -> bool:
|
|
"""Write the model unless unloading began or the model was deleted.
|
|
|
|
Shielded: unloading cancels a running scheduled update, but the file write
|
|
itself continues in a worker thread. The lock must stay held until that
|
|
write has finished, or a deletion could run first and be undone by it.
|
|
"""
|
|
write = self.hass.async_create_task(self._async_write(outside_source), f"{DOMAIN} model save")
|
|
return await asyncio.shield(write)
|
|
|
|
async def _async_write(self, outside_source: str | None) -> bool:
|
|
async with storage_lock(self.hass, self.entry.entry_id):
|
|
# Checked after acquiring the lock, not before: a save that waited for
|
|
# a deletion to finish must not write the file again.
|
|
if self.closing or model_removed(self.hass, self.entry.entry_id):
|
|
return False
|
|
await self._store.async_save(self._payload(outside_source))
|
|
return True
|
|
|
|
@property
|
|
def closing(self) -> bool:
|
|
"""True from the start of unloading: nothing is published, saved or recorded."""
|
|
return self._closing or getattr(self, "_shutdown_requested", False)
|
|
|
|
@callback
|
|
def async_begin_shutdown(self) -> None:
|
|
"""Mark unloading before anything is unloaded.
|
|
|
|
Home Assistant's own shutdown of the coordinator runs only after platforms
|
|
are unloaded; an update still running in that window must not publish or save.
|
|
"""
|
|
self._closing = True
|
|
|
|
@callback
|
|
def async_cancel_shutdown(self) -> None:
|
|
"""Unloading failed and the entry stays loaded: carry on."""
|
|
self._closing = False
|
|
|
|
async def async_shutdown(self) -> None:
|
|
self._closing = True
|
|
await super().async_shutdown()
|
|
|
|
def _stale(self, run: int) -> bool:
|
|
"""True when a newer update started or the entry is unloading."""
|
|
return run != self._run or self.closing
|
|
|
|
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."""
|
|
if self.closing:
|
|
return # a callback queued before the listener was removed
|
|
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,
|
|
# Windows whose state the forecast had to assume (sensor unknown).
|
|
"uncertain_windows": list((vent_inputs.get(key) or {}).get("unknown_windows", []))}
|
|
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")
|
|
assumed = self._ventilation.assumption(window, float(self.config[CONF_WINDOW_OPEN_MINUTES]), now)
|
|
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,
|
|
# What forecasts assume: observed, or a bounded guess from the last reading.
|
|
"forecast_assumption": assumed["basis"], "forecast_open_minutes": assumed["open_minutes"],
|
|
"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
|
|
if self.closing:
|
|
return self._superseded({"status": "inputs_changed"})
|
|
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)
|
|
# Persist at each sample so restart does not erase hours of training, and when
|
|
# a window's state changes so a restart continues from it.
|
|
window_changed, self._ventilation.changed = self._ventilation.changed, False
|
|
if (learned or window_changed) and not self._stale(run):
|
|
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
|
|
# Inputs the forecast had to assume: such forecasts are not compared with reality.
|
|
uncertain = [f"window: {name}" for room in vals
|
|
for name in (vent_inputs.get(room) or {}).get("unknown_windows", [])]
|
|
uncertain += [f"electrical heat: {self.zones[room].name}" for room in vals
|
|
if internal[room] is None and room in self.extended
|
|
and ZoneModel(self.learners[room], self.extended[room]).uses_extended]
|
|
self._forecasts.record(now.timestamp(), vals, output["trajectory_15min"], output["plan_15min"],
|
|
self._revision(), {
|
|
"strategy": "mpc_plan", "outside": outside, "wind": weather.get("wind"),
|
|
"uncertain_inputs": uncertain,
|
|
"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
|