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
+474
-24
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)]
|
||||
@@ -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.
|
||||
|
||||
@@ -174,6 +174,14 @@ class ForecastEvaluator:
|
||||
"rooms": {room: _stats(values) for room, values in per_room.items()}}
|
||||
return result
|
||||
|
||||
def forget(self, room: str) -> None:
|
||||
"""Drop a zone's statistics, for example when its temperature is measured differently."""
|
||||
for by_horizon in self._days.values():
|
||||
for by_room in by_horizon.values():
|
||||
by_room.pop(room, None)
|
||||
for poll in self._history:
|
||||
poll["temps"].pop(room, None)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
# Rounded copy: shorter JSON on every save; in-memory sums stay exact.
|
||||
return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:])]
|
||||
|
||||
@@ -233,11 +233,18 @@ 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 ())
|
||||
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)
|
||||
@@ -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}
|
||||
@@ -15,7 +15,7 @@ displayed standard deviation uses a running estimate of the measurement noise.
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite, sqrt
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Any, Iterable, NamedTuple
|
||||
|
||||
FORGETTING = 0.999 # about 3.5 days of 5-minute samples
|
||||
MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics
|
||||
@@ -195,6 +195,32 @@ class FeatureLearner:
|
||||
bounded[i] = min(limit, max(-limit, bounded[i]))
|
||||
return bounded
|
||||
|
||||
def reset(self, features: Iterable[str], keep_values: Iterable[str] = ()) -> None:
|
||||
"""Forget what was learned for `features` (after a configuration change).
|
||||
|
||||
Their variance returns to the prior and their correlations with the other
|
||||
coefficients are cleared; values go back to the prior unless listed in
|
||||
`keep_values` (used as a warm start). Other coefficients are untouched.
|
||||
"""
|
||||
keep = set(keep_values)
|
||||
for name in features:
|
||||
if name not in self.features:
|
||||
continue
|
||||
i = self.features.index(name)
|
||||
spec = self._specs[i]
|
||||
for j in range(len(self.features)):
|
||||
self.p[i][j] = self.p[j][i] = 0.0
|
||||
self.p[i][i] = spec.variance
|
||||
if name not in keep:
|
||||
self.theta[i] = spec.prior
|
||||
self.active[i] = 0
|
||||
self.theta = self._bounded(self.theta)
|
||||
|
||||
def reset_counts(self) -> None:
|
||||
"""Readiness and error statistics start over (the measured quantity changed)."""
|
||||
self.count = self.rejected_count = self.on_count = self.off_count = 0
|
||||
self.error_sum = self._residual_sq = self._residual_weight = 0.0
|
||||
|
||||
@property
|
||||
def noise_var(self) -> float:
|
||||
"""Estimated variance of the unexplained 5-minute change (°C²)."""
|
||||
|
||||
@@ -122,6 +122,14 @@ class Learner:
|
||||
self.rejected_count += 1
|
||||
return False
|
||||
|
||||
def recalibrate(self) -> None:
|
||||
"""Re-learn after the zone's measurement changed: the coefficients stay as a
|
||||
starting point, but their uncertainty and every count go back to the start,
|
||||
so readiness has to be earned again."""
|
||||
self.p = [list(row) for row in PRIOR_P]
|
||||
self.count = self.on_count = self.off_count = self.rejected_count = 0
|
||||
self.error_sum = 0.0
|
||||
|
||||
@property
|
||||
def mae(self) -> float | None:
|
||||
return self.error_sum / self.count if self.count else None
|
||||
|
||||
@@ -36,7 +36,7 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
||||
# Per-source report ages change every poll; recording them adds a new
|
||||
# attribute row to the database every five minutes. Current values stay visible.
|
||||
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
|
||||
"ventilation", "ventilation_events"})
|
||||
"ventilation", "ventilation_events", "model_changes"})
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -305,3 +305,25 @@ async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs
|
||||
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"
|
||||
|
||||
|
||||
async def test_zone_edits_recalibrate_only_when_the_measurement_changes(hass, entry, inputs, hass_storage):
|
||||
from custom_components.heating_mpc.physics import Learner
|
||||
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
|
||||
"schema_version": 2, "heater_entity": "switch.boiler",
|
||||
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
|
||||
await setup(hass, entry)
|
||||
zone = zone_subentry(entry, "sensor.room")
|
||||
hass.config_entries.async_update_subentry(entry, zone, data={**zone.data, "comfort_weight": 3.0})
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.learners["sensor.room"].count == 250 # comfort is not physics
|
||||
hass.states.async_set("sensor.second", "21", {"unit_of_measurement": "°C"})
|
||||
hass.config_entries.async_update_subentry(entry, zone, data={
|
||||
**zone.data, "sensors": ["sensor.room", "sensor.second"],
|
||||
"sensor_weights": {"sensor.room": 1.0, "sensor.second": 1.0}})
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.learners["sensor.room"].count == 0
|
||||
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||
assert status.attributes["model_changes"][-1]["reasons"] == ["zone sensors or sensor weights changed"]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Configuration fingerprints: what invalidates which learned coefficient."""
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||
from heating_mpc import compatibility as cp, heat_sources as hs, windows as wm, zones as zn # noqa: E402
|
||||
|
||||
|
||||
def setup(zones=None, sources=None, windows=None, recovery=20):
|
||||
zones = zones or [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.l1', 'sensor.l2'],
|
||||
'sensor_weights': {'sensor.l1': 1, 'sensor.l2': 1}, 'neighbors': ['bed']},
|
||||
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b']}]
|
||||
built = {z.key: z for z in zn.build_zones((z, None) for z in zones)}
|
||||
sources = hs.build_sources(((s, None) for s in (sources if sources is not None else [
|
||||
{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'},
|
||||
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}])), built)
|
||||
windows = wm.build_windows(((w, None) for w in (windows if windows is not None else [
|
||||
{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345},
|
||||
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}])), built)
|
||||
return cp.zone_fingerprints(built, sources, windows, recovery), sources
|
||||
|
||||
|
||||
def differing(a, b):
|
||||
return {zone: sorted(k for k in set(a[zone]) | set(b[zone]) if a[zone].get(k) != b[zone].get(k))
|
||||
for zone in a if zone in b and a[zone] != b[zone]}
|
||||
|
||||
|
||||
class FingerprintTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.base, _ = setup()
|
||||
|
||||
def test_deterministic_and_comfort_settings_are_not_physical(self):
|
||||
self.assertEqual(setup()[0], self.base)
|
||||
zones = [{'key': 'living', 'name': 'Renamed', 'sensors': ['sensor.l1', 'sensor.l2'], 'comfort_weight': 5,
|
||||
'target_offset': 1.5, 'sensor_weights': {'sensor.l1': 2, 'sensor.l2': 2}, 'neighbors': ['bed']},
|
||||
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b'], 'comfort_weight': 3}]
|
||||
self.assertEqual(differing(self.base, setup(zones)[0]), {}) # equal weights scaled: same average
|
||||
|
||||
def test_measurement_changes_reset_the_whole_zone_and_its_neighbour_term(self):
|
||||
for sensors, weights in ((['sensor.l1', 'sensor.l2'], {'sensor.l1': 3, 'sensor.l2': 1}), # weight ratio
|
||||
(['sensor.l1', 'sensor.l3'], {}), # replaced
|
||||
(['sensor.l1'], {})): # removed
|
||||
with self.subTest(sensors=sensors, weights=weights):
|
||||
zones = [{'key': 'living', 'sensors': sensors, 'sensor_weights': weights, 'neighbors': ['bed']},
|
||||
{'key': 'bed', 'sensors': ['sensor.b']}]
|
||||
diff = differing(self.base, setup(zones)[0])
|
||||
self.assertEqual(set(diff['living']), set(self.base['living']))
|
||||
self.assertEqual(diff['bed'], ['couple:living']) # the bedroom's own model is untouched
|
||||
|
||||
def test_heat_source_changes_reset_only_the_electrical_term(self):
|
||||
moved = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'bed'},
|
||||
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
|
||||
self.assertEqual(differing(self.base, setup(sources=moved)[0]), {'living': ['internal'], 'bed': ['internal']})
|
||||
for change in ({'sensor': 'sensor.ups2'}, {'heat_fraction': 0.5}, {'response_minutes': 30},
|
||||
{'baseline_mode': 'manual', 'baseline_w': 150}):
|
||||
with self.subTest(change=change):
|
||||
sources = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living', **change},
|
||||
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
|
||||
self.assertEqual(differing(self.base, setup(sources=sources)[0]), {'living': ['internal']})
|
||||
|
||||
def test_window_changes_are_selective(self):
|
||||
turned = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 300},
|
||||
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
||||
self.assertEqual(differing(self.base, setup(windows=turned)[0]), {'bed': ['vent_wind_dir']})
|
||||
bigger = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345, 'opening_factor': 2},
|
||||
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
||||
diff = differing(self.base, setup(windows=bigger)[0])
|
||||
self.assertEqual(diff['bed'], ['recovery', 'vent_cross', 'vent_gust', 'vent_natural', 'vent_wind',
|
||||
'vent_wind_dir'])
|
||||
self.assertEqual(diff['living'], ['vent_cross']) # sees the bedroom's windows
|
||||
self.assertEqual(differing(self.base, setup(recovery=40)[0]), {'living': ['recovery'], 'bed': ['recovery']})
|
||||
|
||||
def test_baseline_prints_and_change_detection(self):
|
||||
_, sources = setup()
|
||||
prints = cp.baseline_fingerprints(sources)
|
||||
_, without_pc = setup(sources=[{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'}])
|
||||
self.assertNotEqual(prints['ups'], cp.baseline_fingerprints(without_pc)['ups'])
|
||||
self.assertEqual(cp.changed(None, self.base['living'], ['base']), []) # 0.4.0 data: assumed to match
|
||||
self.assertEqual(cp.changed({'base': 'x'}, self.base['living'], ['base', 'internal']), ['base'])
|
||||
self.assertEqual(cp.reason('couple:bed'), "a neighbouring zone's sensors changed")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1087,3 +1087,109 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1)
|
||||
window = data['windows']['Bedroom window']
|
||||
self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None))
|
||||
|
||||
ZONES = [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.room'], 'neighbors': ['bed']},
|
||||
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.bedroom']}]
|
||||
SOURCES = [{'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'living'}]
|
||||
WINDOWS = [{'key': 'w', 'name': 'Bed window', 'sensor': 'binary_sensor.bed_window', 'zone': 'bed',
|
||||
'azimuth': 345}]
|
||||
|
||||
def configured(self, zones=None, sources=None, windows=None):
|
||||
self.entry.data.update(outside_wind_sources=['sensor.wind'], outside_wind_direction_sources=['sensor.wind_dir'])
|
||||
self.entry.subentries = {}
|
||||
for kind, items in (('zone', zones or self.ZONES), ('heat_source', self.SOURCES if sources is None else sources),
|
||||
('window', self.WINDOWS if windows is None else windows)):
|
||||
for i, data in enumerate(items):
|
||||
self.entry.subentries[f'{kind}{i}'] = types.SimpleNamespace(
|
||||
subentry_id=f'{kind}{i}', subentry_type=kind, data=data)
|
||||
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||
|
||||
def trained_payload(self):
|
||||
c = self.configured()
|
||||
for key in ('living', 'bed'):
|
||||
base = c.learners[key]
|
||||
base.count, base.on_count, base.off_count, base.theta = 300, 100, 100, [0.004, 0.12, 0.001]
|
||||
living = c.extended['living']
|
||||
living.count, living.on_count, living.off_count = 300, 100, 100
|
||||
living.theta = [0.004, 0.12, 0.001, 0.15, 0.008]
|
||||
vent = c.ventilation['bed']
|
||||
vent.count = 80
|
||||
vent.theta[vent.features.index('vent_natural')] = 0.01
|
||||
vent.theta[vent.features.index('vent_wind_dir')] = 0.0005
|
||||
c._evaluator._days = {'2026-10-27': {'60': {'living': [10, 0.1, 0.2, 0.3, 0.4],
|
||||
'bed': [10, 0.1, 0.2, 0.3, 0.4]}}}
|
||||
c._heat_sources._baselines['ups'].samples.extend([200.0] * 24)
|
||||
return c._payload('sensor.outside')
|
||||
|
||||
async def restored(self, payload, **config):
|
||||
c = self.configured(**config)
|
||||
c._store.async_load.return_value = payload
|
||||
await c.async_restore_model()
|
||||
return c
|
||||
|
||||
async def test_comfort_settings_keep_every_learned_coefficient(self):
|
||||
payload = self.trained_payload()
|
||||
zones = [{**self.ZONES[0], 'name': 'Lounge', 'comfort_weight': 4, 'target_offset': 1},
|
||||
{**self.ZONES[1], 'comfort_weight': 0.5}]
|
||||
c = await self.restored(payload, zones=zones)
|
||||
self.assertEqual((c.learners['living'].count, c.extended['living'].theta, c.ventilation['bed'].count),
|
||||
(300, [0.004, 0.12, 0.001, 0.15, 0.008], 80))
|
||||
self.assertEqual(list(c._model_changes), [])
|
||||
c._store.async_save.assert_not_called()
|
||||
|
||||
async def test_changed_zone_sensors_recalibrate_only_that_zone(self):
|
||||
payload = self.trained_payload()
|
||||
zones = [{**self.ZONES[0], 'sensors': ['sensor.room', 'sensor.second']}, self.ZONES[1]]
|
||||
c = await self.restored(payload, zones=zones)
|
||||
base = c.learners['living']
|
||||
self.assertEqual((base.theta, base.count, base.p), ([0.004, 0.12, 0.001], 0, coordinator.Learner().p)) # prior uncertainty
|
||||
living = c.extended['living']
|
||||
self.assertEqual((living.count, living.theta[:3], living.coefficient('internal')), (0, [0.004, 0.12, 0.001], 0.0))
|
||||
# The bedroom keeps its model; only its exchange term with the living room restarts.
|
||||
self.assertEqual((c.learners['bed'].count, c.ventilation['bed'].count), (300, 80))
|
||||
self.assertEqual(c.extended['bed'].coefficient('couple:living'), 0.0)
|
||||
self.assertEqual(set(c._evaluator._days['2026-10-27']['60']), {'bed'}) # living measured differently now
|
||||
change = next(ch for ch in c._model_changes if ch.get('zone') == 'Living')
|
||||
self.assertEqual(change['reasons'], ['zone sensors or sensor weights changed'])
|
||||
# Saved at once with the new fingerprints: another restart changes nothing.
|
||||
saved = c._store.async_save.call_args.args[0]
|
||||
again = await self.restored(saved, zones=zones)
|
||||
self.assertEqual(len(again._model_changes), len(c._model_changes))
|
||||
again._store.async_save.assert_not_called()
|
||||
|
||||
async def test_turned_window_resets_only_the_direction_term(self):
|
||||
payload = self.trained_payload()
|
||||
c = await self.restored(payload, windows=[{**self.WINDOWS[0], 'azimuth': 300}])
|
||||
vent = c.ventilation['bed']
|
||||
self.assertEqual((vent.coefficient('vent_natural'), vent.coefficient('vent_wind_dir'), vent.count),
|
||||
(0.01, 0.0, 80))
|
||||
self.assertEqual((c.learners['bed'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
|
||||
self.assertEqual(c._model_changes[-1]['reasons'], ['window orientation changed'])
|
||||
|
||||
async def test_moved_or_replaced_heat_source(self):
|
||||
payload = self.trained_payload()
|
||||
c = await self.restored(payload, sources=[{**self.SOURCES[0], 'zone': 'bed'}])
|
||||
self.assertEqual(c.extended['living'].coefficient('internal'), 0.0)
|
||||
self.assertEqual(c.extended['living'].coefficient('couple:bed'), 0.008) # unrelated term kept
|
||||
self.assertEqual(c.extended['bed'].coefficient('internal'), 0.0) # new there, from the prior
|
||||
self.assertEqual(len(c._heat_sources._baselines['ups'].samples), 24) # same sensor: same load
|
||||
replaced = await self.restored(payload, sources=[{**self.SOURCES[0], 'sensor': 'sensor.ups2'}])
|
||||
self.assertEqual(len(replaced._heat_sources._baselines['ups'].samples), 0)
|
||||
self.assertEqual(replaced.extended['living'].coefficient('internal'), 0.0)
|
||||
self.assertIn('baseline', replaced._model_changes[-1]['recalibrated'])
|
||||
|
||||
async def test_v040_data_without_fingerprints_is_kept_and_then_fingerprinted(self):
|
||||
payload = self.trained_payload()
|
||||
del payload['fingerprints']
|
||||
c = await self.restored(payload)
|
||||
self.assertEqual((c.learners['living'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
|
||||
# Fingerprinted at once, so an edit right after the upgrade is detected.
|
||||
self.assertEqual(c._store.async_save.call_args.args[0]['fingerprints'], c._fingerprints)
|
||||
|
||||
async def test_re_added_zone_with_other_sensors_is_recalibrated(self):
|
||||
payload = self.trained_payload()
|
||||
without = await self.restored(payload, zones=[self.ZONES[1]], sources=[])
|
||||
self.assertIn('living', without._retired)
|
||||
saved = without._payload('sensor.outside')
|
||||
back = await self.restored(saved, zones=[{**self.ZONES[0], 'sensors': ['sensor.other']}, self.ZONES[1]])
|
||||
self.assertEqual((back.learners['living'].theta, back.learners['living'].count), ([0.004, 0.12, 0.001], 0))
|
||||
Reference in new issue
Block a user