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
@@ -0,0 +1,113 @@
|
||||
"""Which learned coefficients stay valid when the configuration changes.
|
||||
|
||||
Pure Python; no Home Assistant imports.
|
||||
|
||||
Every learned component gets a fingerprint of exactly the configuration it was
|
||||
learned against. When a fingerprint changes, only that component is recalibrated;
|
||||
everything else is kept. What each fingerprint covers:
|
||||
|
||||
* zone measurement ("base", "loss", "heat", "bias"): the zone's sensors and their
|
||||
relative weights. Scaling all weights equally changes nothing; replacing, adding
|
||||
or removing a sensor, or changing the ratio between weights, does.
|
||||
* "internal": the measurement plus each enabled heat source in the zone: sensor,
|
||||
heat share, baseline setting, response time and what it includes or is included in.
|
||||
* "couple:<zone>": the measurement plus the neighbour's measurement.
|
||||
* ventilation terms: the measurement plus the zone's windows (sensor, polarity,
|
||||
opening factor); "vent_wind_dir" also the windows' facing directions;
|
||||
"recovery" also the recovery time constant; "vent_cross" also the other zones'
|
||||
windows.
|
||||
|
||||
Comfort weight, target offset and names are not physical and are never included.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
FINGERPRINT_VERSION = 1
|
||||
|
||||
|
||||
def _hash(value: Any) -> str:
|
||||
text = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha1(text.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def measurement(zone: Any) -> list[tuple[str, float]]:
|
||||
"""The zone temperature's definition: sensors with normalized weights."""
|
||||
weights = {sensor: zone.sensor_weight(sensor) for sensor in zone.sensors}
|
||||
total = sum(weights.values())
|
||||
return sorted((sensor, round(weight / total, 4)) for sensor, weight in weights.items())
|
||||
|
||||
|
||||
def source_signature(source: Any, sources: Iterable[Any]) -> list[Any]:
|
||||
sources = list(sources)
|
||||
parent = next((s.sensor for s in sources if s.key == source.included_in), None)
|
||||
children = sorted(s.sensor for s in sources if s.included_in == source.key and s.enabled)
|
||||
baseline = source.baseline_w if source.baseline_mode == "manual" else None
|
||||
return [source.sensor, source.fraction, source.baseline_mode, baseline, source.response_minutes,
|
||||
parent, children]
|
||||
|
||||
|
||||
def baseline_fingerprints(sources: Iterable[Any]) -> dict[str, str]:
|
||||
"""A source's baseline samples are its effective power: its sensor minus included ones."""
|
||||
sources = list(sources)
|
||||
return {s.key: _hash([s.sensor, sorted(c.sensor for c in sources if c.included_in == s.key and c.enabled)])
|
||||
for s in sources}
|
||||
|
||||
|
||||
def zone_fingerprints(zones: Mapping[str, Any], sources: Iterable[Any], windows: Iterable[Any],
|
||||
recovery_minutes: float) -> dict[str, dict[str, str]]:
|
||||
"""Per zone: a fingerprint for "base" and for every extended and ventilation feature."""
|
||||
sources, windows = list(sources), list(windows)
|
||||
measured = {key: _hash(measurement(zone)) for key, zone in zones.items()}
|
||||
|
||||
def zone_windows(key: str, with_azimuth: bool = False) -> list[Any]:
|
||||
return sorted([w.sensor, w.inverted, w.opening] + ([w.azimuth] if with_azimuth else [])
|
||||
for w in windows if w.zone == key and w.enabled)
|
||||
|
||||
result = {}
|
||||
for key, zone in zones.items():
|
||||
m = measured[key]
|
||||
plain = zone_windows(key)
|
||||
# Cross-ventilation scales with the other zones' openings.
|
||||
others = sorted([w.sensor, w.opening] for w in windows if w.zone != key and w.enabled)
|
||||
prints = {"base": m, "loss": m, "heat": m, "bias": m,
|
||||
"internal": _hash([m, sorted(source_signature(s, sources) for s in sources
|
||||
if s.zone == key and s.enabled)]),
|
||||
"vent_natural": _hash([m, plain]), "vent_wind": _hash([m, plain]),
|
||||
"vent_gust": _hash([m, plain]),
|
||||
"vent_wind_dir": _hash([m, zone_windows(key, with_azimuth=True)]),
|
||||
"recovery": _hash([m, plain, float(recovery_minutes)]),
|
||||
"vent_cross": _hash([m, plain, others])}
|
||||
for neighbor in zone.neighbors:
|
||||
prints[f"couple:{neighbor}"] = _hash([m, measured.get(neighbor)])
|
||||
result[key] = prints
|
||||
return result
|
||||
|
||||
|
||||
REASONS = {
|
||||
"base": "zone sensors or sensor weights changed",
|
||||
"internal": "heat sources changed",
|
||||
"couple": "a neighbouring zone's sensors changed",
|
||||
"vent_wind_dir": "window orientation changed",
|
||||
"recovery": "windows or recovery time changed",
|
||||
}
|
||||
|
||||
|
||||
def reason(component: str) -> str:
|
||||
if component in REASONS:
|
||||
return REASONS[component]
|
||||
if component.startswith("couple:"):
|
||||
return REASONS["couple"]
|
||||
return "windows changed"
|
||||
|
||||
|
||||
def changed(old: Mapping[str, str] | None, new: Mapping[str, str], components: Iterable[str]) -> list[str]:
|
||||
"""Components whose fingerprint differs. Without stored fingerprints (0.4.0 data)
|
||||
nothing is reported: those models were learned before fingerprints existed and
|
||||
are assumed to match the configuration they are loaded with."""
|
||||
if old is None:
|
||||
return []
|
||||
# A component missing from the old fingerprints is new, not changed.
|
||||
return [c for c in components if c in old and old[c] != new.get(c)]
|
||||
Reference in new issue
Block a user