diff --git a/custom_components/heating_mpc/compatibility.py b/custom_components/heating_mpc/compatibility.py new file mode 100644 index 0000000..00cd6ae --- /dev/null +++ b/custom_components/heating_mpc/compatibility.py @@ -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:": 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)] diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 62dbd85..460cff8 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -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. diff --git a/custom_components/heating_mpc/evaluation.py b/custom_components/heating_mpc/evaluation.py index f946491..16fffa3 100644 --- a/custom_components/heating_mpc/evaluation.py +++ b/custom_components/heating_mpc/evaluation.py @@ -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:])] diff --git a/custom_components/heating_mpc/heat_sources.py b/custom_components/heating_mpc/heat_sources.py index 926d631..478157d 100644 --- a/custom_components/heating_mpc/heat_sources.py +++ b/custom_components/heating_mpc/heat_sources.py @@ -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} diff --git a/custom_components/heating_mpc/learning.py b/custom_components/heating_mpc/learning.py index 716fef1..20235b4 100644 --- a/custom_components/heating_mpc/learning.py +++ b/custom_components/heating_mpc/learning.py @@ -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²).""" diff --git a/custom_components/heating_mpc/physics.py b/custom_components/heating_mpc/physics.py index fb863ad..6a15dde 100644 --- a/custom_components/heating_mpc/physics.py +++ b/custom_components/heating_mpc/physics.py @@ -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 diff --git a/custom_components/heating_mpc/sensor.py b/custom_components/heating_mpc/sensor.py index aa8f8e8..321bce3 100644 --- a/custom_components/heating_mpc/sensor.py +++ b/custom_components/heating_mpc/sensor.py @@ -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) diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index 28a05a7..d4edf6f 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -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"] diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 0000000..f269b71 --- /dev/null +++ b/tests/test_compatibility.py @@ -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() diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 31d70f9..8d22a59 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -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))