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
@@ -233,12 +233,19 @@ def forecast_response(response_kw: float, variable_kw: float, steps: int, tau_mi
|
||||
class HeatSourceTracker:
|
||||
"""Per-poll processing of all heat sources: baselines, responses, zone inputs."""
|
||||
|
||||
def __init__(self, sources: list[HeatSource], saved: Any = None) -> None:
|
||||
def __init__(self, sources: list[HeatSource], saved: Any = None,
|
||||
fingerprints: Mapping[str, str] | None = None) -> None:
|
||||
self.sources = sources
|
||||
self.fingerprints = dict(fingerprints or {})
|
||||
saved = saved if isinstance(saved, Mapping) else {}
|
||||
stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {}
|
||||
self._baselines = {s.key: BaselineEstimator(stored.get(s.key) if isinstance(stored.get(s.key), list) else ())
|
||||
for s in sources}
|
||||
old_prints = saved.get("fingerprints") if isinstance(saved.get("fingerprints"), Mapping) else None
|
||||
# Samples of a replaced sensor (or a changed inclusion) describe a different load.
|
||||
self.reset_baselines = [s.key for s in sources if old_prints is not None and s.key in old_prints
|
||||
and old_prints[s.key] != self.fingerprints.get(s.key)]
|
||||
self._baselines = {s.key: BaselineEstimator(
|
||||
stored.get(s.key) if isinstance(stored.get(s.key), list) and s.key not in self.reset_baselines else ())
|
||||
for s in sources}
|
||||
self._filters = {s.key: ResponseFilter(s.response_minutes) for s in sources}
|
||||
self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW)
|
||||
self._settled_since: dict[str, float] = {}
|
||||
@@ -301,4 +308,5 @@ class HeatSourceTracker:
|
||||
return total
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()}}
|
||||
return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()},
|
||||
"fingerprints": self.fingerprints}
|
||||
Reference in new issue
Block a user