Fix shadow observer lifecycle, learning and prediction correctness

This commit is contained in:
buzera committed 2026-10-26 17:50:20 +02:00
1 parent 2b9a10979b
commit c434f3995d
10 files changed
+591 -30

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
# v0.2.1 source audit — 2026-10-26
Scope: repository implementation, offline tests and documented HA APIs. No HAOS,
physical relay, thermostat, deployment or gas-consumption testing was performed.
The manifest remains 0.2.1; these are unreleased reliability fixes, not a release
acceptance declaration. Milestone 0 remains open.
## Findings and repairs
| Area | Baseline finding | Repair / evidence |
|---|---|---|
| Lifecycle, high | Config flow VERSION=2 had no migration hook; the weather helper's legacy fallback alone cannot migrate HA entries. | Migrate v1 data and options independently through HA's update API, retaining options precedence and existing prioritized selections. Reject newer config versions. |
| Lifecycle, medium | Removed room forecast entities remained in the registry after options reload. | Remove only obsolete room forecast IDs for this integration and entry; retain all other entities and compatible IDs. |
| Lifecycle, medium | Raised platform setup left the coordinator in hass.data. | Remove that reference on failure. HA's own partially loaded platform cleanup still requires real runtime testing. |
| Persistence, high | Malformed roots/counters could crash restore; finite but nonphysical coefficients and invalid covariance could be accepted. | Validate shapes, coefficient bounds, symmetric positive-definite covariance, integer counts and finite error totals; reset malformed rooms atomically. |
| Learning, high | Global ON/OFF counts increased even when fitting rejected outliers and could qualify a newly added room using other rooms' history. | Persist accepted ON/OFF counts per room; readiness requires every room to qualify. Retain compatible legacy coefficients/counts but collect fresh excitation evidence. |
| Learning, high | A 4–7 minute interval was treated as exactly 5 minutes, including intervals containing relay transitions. | Accept 270–330 seconds, normalize temperature change to five minutes, compute lag using actual elapsed time; reject gaps/backward time, source changes and relay changes since the previous poll (including on/off round trips). |
| Inputs, high | Thermostat targets were interpreted as Celsius even in a Fahrenheit HA installation. | Normalize using thermostat temperature_unit when present, otherwise HA's configured temperature unit. |
| Inputs, medium | Invalid weights or malformed unit metadata could cause crashes/nonfinite arithmetic. | Reject invalid weights with unavailable proposal; reject non-string units as unusable sources. |
| Prediction, high | Inputs could change while saving or optimizing, yet the old plan could be published as ready. | Compare captured HA State snapshots before publishing; discard results when any watched state changes. |
| Prediction, medium | A 30/45-minute pure optimizer horizon returned its endpoint as a 60-minute forecast. | Return no 60-minute forecast for horizons below 60. UI already limits horizons to at least 60. Replay tests check the actual +60 and endpoint values. |
| Prediction, medium | NaN weights/lag and negative/nonfinite optimizer parameters were not rejected consistently. | Add finite/range validation and regression tests. |
The initial 10 tests passed. The expanded suite contains 33 offline tests covering
model/weather logic plus coordinator and integration entry points using small HA
boundary doubles. The doubles check our calls and state handling; they do not
exercise HA scheduling, Store internals, entity registration or actual config forms.
The test service-call spy raises if invoked. Source inspection found no actuator
service calls, service registration, relay automations or network model inference.
## Remaining gaps, in priority order
1. **Freshness and alignment:** numeric but stale room/outdoor states are still
accepted. Define configurable freshness limits and reporting timestamps in the
UI, distinguish unchanged values from missing reports, expose rejection reasons,
and test fallback/recovery. Missing units still assume canonical units for
compatibility. Optional weather source lists lack uniform maximum-size checks.
2. **Real HA test harness and HAOS acceptance:** config/options form submission,
reload listener/poller teardown, disabled entities, partial platform setup failure,
actual storage failures, restart/uninstall and registry cleanup need tests against
a declared supported HA version. Add CI, translation/schema and packaging checks.
Existing en/ro flow strings are unchanged by this patch.
3. **Sampling and identification:** polling detects a relay change through
last_changed and skips that interval; it does not integrate the relay duty cycle.
Restart/gaps reset the lag proxy to the current command, which is only an
approximation. Use timestamped relay events and aligned sensor observations
before claiming robust radiator identification. Rejecting transition intervals
can slow learning. The jitter correction is a local linear approximation.
4. **Persistence and identity:** Store envelope version stays 1; the additive model
payload is schema_version=2. Legacy payloads remain readable. Unsupported payload
schemas are ignored; comprehensive future-version downgrade protection remains
open. Renamed room entity IDs are treated as replacements (fresh model), not
automatically matched to entity-registry identity. Same-ID physical sensor
relocation and systematic weather provider offsets remain undetected.
5. **Forecast validity:** forecasts remain counterfactual MPC plans with constant
outdoor temperature. Five-minute residual MAE is not held-out multi-horizon
accuracy. No actual-policy forecast, recorded trajectories, confidence estimate,
identifiability test or persistence benchmark exists. Prediction updates remain
five-minute polls; a later manual change does not instantly refresh the diagnostic.
6. **Resource use:** optimization is offloaded, but no Pi 5 benchmark exists.
Model state is written on each eligible interval (roughly 288 writes/day).
Rejected-fit reasons and aggregate failure counters are not yet exposed.
## Compatibility and safe trial
Back up HA before installing. Replace the component directory and restart Core;
keep Generic Thermostat as the only relay writer. Compatible room coefficients and
forecast unique IDs remain stable. Older saved models need at least 30 newly
accepted ON and 30 OFF samples per room before becoming ready again, plus the
existing 200-total-sample condition. No artificial heating cycles are generated.
Observe status, normalized target, per-room model counts and weather provenance.
Test manual heat/off and preset changes, a missing sensor, an options reload, room
removal and a restart. The proposed-heat entity must remain diagnostic only. Use a
disposable config entry for uninstall testing because deletion removes its models.
For rollback, restore the component and HA backup together; older code ignores the
new per-room counters and retains its original defects. No installation was done
as part of this audit.
## Verification commands
- `python3 -m unittest discover -s tests -v` — 33 tests pass (Python 3.9.6).
- `PYTHONPYCACHEPREFIX=/tmp/heating-mpc-audit-pycache python3 -m compileall -q custom_components/heating_mpc` — passes.
- `git diff --check` — passes.
The initial unredirected compile check failed because macOS's Python cache path
was outside the writable sandbox; the temporary-cache rerun succeeded. No full HA
suite, type checker, linter, HAOS or physical-device checks were run.
API references used to check migration and entity-registry calls:
[HA config entries](https://developers.home-assistant.io/docs/config_entries_index/),
[config migration](https://developers.home-assistant.io/docs/config_entries_config_flow_handler/#config-entry-migration),
[entity registry](https://contributors.home-assistant.io/core/integrations/entity-registry/).
+25
View File
@@ -73,3 +73,28 @@ python -m unittest discover -s tests -v
- Corrects the manifest classification from `helper` to `service`. Existing config entries should move from the Helpers listing to **Settings → Devices & services → Integrations** after replacing the files and restarting Home Assistant Core. No deletion/reconfiguration is needed to upgrade. - Corrects the manifest classification from `helper` to `service`. Existing config entries should move from the Helpers listing to **Settings → Devices & services → Integrations** after replacing the files and restarting Home Assistant Core. No deletion/reconfiguration is needed to upgrade.
- When the integration config entry is deleted from the Integrations page, Home Assistant removes its registered entities, and `async_remove_entry` removes the locally persisted learned-model data (`.storage/heating_mpc.<entry_id>`). The custom component source folder remains on disk until manually removed. - When the integration config entry is deleted from the Integrations page, Home Assistant removes its registered entities, and `async_remove_entry` removes the locally persisted learned-model data (`.storage/heating_mpc.<entry_id>`). The custom component source folder remains on disk until manually removed.
- MPC behavior is otherwise unchanged and remains read-only / shadow-only. - MPC behavior is otherwise unchanged and remains read-only / shadow-only.
## Unreleased: v0.2.1 reliability audit
See [AUDIT.md](AUDIT.md) for findings, fixes, test evidence and remaining gaps.
No active control was added; the manifest version has not been advanced.
- Fix config-entry v1 migration, obsolete room forecast cleanup and coordinator
cleanup after a raised platform setup error.
- Validate saved models atomically and add an explicit payload schema version.
Compatible legacy coefficients survive; readiness now requires at least 30 new
accepted ON and 30 OFF samples **per room** when loading legacy models without
per-room counters. The existing 200-total-sample requirement also applies.
- Reject intervals containing relay changes or outside-provider changes. Accept
only 270–330-second intervals and normalize small timing differences. This can
slow learning; it never generates extra heating cycles.
- Normalize thermostat targets to Celsius; reject invalid weights and optimizer
parameters. Status can report `invalid_weights` or `inputs_changed`; neither
publishes a proposal. Input changes during calculation discard the prediction.
- Model diagnostics include each room's accepted total/ON/OFF sample counts.
Predictions remain **counterfactual**: the proposed plan is not the actual
Generic Thermostat's future command sequence.
Offline lifecycle tests use HA doubles. Freshness policies, actual HA/HAOS lifecycle
validation and held-out prediction accuracy remain unfinished. Back up before
trying these changes; follow the safe trial and rollback notes in AUDIT.md.
+15 -1
View File
@@ -39,7 +39,7 @@ Current behavior: **shadow mode only**. `generic_thermostat` remains the one and
**Objective:** Make the existing observer trustworthy before extending it. **Objective:** Make the existing observer trustworthy before extending it.
- [ ] Perform a source audit of v0.2.1: pure model, optimizer, config/options, coordinator update timing, persistent model and entity lifecycle. - [x] Perform a source audit of v0.2.1: pure model, optimizer, config/options, coordinator update timing, persistent model and entity lifecycle.
- [ ] Add Home Assistant test harness coverage for initial setup, update, restart/reload, options changes, missing sensors, `heat/off`, target changes, teardown and uninstall. - [ ] Add Home Assistant test harness coverage for initial setup, update, restart/reload, options changes, missing sensors, `heat/off`, target changes, teardown and uninstall.
- [ ] Verify that **zero physical-device service calls** occur in observation mode, including restart and failed update cases. - [ ] Verify that **zero physical-device service calls** occur in observation mode, including restart and failed update cases.
- [ ] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples. - [ ] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples.
@@ -52,6 +52,20 @@ Current behavior: **shadow mode only**. `generic_thermostat` remains the one and
**Exit criteria:** No control writes; reproducible tests covering HA lifecycle; clean upgrade/removal; clear status during outages; no significant model-data loss or silent corruption on restart. **Exit criteria:** No control writes; reproducible tests covering HA lifecycle; clean upgrade/removal; clear status during outages; no significant model-data loss or silent corruption on restart.
### Audit update — 2026-10-26
See [AUDIT.md](AUDIT.md). The source audit and focused repairs are complete;
Milestone 0 acceptance is **not** complete. Offline boundary doubles cover setup,
reload/unload, migration, removal, corrupt storage, missing inputs and manual off.
They are not a full HA test harness. Per-room accepted ON/OFF counts, additive
payload schema v2, conservative relay-transition rejection, jitter normalization,
Fahrenheit targets and forecast input-change guards are implemented.
Next implement configurable freshness/alignment with diagnostics and a supported
HA test harness/CI. Then improve event-based sampling and identity/persistence
migration tests. Only after those reliability checks proceed to Milestone 1's
recorded forecasts and walk-forward evaluation. Live control remains unauthorized.
## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0) ## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0)
**Objective:** Users can verify what the model predicts and whether it is actually accurate. **Objective:** Users can verify what the model predicts and whether it is actually accurate.
+34 -1
View File
@@ -5,8 +5,10 @@ from homeassistant.const import Platform
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN from .const import DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
from .weather_sources import sources_for
from .coordinator import HeatingMPCCoordinator from .coordinator import HeatingMPCCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
@@ -17,7 +19,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_restore_model() await coordinator.async_restore_model()
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
try:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except Exception:
hass.data[DOMAIN].pop(entry.entry_id, None)
raise
# Remove only obsolete room forecasts belonging to this entry/platform.
registry = er.async_get(hass)
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms}
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
if (entity.platform == DOMAIN and
entity.unique_id.startswith(f"{entry.entry_id}_sensor.") and
entity.unique_id.endswith("_predicted_60min") and
entity.unique_id not in expected):
registry.async_remove(entity.entity_id)
entry.async_on_unload(entry.add_update_listener(async_reload_entry)) entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True return True
@@ -37,3 +52,21 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove the learned model when this integration instance is deleted.""" """Remove the learned model when this integration instance is deleted."""
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
await store.async_remove() await store.async_remove()
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate legacy single weather source without changing user selections."""
if entry.version > 2:
return False
if entry.version == 1:
def migrate(config):
result = dict(config)
if CONF_OUTSIDE in result:
result[WEATHER_SOURCES["temperature"]] = sources_for(result, "temperature")
result.pop(CONF_OUTSIDE)
return result
hass.config_entries.async_update_entry(
entry, data=migrate(entry.data), options=migrate(entry.options),
version=2, minor_version=1,
)
return True
+41 -14
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import partial from functools import partial
from math import isfinite from math import exp, isfinite
from typing import Any from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -17,7 +17,7 @@ from .const import (
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
CONF_BAND, DEFAULTS, CONF_BAND, DEFAULTS,
) )
from .physics import HEAT_LAG, Learner, optimize from .physics import Learner, optimize
from .weather_sources import choose_weather_sources, sources_for, normalize from .weather_sources import choose_weather_sources, sources_for, normalize
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -40,17 +40,26 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def async_restore_model(self) -> None: async def async_restore_model(self) -> None:
stored = await self._store.async_load() or {} stored = await self._store.async_load() or {}
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
_LOGGER.warning("Ignoring unsupported Heating MPC model data")
return
# Replacing the actual heating system invalidates heating coefficients. # Replacing the actual heating system invalidates heating coefficients.
# Switching among weather providers does not discard months of learning. # Switching among weather providers does not discard months of learning.
if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]: if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]:
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return return
# Models carry room identity, so changing the room list is safe. # Models carry room identity, so changing the room list is safe.
for room, data in stored.get("learners", {}).items(): saved = stored.get("learners", {})
if not isinstance(saved, dict):
return
for room, data in saved.items():
if room in self.learners: if room in self.learners:
self.learners[room] = Learner.from_dict(data) self.learners[room] = Learner.from_dict(data)
self._on_samples = max(0, int(stored.get("on_samples", 0))) self._update_counts()
self._off_samples = max(0, int(stored.get("off_samples", 0)))
def _update_counts(self) -> None:
self._on_samples = min(m.on_count for m in self.learners.values())
self._off_samples = min(m.off_count for m in self.learners.values())
def _value(self, entity_id: str, low: float, high: float) -> float | None: def _value(self, entity_id: str, low: float, high: float) -> float | None:
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
@@ -71,6 +80,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
entity_id for metric in WEATHER_SOURCES entity_id for metric in WEATHER_SOURCES
for entity_id in sources_for(self.config, metric) for entity_id in sources_for(self.config, metric)
} }
watched = set(self.rooms) | weather_entity_ids | {
self.config[CONF_CLIMATE], self.config[CONF_HEATER]}
snapshot = {entity: self.hass.states.get(entity) for entity in watched}
weather_states = { weather_states = {
entity_id: (state.state, state.attributes.get("unit_of_measurement")) entity_id: (state.state, state.attributes.get("unit_of_measurement"))
for entity_id in weather_entity_ids for entity_id in weather_entity_ids
@@ -109,21 +121,21 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._previous is not None: if self._previous is not None:
dt = (now - self._previous["time"]).total_seconds() dt = (now - self._previous["time"]).total_seconds()
# Do not fit one transition across a provider/fallback switch. # Do not fit one transition across a provider/fallback switch.
if (240 <= dt <= 420 and if (270 <= dt <= 330 and
heater.last_changed <= self._previous["time"] and
heater_on == self._previous["heater_on"] and
self._previous["outside_source"] == active_sources["temperature"]): self._previous["outside_source"] == active_sources["temperature"]):
next_lag = self._heat_lag + HEAT_LAG * (float(self._previous["heater_on"]) - self._heat_lag) next_lag = self._heat_lag + (1 - exp(-dt / 1200)) * (float(self._previous["heater_on"]) - self._heat_lag)
for room in self.rooms: for room in self.rooms:
self.learners[room].learn( self.learners[room].learn(
self._previous["temps"][room], vals[room], self._previous["temps"][room], vals[room],
self._previous["outside"], next_lag self._previous["outside"], next_lag, dt, self._previous["heater_on"]
) )
self._heat_lag = next_lag self._heat_lag = next_lag
if self._previous["heater_on"]: self._update_counts()
self._on_samples += 1
else:
self._off_samples += 1
# 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({
"schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()}, "learners": {room: learner.as_dict() for room, learner in self.learners.items()},
"on_samples": self._on_samples, "off_samples": self._off_samples, "on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER], "heater_entity": self.config[CONF_HEATER],
@@ -138,7 +150,13 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"outside_source": active_sources["temperature"], "outside_source": active_sources["temperature"],
} }
weights = self.config.get(CONF_WEIGHTS, {}) weights = self.config.get(CONF_WEIGHTS, {})
try:
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms} weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
if any(not isfinite(w) or w <= 0 for w in weights.values()) or not isfinite(sum(weights.values())):
raise ValueError
except (AttributeError, TypeError, ValueError, OverflowError):
status["status"] = "invalid_weights"
return status
weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values()) weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values())
status.update({ status.update({
"weighted_temperature": round(weighted_temp, 3), "weighted_temperature": round(weighted_temp, 3),
@@ -151,6 +169,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
room: {"loss": round(model.theta[0], 6), room: {"loss": round(model.theta[0], 6),
"heat": round(model.theta[1], 6), "heat": round(model.theta[1], 6),
"bias": round(model.theta[2], 6), "bias": round(model.theta[2], 6),
"samples": model.count,
"on_samples": model.on_count, "off_samples": model.off_count,
"mae_5m": round(model.mae, 4) if model.mae is not None else None} "mae_5m": round(model.mae, 4) if model.mae is not None else None}
for room, model in self.learners.items() for room, model in self.learners.items()
}, },
@@ -159,8 +179,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
status["status"] = "paused" status["status"] = "paused"
return status return status
try: try:
target = float(climate.attributes["temperature"]) target = normalize("temperature", climate.attributes["temperature"],
if not isfinite(target) or not 5 <= target <= 35: climate.attributes.get("temperature_unit",
self.hass.config.units.temperature_unit))
if target is None or not 5 <= target <= 35:
raise ValueError raise ValueError
except (TypeError, KeyError, ValueError): except (TypeError, KeyError, ValueError):
status["status"] = "waiting_for_target" status["status"] = "waiting_for_target"
@@ -184,7 +206,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
heating_cost=float(self.config[CONF_HEAT_COST]), heating_cost=float(self.config[CONF_HEAT_COST]),
comfort_band=float(self.config[CONF_BAND]), comfort_band=float(self.config[CONF_BAND]),
) )
# HA State objects are immutable snapshots. Discard a result if any
# input changed while the worker was calculating the hypothetical plan.
output = await self.hass.async_add_executor_job(job) output = await self.hass.async_add_executor_job(job)
if any(self.hass.states.get(entity) is not state for entity, state in snapshot.items()):
status["status"] = "inputs_changed"
return status
except (ValueError, RuntimeError, OverflowError) as err: except (ValueError, RuntimeError, OverflowError) as err:
_LOGGER.warning("MPC prediction failed: %s", err) _LOGGER.warning("MPC prediction failed: %s", err)
status["status"] = "prediction_error" status["status"] = "prediction_error"
+58 -14
View File
@@ -26,25 +26,32 @@ class Learner:
p: list[list[float]] = field(default_factory=lambda: [[0.0005, 0., 0.], [0., 0.8, 0.], [0., 0., 0.08]]) p: list[list[float]] = field(default_factory=lambda: [[0.0005, 0., 0.], [0., 0.8, 0.], [0., 0., 0.08]])
count: int = 0 count: int = 0
error_sum: float = 0.0 error_sum: float = 0.0
on_count: int = 0
off_count: int = 0
def predict_delta(self, temp: float, outside: float, heat: float) -> float: def predict_delta(self, temp: float, outside: float, heat: float) -> float:
a, b, c = self.theta a, b, c = self.theta
return a * (outside - temp) + b * heat + c return a * (outside - temp) + b * heat + c
def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float) -> None: def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float,
duration_seconds: float = 300, command: bool | None = None) -> bool:
if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)): if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)):
return return False
y = temp_after - temp_before if not 0 <= effective_heat <= 1:
return False
if not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330:
return False
y = (temp_after - temp_before) * 300 / duration_seconds
if abs(y) > 0.8: # sensor glitch/door opened/restarted device if abs(y) > 0.8: # sensor glitch/door opened/restarted device
return return False
x = [outside - temp_before, effective_heat, 1.0] x = [outside - temp_before, effective_heat, 1.0]
predicted = sum(t * v for t, v in zip(self.theta, x)) predicted = sum(t * v for t, v in zip(self.theta, x))
residual = y - predicted residual = y - predicted
px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)] px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)]
forgetting = 0.999 forgetting = 0.999
denominator = forgetting + sum(x[i] * px[i] for i in range(3)) denominator = forgetting + sum(x[i] * px[i] for i in range(3))
if denominator <= 0: if not isfinite(denominator) or denominator <= 0:
return return False
k = [value / denominator for value in px] k = [value / denominator for value in px]
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)] self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
for i in range(3): for i in range(3):
@@ -58,29 +65,57 @@ class Learner:
] ]
self.count += 1 self.count += 1
self.error_sum += abs(residual) self.error_sum += abs(residual)
if command is not None:
if command:
self.on_count += 1
else:
self.off_count += 1
return True
@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
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum} return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
"on_count": self.on_count, "off_count": self.off_count}
@classmethod @classmethod
def from_dict(cls, source: dict[str, Any]) -> "Learner": def from_dict(cls, source: dict[str, Any]) -> "Learner":
obj = cls() obj = cls()
try: try:
if not isinstance(source, dict):
return obj
theta = [float(v) for v in source["theta"]] theta = [float(v) for v in source["theta"]]
p = [[float(v) for v in r] for r in source["p"]] p = [[float(v) for v in r] for r in source["p"]]
if len(theta) != 3 or len(p) != 3 or any(len(r) != 3 for r in p): if len(theta) != 3 or len(p) != 3 or any(len(r) != 3 for r in p):
return obj return obj
if not all(isfinite(v) for v in theta + [v for row in p for v in row]): if not all(isfinite(v) for v in theta + [v for row in p for v in row]):
return obj return obj
obj.theta, obj.p = theta, p if not (0 <= theta[0] <= .04 and 0 <= theta[1] <= .25 and -.06 <= theta[2] <= .06):
obj.count = max(0, int(source.get("count", 0))) return obj
obj.error_sum = max(0., float(source.get("error_sum", 0))) # A covariance must be symmetric positive definite (Cholesky check).
except (KeyError, TypeError, ValueError): lower = [[0.0] * 3 for _ in range(3)]
return cls() for i in range(3):
for j in range(i + 1):
if abs(p[i][j] - p[j][i]) > 1e-8:
return obj
value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
if i == j:
if value <= 0 or not isfinite(value):
return obj
lower[i][j] = value ** .5
else:
lower[i][j] = value / lower[j][j]
counts = [source.get(key, 0) for key in ("count", "on_count", "off_count")]
if any(type(v) is not int or v < 0 for v in counts):
return obj
count, on_count, off_count = counts
error_sum = float(source.get("error_sum", 0))
if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count:
return obj
return cls(theta, p, count, error_sum, on_count, off_count)
except (KeyError, TypeError, ValueError, OverflowError):
return obj return obj
@@ -112,9 +147,18 @@ def optimize(
raise ValueError("Invalid temperature") raise ValueError("Invalid temperature")
if any(not isfinite(t) for t in temps.values()): if any(not isfinite(t) for t in temps.values()):
raise ValueError("Invalid room temperature") raise ValueError("Invalid room temperature")
if any(float(weights.get(room, 0)) <= 0 for room in temps): if any(not isfinite(float(weights.get(room, 0))) or float(weights.get(room, 0)) <= 0 for room in temps):
raise ValueError("All rooms need a positive weight") raise ValueError("All rooms need a positive weight")
if not isfinite(effective_heat) or not 0 <= effective_heat <= 1:
raise ValueError("Invalid heating lag")
if any(not isfinite(v) or v < 0 for v in (
seconds_since_change, minimum_on_minutes, minimum_off_minutes,
switching_cost, heating_cost, comfort_band,
)):
raise ValueError("Invalid optimizer parameter")
total_weight = sum(float(weights[room]) for room in temps) total_weight = sum(float(weights[room]) for room in temps)
if not isfinite(total_weight):
raise ValueError("Invalid total weight")
norm = {room: float(weights[room]) / total_weight for room in temps} norm = {room: float(weights[room]) / total_weight for room in temps}
blocks = horizon_minutes // 15 blocks = horizon_minutes // 15
nsteps_per_block = 3 nsteps_per_block = 3
@@ -156,7 +200,7 @@ def optimize(
elapsed_s += 15 * 60 elapsed_s += 15 * 60
if feasible and cost < best: if feasible and cost < best:
best = cost best = cost
result = (bits, at_60 or dict(state), dict(state)) result = (bits, at_60 or {}, dict(state))
if result is None: if result is None:
raise RuntimeError("No feasible schedule") raise RuntimeError("No feasible schedule")
plan, temps_60m, temps_end = result plan, temps_60m, temps_end = result
@@ -44,6 +44,8 @@ def normalize(metric: str, raw: object, unit: str | None = None) -> float | None
return None return None
if not isfinite(value): if not isfinite(value):
return None return None
if unit is not None and not isinstance(unit, str):
return None
unit = (unit or "").strip().lower().replace(" ", "") unit = (unit or "").strip().lower().replace(" ", "")
if metric in ("temperature", "feels_like"): if metric in ("temperature", "feels_like"):
if unit in ("°f", "f", "fahrenheit"): if unit in ("°f", "f", "fahrenheit"):
+273
View File
@@ -0,0 +1,273 @@
"""Offline HA boundary doubles; these do not validate the HA runtime itself."""
import importlib.util
import sys
import types
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
class CoordinatorBase:
@classmethod
def __class_getitem__(cls, item):
return cls
def __init__(self, hass, *args, **kwargs):
self.hass = hass
async def async_config_entry_first_refresh(self):
self.data = await self._async_update_data()
class Store:
def __init__(self, *args):
self.async_load = AsyncMock(return_value=None)
self.async_save = AsyncMock()
self.async_remove = AsyncMock()
def load_integration():
modules = {name: types.ModuleType(name) for name in (
'homeassistant', 'homeassistant.const', 'homeassistant.core',
'homeassistant.config_entries', 'homeassistant.helpers',
'homeassistant.helpers.storage', 'homeassistant.helpers.update_coordinator',
'homeassistant.helpers.entity_registry')}
modules['homeassistant.const'].Platform = types.SimpleNamespace(SENSOR='sensor', BINARY_SENSOR='binary_sensor')
modules['homeassistant.core'].HomeAssistant = object
modules['homeassistant.config_entries'].ConfigEntry = object
modules['homeassistant.helpers.storage'].Store = Store
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
registry = modules['homeassistant.helpers.entity_registry']
registry.async_get = lambda hass: hass.registry
registry.async_entries_for_config_entry = lambda reg, entry: reg.entries
name = 'audit_heating_mpc'
spec = importlib.util.spec_from_file_location(name, ROOT / '__init__.py', submodule_search_locations=[str(ROOT)])
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
with patch.dict(sys.modules, modules):
spec.loader.exec_module(module)
return module, sys.modules[name + '.coordinator']
integration, coordinator = load_integration()
class BoundaryTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.now = datetime.now(timezone.utc)
self.states = {}
self.hass = types.SimpleNamespace(
data={}, states=types.SimpleNamespace(get=self.states.get),
config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')),
services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))),
registry=types.SimpleNamespace(entries=[], async_remove=Mock()),
config_entries=types.SimpleNamespace(
async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True),
async_reload=AsyncMock(), async_update_entry=Mock()),
)
async def execute(job):
return job()
self.hass.async_add_executor_job = execute
self.entry = types.SimpleNamespace(entry_id='entry', version=2,
data={'room_entities': ['sensor.room'], 'heater_entity': 'switch.boiler',
'climate_entity': 'climate.reference', 'outside_temperature_sources': ['sensor.outside'],
'horizon_minutes': 60}, options={}, async_on_unload=Mock(), add_update_listener=Mock())
self.set_state('sensor.room', '20', unit_of_measurement='°C')
self.set_state('sensor.outside', '5', unit_of_measurement='°C')
self.set_state('switch.boiler', 'off')
self.set_state('climate.reference', 'heat', temperature=22)
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
def set_state(self, entity, value, **attrs):
self.states[entity] = types.SimpleNamespace(state=value, attributes=attrs,
last_changed=self.now - timedelta(hours=1))
async def sample(self, seconds=300):
with patch.object(coordinator, 'datetime') as clock:
clock.now.return_value = self.now
data = await self.c._async_update_data()
self.now += timedelta(seconds=seconds)
return data
def ready(self):
m = self.c.learners['sensor.room']
m.count, m.on_count, m.off_count = 200, 100, 100
self.c._update_counts()
async def test_learning_persistence_and_restart(self):
await self.sample()
await self.sample()
self.assertEqual(self.c.learners['sensor.room'].count, 1)
payload = self.c._store.async_save.call_args.args[0]
self.assertEqual(payload['schema_version'], 2)
new = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
new._store.async_load.return_value = payload
await new.async_restore_model()
self.assertEqual(new._off_samples, 1)
self.assertIsNone(new._previous)
async def test_transition_and_round_trip_switch_are_skipped(self):
for new_state in ('on', 'off'):
self.c._previous = None
await self.sample()
self.states['switch.boiler'].state = new_state
self.states['switch.boiler'].last_changed = self.now - timedelta(seconds=60)
await self.sample()
self.assertEqual(self.c.learners['sensor.room'].count, 0)
async def test_gaps_backward_clock_and_excessive_jitter_skipped(self):
for seconds in (0, -300, 100, 420, 3600):
self.c._previous = None
await self.sample(seconds)
await self.sample()
self.assertEqual(self.c.learners['sensor.room'].count, 0)
async def test_missing_sensor_breaks_training_continuity(self):
await self.sample()
self.states['sensor.room'].state = 'unavailable'
self.assertEqual((await self.sample())['status'], 'waiting_for_sensors')
self.states['sensor.room'].state = '20'
await self.sample()
self.assertEqual(self.c.learners['sensor.room'].count, 0)
async def test_weather_fallback_skips_transition(self):
await self.sample()
self.c.config['outside_temperature_sources'].append('sensor.alternate')
self.set_state('sensor.alternate', '7', unit_of_measurement='°C')
self.states['sensor.outside'].state = 'unavailable'
data = await self.sample()
self.assertEqual(data['outside_temperature'], 7)
self.assertEqual(data['samples'], 0)
async def test_outlier_does_not_count_towards_readiness(self):
await self.sample()
self.states['sensor.room'].state = '25'
data = await self.sample()
self.assertEqual(data['samples'], 0)
self.assertEqual(data['off_samples'], 0)
async def test_fahrenheit_target_and_manual_off(self):
self.ready()
self.hass.config.units.temperature_unit = '°F'
self.states['climate.reference'].attributes['temperature'] = 71.6
data = await self.sample()
self.assertEqual(data['target'], 22)
self.assertEqual(data['status'], 'ready')
self.states['climate.reference'].state = 'off'
data = await self.sample()
self.assertEqual(data['status'], 'paused')
self.assertIsNone(data['proposal'])
self.assertEqual(self.states['climate.reference'].attributes['temperature'], 71.6)
self.hass.services.async_call.assert_not_called()
async def test_invalid_target_and_optimizer_failure(self):
self.ready()
self.states['climate.reference'].attributes['temperature'] = 'nan'
self.assertEqual((await self.sample())['status'], 'waiting_for_target')
self.states['climate.reference'].attributes['temperature'] = 22
with patch.object(coordinator, 'optimize', side_effect=RuntimeError('test')):
data = await self.sample()
self.assertEqual(data['status'], 'prediction_error')
self.assertIsNone(data['proposal'])
async def test_corrupt_storage_and_legacy_counters(self):
for payload in ([], 'broken', {'schema_version': 999}, {'learners': []},
{'on_samples': 'bad', 'off_samples': float('inf')}):
self.c._store.async_load.return_value = payload
await self.c.async_restore_model()
self.assertEqual(self.c._on_samples, 0)
old = self.c.learners['sensor.room'].as_dict()
old.update(count=500)
old.pop('on_count'); old.pop('off_count')
self.c._store.async_load.return_value = {'learners': {'sensor.room': old}, 'on_samples': 250}
await self.c.async_restore_model()
self.assertEqual(self.c.learners['sensor.room'].count, 500)
self.assertEqual(self.c._on_samples, 0)
async def test_replaced_heater_invalidates_model(self):
self.c._store.async_load.return_value = {'heater_entity': 'switch.other',
'learners': {'sensor.room': {'count': 999}}}
await self.c.async_restore_model()
self.assertEqual(self.c.learners['sensor.room'].count, 0)
async def test_setup_reload_unload_and_registry_cleanup(self):
obsolete = types.SimpleNamespace(platform='heating_mpc', unique_id='entry_sensor.old_predicted_60min', entity_id='sensor.old_forecast')
kept = types.SimpleNamespace(platform='heating_mpc', unique_id='entry_sensor.room_predicted_60min', entity_id='sensor.forecast')
self.hass.registry.entries = [obsolete, kept]
self.assertTrue(await integration.async_setup_entry(self.hass, self.entry))
self.hass.registry.async_remove.assert_called_once_with('sensor.old_forecast')
await integration.async_reload_entry(self.hass, self.entry)
self.hass.config_entries.async_reload.assert_awaited_once_with('entry')
self.hass.config_entries.async_unload_platforms.return_value = False
self.assertFalse(await integration.async_unload_entry(self.hass, self.entry))
self.assertIn('entry', self.hass.data['heating_mpc'])
self.hass.config_entries.async_unload_platforms.return_value = True
self.assertTrue(await integration.async_unload_entry(self.hass, self.entry))
self.assertNotIn('entry', self.hass.data['heating_mpc'])
self.hass.services.async_call.assert_not_called()
async def test_failed_platform_setup_removes_coordinator(self):
self.hass.config_entries.async_forward_entry_setups.side_effect = RuntimeError('test')
with self.assertRaises(RuntimeError):
await integration.async_setup_entry(self.hass, self.entry)
self.assertNotIn('entry', self.hass.data['heating_mpc'])
async def test_uninstall_removes_storage(self):
store = Store()
with patch.object(integration, 'Store', return_value=store):
await integration.async_remove_entry(self.hass, self.entry)
store.async_remove.assert_awaited_once()
async def test_config_migration_preserves_options_precedence(self):
self.entry.version = 1
self.entry.data['outside_entity'] = 'sensor.old'
self.entry.options = {'outside_entity': 'sensor.override'}
self.assertTrue(await integration.async_migrate_entry(self.hass, self.entry))
kwargs = self.hass.config_entries.async_update_entry.call_args.kwargs
self.assertEqual(kwargs['options']['outside_temperature_sources'], ['sensor.override'])
self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside'])
self.assertNotIn('outside_entity', kwargs['data'])
self.assertEqual(kwargs['version'], 2)
self.entry.version = 3
self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry))
async def test_changed_inputs_during_optimization_discard_result(self):
self.ready()
async def changed(job):
output = job()
self.set_state('climate.reference', 'off', temperature=22)
return output
self.hass.async_add_executor_job = changed
data = await self.sample()
self.assertEqual(data['status'], 'inputs_changed')
self.assertIsNone(data['proposal'])
async def test_invalid_weights_do_not_publish_proposal(self):
for weight in (0, -1, 'bad', float('nan'), float('inf')):
self.c.config['weights'] = {'sensor.room': weight}
data = await self.sample()
self.assertEqual(data['status'], 'invalid_weights')
self.assertIsNone(data['proposal'])
async def test_added_room_needs_its_own_excitation_evidence(self):
model = coordinator.Learner(count=500, on_count=250, off_count=250)
self.entry.options['room_entities'] = ['sensor.room', 'sensor.new']
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
self.c._store.async_load.return_value = {'learners': {'sensor.room': model.as_dict()}}
await self.c.async_restore_model()
self.assertEqual(self.c.learners['sensor.room'].count, 500)
self.assertEqual(self.c._on_samples, 0)
self.assertEqual(self.c._off_samples, 0)
async def test_changed_inputs_during_save_discard_result(self):
self.ready()
await self.sample()
async def changed(payload):
self.set_state('climate.reference', 'off', temperature=22)
self.c._store.async_save.side_effect = changed
data = await self.sample()
self.assertEqual(data['status'], 'inputs_changed')
self.assertIsNone(data['proposal'])
+49
View File
@@ -60,6 +60,55 @@ class ModelTests(unittest.TestCase):
m.optimize({'r': 20}, 10, 0, 22, {'r': 0}, {'r': m.Learner()}, m.optimize({'r': 20}, 10, 0, 22, {'r': 0}, {'r': m.Learner()},
False, 1000, horizon_minutes=60) False, 1000, horizon_minutes=60)
def test_short_horizon_does_not_claim_60_minute_prediction(self):
out = m.optimize({'r': 20}, 5, 0, 22, {'r': 1}, {'r': m.Learner()},
False, 3600, horizon_minutes=30)
self.assertEqual(out['predicted_60m'], {})
self.assertIn('r', out['predicted_end'])
def test_prediction_matches_replay_at_60_minutes(self):
learners = {'r': m.Learner()}
out = m.optimize({'r': 20}, 5, .4, 22, {'r': 1}, learners, False, 3600,
horizon_minutes=90)
temps, lag = {'r': 20}, .4
for index, bit in enumerate(out['plan_15min']):
for _ in range(3):
temps, lag = m.simulate_step(temps, 5, lag, bit, learners)
if index == 3:
self.assertEqual(out['predicted_60m']['r'], round(temps['r'], 3))
self.assertEqual(out['predicted_end']['r'], round(temps['r'], 3))
def test_jitter_normalized_and_rejection_counted(self):
nominal, delayed = m.Learner(), m.Learner()
self.assertTrue(nominal.learn(20, 20.1, 5, .5, command=True))
self.assertTrue(delayed.learn(20, 20.11, 5, .5, 330, True))
for a, b in zip(nominal.theta, delayed.theta):
self.assertAlmostEqual(a, b)
self.assertFalse(delayed.learn(20, 25, 5, .5, command=False))
self.assertEqual((delayed.count, delayed.on_count, delayed.off_count), (1, 1, 0))
def test_corrupt_model_restore_is_atomic(self):
for changes in ({'theta': [-1, .1, 0]}, {'p': [[-1, 0, 0], [0, 1, 0], [0, 0, 1]]},
{'count': float('inf')}, {'count': '5'}, {'error_sum': float('nan')},
{'off_count': 1}, {'theta': [float('nan'), 0, 0]}, {'p': None}):
with self.subTest(changes=changes):
data = m.Learner().as_dict()
data.update(changes)
self.assertEqual(m.Learner.from_dict(data), m.Learner())
for bad in (None, [], 'broken'):
self.assertEqual(m.Learner.from_dict(bad), m.Learner())
def test_nonfinite_optimizer_inputs_rejected(self):
for key, value in (('weights', {'r': float('nan')}), ('effective_heat', float('nan')),
('switching_cost', float('inf')), ('seconds_since_change', -1),
('minimum_on_minutes', -1), ('comfort_band', float('nan'))):
args = dict(temps={'r': 20}, outside=5, effective_heat=0, target=22,
weights={'r': 1}, learners={'r': m.Learner()}, is_on=False,
seconds_since_change=3600, horizon_minutes=60)
args[key] = value
with self.subTest(key=key), self.assertRaises(ValueError):
m.optimize(**args)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+1
View File
@@ -66,6 +66,7 @@ class WeatherInputs(unittest.TestCase):
self.assertIsNone(w.normalize("temperature", value, "°C")) self.assertIsNone(w.normalize("temperature", value, "°C"))
self.assertIsNone(w.normalize("temperature", 22.0, "km/h")) self.assertIsNone(w.normalize("temperature", 22.0, "km/h"))
self.assertIsNone(w.normalize("wind", -3, "m/s")) self.assertIsNone(w.normalize("wind", -3, "m/s"))
self.assertIsNone(w.normalize("temperature", 20, 42))
self.assertIsNone(w.normalize("clouds", 150, "%")) self.assertIsNone(w.normalize("clouds", 150, "%"))