Recalibrate only the model parts whose configuration changed
Confirmed: learned models were matched only by zone key, so replacing a zone's sensors, changing sensor weights, moving or replacing a heat source or turning a window kept coefficients learned for a different configuration, silently. compatibility.py fingerprints exactly what each learned component depends on: the zone measurement (sensors and relative weights; equal scaling changes nothing), each zone's heat sources, the neighbour's measurement, the zone's windows, window orientation (direction term only), the recovery time (recovery only) and other zones' openings (cross term only). Comfort weight, target offset and names are not included. Heat-source baselines have their own fingerprint (sensor and included sources). On restore, a changed measurement recalibrates the zone: base and extended loss/heat/bias keep their values as a warm start, but their uncertainty, counts and readiness start over, other terms return to the prior, and the zone's forecast statistics are dropped. Otherwise only the changed terms are reset; everything else, including other zones, is kept. Each recalibration is logged (Status model_changes, info log) and saved at once. Data from before fingerprints (0.4.0) is assumed to match its current configuration and is fingerprinted at the first start, so the next edit is detected even before a learned sample. Tests cover comfort changes, sensor replacement and weights, window orientation and size, moved and replaced heat sources, re-added zones, 0.4.0 data and a second restart, plus an HA Core test through real subentry updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9bb64e79ca
commit
099c63cd02
10 files changed
+475
-25
No files matched your search
@@ -20,6 +20,7 @@ from .const import (
|
||||
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
|
||||
from .evaluation import ForecastEvaluator, model_snapshot
|
||||
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
|
||||
@@ -38,6 +39,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
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 entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
|
||||
@@ -94,7 +97,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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._heat_sources = HeatSourceTracker(self.sources)
|
||||
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)
|
||||
@@ -107,6 +111,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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)
|
||||
@@ -134,7 +142,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
||||
return
|
||||
self._evaluator.load(stored.get("evaluation"))
|
||||
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"))
|
||||
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:
|
||||
@@ -146,8 +158,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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 room identity, so changing the room list is safe. Models of
|
||||
# rooms no longer configured are retained, most recently removed last.
|
||||
# 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):
|
||||
@@ -156,14 +170,83 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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()
|
||||
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")))
|
||||
|
||||
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)
|
||||
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(),
|
||||
"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.
|
||||
@@ -443,6 +526,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"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.
|
||||
@@ -567,20 +651,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
||||
if learned:
|
||||
# Persist at each sample so restart does not erase hours of training.
|
||||
await self._store.async_save({
|
||||
"schema_version": 2,
|
||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||
# 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(),
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
await self._store.async_save(self._payload(active_sources["temperature"]))
|
||||
# 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.
|
||||
|
||||
Reference in new issue
Block a user