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:
buzeraandClaude Opus 5.5 committed 2026-10-27 03:35:11 +02:00
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)]
+90 -19
View File
@@ -20,6 +20,7 @@ from .const import (
CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST, 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, 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 .data_quality import ReportAgeHistory, freshness
from .evaluation import ForecastEvaluator, model_snapshot from .evaluation import ForecastEvaluator, model_snapshot
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power 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 MAX_RELAY_EVENTS = 256
# Models of zones removed in the UI, kept in case a zone is added back. # Models of zones removed in the UI, kept in case a zone is added back.
MAX_RETIRED_ROOMS = 24 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]: 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.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.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self.sources = entry_source_list(entry, self.zones) 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. # Extended model per zone: base terms plus electrical heat and adjacent zones.
self._extended_features = { self._extended_features = {
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors) 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} for key in self.zones}
self.ventilation: dict[str, FeatureLearner] = { self.ventilation: dict[str, FeatureLearner] = {
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features} 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._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory() self._report_ages = ReportAgeHistory()
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY) 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") _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return return
self._evaluator.load(stored.get("evaluation")) 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 {} saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
for key, features in self._extended_features.items(): for key, features in self._extended_features.items():
if features and key in saved_models: 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(): for key, features in self._ventilation_features.items():
if features and key in saved_vent: if features and key in saved_vent:
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features) self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
# Models carry room identity, so changing the room list is safe. Models of # Models carry zone identity, so changing the zone list is safe. Models of
# rooms no longer configured are retained, most recently removed last. # 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 for key in ("retired_learners", "learners"): # current learners take precedence
saved = stored.get(key) saved = stored.get(key)
if not isinstance(saved, dict): if not isinstance(saved, dict):
@@ -156,14 +170,83 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not isinstance(room, str): if not isinstance(room, str):
continue continue
learner = Learner.from_dict(data) 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: if room in self.learners:
self.learners[room] = learner self.learners[room] = learner
base_prints[room] = fingerprint
elif learner.count: elif learner.count:
self._retired.pop(room, None) 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: while len(self._retired) > MAX_RETIRED_ROOMS:
self._retired.pop(next(iter(self._retired))) self._retired.pop(next(iter(self._retired)))
recalibrated = self._check_compatibility(stored_prints, base_prints)
self._update_counts() 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: def _update_counts(self) -> None:
# Without zones (all deleted in the UI) there is nothing to count. # 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_comparison": {k: v for k, v in weather_comparisons.items() if v},
"weather_model_inputs": ["temperature"], "weather_model_inputs": ["temperature"],
"retired_zone_models": sorted(self._retired), "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], "excluded_zones": [room for room in self.rooms if temps[room] is None],
} }
# Electrical heat sources: processed every poll so their responses stay continuous. # 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) status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned: if learned:
# Persist at each sample so restart does not erase hours of training. # Persist at each sample so restart does not erase hours of training.
await self._store.async_save({ await self._store.async_save(self._payload(active_sources["temperature"]))
"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"],
})
# Comfort weights are validated when zones are parsed (invalid -> 1.0). # Comfort weights are validated when zones are parsed (invalid -> 1.0).
weights = {r: self.zones[r].comfort_weight for r in self.rooms} 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. # 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()}} "rooms": {room: _stats(values) for room, values in per_room.items()}}
return result 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]: def as_dict(self) -> dict[str, Any]:
# Rounded copy: shorter JSON on every save; in-memory sums stay exact. # 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:])] return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:])]
+11 -3
View File
@@ -233,11 +233,18 @@ def forecast_response(response_kw: float, variable_kw: float, steps: int, tau_mi
class HeatSourceTracker: class HeatSourceTracker:
"""Per-poll processing of all heat sources: baselines, responses, zone inputs.""" """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.sources = sources
self.fingerprints = dict(fingerprints or {})
saved = saved if isinstance(saved, Mapping) else {} saved = saved if isinstance(saved, Mapping) else {}
stored = saved.get("baselines") if isinstance(saved.get("baselines"), 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} for s in sources}
self._filters = {s.key: ResponseFilter(s.response_minutes) 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._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW)
@@ -301,4 +308,5 @@ class HeatSourceTracker:
return total return total
def as_dict(self) -> dict[str, Any]: 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}
+27 -1
View File
@@ -15,7 +15,7 @@ displayed standard deviation uses a running estimate of the measurement noise.
from __future__ import annotations from __future__ import annotations
from math import isfinite, sqrt 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 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 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])) bounded[i] = min(limit, max(-limit, bounded[i]))
return bounded 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 @property
def noise_var(self) -> float: def noise_var(self) -> float:
"""Estimated variance of the unexplained 5-minute change (°C²).""" """Estimated variance of the unexplained 5-minute change (°C²)."""
+8
View File
@@ -122,6 +122,14 @@ class Learner:
self.rejected_count += 1 self.rejected_count += 1
return False 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 @property
def mae(self) -> float | None: def mae(self) -> float | None:
return self.error_sum / self.count if self.count else None return self.error_sum / self.count if self.count else None
+1 -1
View File
@@ -36,7 +36,7 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
# Per-source report ages change every poll; recording them adds a new # Per-source report ages change every poll; recording them adds a new
# attribute row to the database every five minutes. Current values stay visible. # attribute row to the database every five minutes. Current values stay visible.
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows", _unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
"ventilation", "ventilation_events"}) "ventilation", "ventilation_events", "model_changes"})
def __init__(self, coordinator, entry_id): def __init__(self, coordinator, entry_id):
super().__init__(coordinator) super().__init__(coordinator)
+22
View File
@@ -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"]) add_zone(hass, entry, "sensor.room", ["sensor.room"])
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning" 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"]
+88
View File
@@ -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()
+106
View File
@@ -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) self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1)
window = data['windows']['Bedroom window'] window = data['windows']['Bedroom window']
self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None)) 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))