Files
heating-mpc-ha/tests/ha/test_lifecycle.py
T
buzeraandClaude Opus 5.5 3afb6688a5 Score forecasts against reality; expose forecast trajectories
There was no evidence of how accurate the thermal model is: the only
number was an in-sample 5-minute error.

At every poll, record each room's reading, the outdoor temperature, the
radiator-lag estimate and the model parameters. 15, 30, 60 and 120
minutes later, replay the room from that poll with the parameters it
had then (out of sample), the outdoor temperature held constant and the
radiator lag that actually followed from the relay's real history, and
compare with the reading now. A no-change forecast is scored alongside.
Only spans with regular polls and a well-known lag are scored. 7 days
of per-room statistics are saved with the model (optional "evaluation"
payload key; about 18 KB at most).

Four new sensors, Heating MPC Forecast Error 15/30/60/120 min, show the
mean absolute error with bias, persistence error, skill and per-room
detail. On the synthetic lagged plant the 60-minute error is 0.03 °C
against 0.39 °C for persistence.

Room forecast entities gain forecast_15min and plan_15min attributes
and are labelled counterfactual. Volatile attributes are unrecorded.
The model is now saved after scoring, so saved statistics are current.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:35:13 +02:00

241 lines
12 KiB
Python

"""Run the observer through real HA lifecycle and storage APIs."""
from datetime import timedelta
from unittest.mock import patch
import pytest
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import async_fire_time_changed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from custom_components.heating_mpc.const import DOMAIN
async def setup(hass, entry):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return hass.data[DOMAIN][entry.entry_id]
async def test_setup_reload_unload(hass, entry, inputs):
coordinator = await setup(hass, entry)
assert coordinator.config_entry is entry
registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, entry.entry_id)
assert len(entities) == 9 # 5 core + 4 forecast-error sensors
ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning"
status = hass.states.get(ids[f"{entry.entry_id}_status"])
assert status.attributes["input_quality"]["rooms"]["sensor.room"]["max_age_seconds_24h"] == 0
# Volatile diagnostics stay visible but are not written to the Recorder.
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
assert "models" not in status.state_info["unrecorded_attributes"]
proposal_id = registry.async_get_entity_id("binary_sensor", DOMAIN, f"{entry.entry_id}_proposal")
assert hass.states.get(proposal_id).state == "unavailable"
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id] is not coordinator
assert ids == {e.unique_id: e.entity_id for e in er.async_entries_for_config_entry(registry, entry.entry_id)}
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id not in hass.data[DOMAIN]
assert not coordinator._listeners
assert coordinator._unsub_refresh is None
async def test_options_reload_removes_only_old_room(hass, entry, inputs):
await setup(hass, entry)
registry = er.async_get(hass)
old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
assert registry.async_get(old) is None
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min")
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 9
async def test_storage_restore_and_remove(hass, entry, inputs):
coordinator = await setup(hass, entry)
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
await coordinator._store.async_save({"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": model.as_dict()}})
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
restored = hass.data[DOMAIN][entry.entry_id]
assert restored.learners["sensor.room"].count == 250
assert restored.data["status"] == "ready"
forecast_id = er.async_get(hass).async_get_entity_id(
"sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
forecast = hass.states.get(forecast_id)
assert len(forecast.attributes["forecast_15min"]) == len(forecast.attributes["plan_15min"]) == 4
assert forecast.attributes["forecast_15min"][3] == float(forecast.state)
assert {"forecast_15min", "plan_15min"} <= forecast.state_info["unrecorded_attributes"]
assert await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None
assert not er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
async def test_missing_sensor_manual_off_and_target(hass, entry, inputs):
coordinator = await setup(hass, entry)
hass.states.async_set("sensor.room", "unavailable")
await coordinator.async_refresh()
assert coordinator.data["status"] == "waiting_for_sensors"
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"})
hass.states.async_set("climate.reference", "off", {"temperature": 19, "preset_mode": "away"})
await coordinator.async_refresh()
assert coordinator.data["status"] == "paused"
assert coordinator.data["proposal"] is None
assert hass.states.get("climate.reference").attributes["preset_mode"] == "away"
hass.states.async_set("climate.reference", "heat", {"temperature": 21, "preset_mode": "sleep"})
await coordinator.async_refresh()
assert coordinator.data["target"] == 21
assert hass.states.get("switch.boiler").state == "off"
async def test_optimizer_failure_is_unavailable(hass, entry, inputs):
coordinator = await setup(hass, entry)
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
coordinator._update_counts()
with patch("custom_components.heating_mpc.coordinator.optimize", side_effect=RuntimeError("test")):
await coordinator.async_refresh()
assert coordinator.data["status"] == "prediction_error"
assert coordinator.data["proposal"] is None
async def test_scheduled_sample_saves_and_unload_stops_polling(hass, entry, inputs, freezer, hass_storage):
coordinator = await setup(hass, entry)
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.learners["sensor.room"].count == 1
payload = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"]
assert payload["learners"]["sensor.room"]["off_count"] == 1
assert await hass.config_entries.async_unload(entry.entry_id)
future += timedelta(minutes=5)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.learners["sensor.room"].count == 1
@pytest.mark.parametrize("payload", ["broken", {"learners": []}, {"learners": {"sensor.room": {"theta": []}}}])
async def test_corrupt_saved_payload_starts_learning(hass, entry, inputs, hass_storage, payload):
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": payload}
coordinator = await setup(hass, entry)
assert coordinator.data["status"] == "learning"
assert coordinator.data["samples"] == 0
async def test_startup_preserves_manual_off_with_trained_model(hass, entry, inputs, hass_storage):
from custom_components.heating_mpc.physics import Learner
model = Learner(count=250, on_count=125, off_count=125)
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": model.as_dict()}}}
hass.states.async_set("climate.reference", "off", {"temperature": 19, "preset_mode": "away"})
coordinator = await setup(hass, entry)
assert coordinator.data["status"] == "paused"
assert coordinator.data["proposal"] is None
assert hass.states.get("climate.reference").state == "off"
assert hass.states.get("climate.reference").attributes["temperature"] == 19
async def test_startup_missing_inputs_then_recovery(hass, entry):
coordinator = await setup(hass, entry)
assert coordinator.data["status"] == "waiting_for_sensors"
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"})
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"})
hass.states.async_set("switch.boiler", "off")
hass.states.async_set("climate.reference", "heat", {"temperature": 22})
await coordinator.async_refresh()
assert coordinator.data["status"] == "learning"
assert coordinator.data["samples"] == 0
async def test_freshly_written_relay_state_defers_learning(hass, entry, inputs, freezer):
"""After an HA restart the relay's last_changed is new, so radiator lag is unknown."""
hass.states.async_set("switch.boiler", "on")
coordinator = await setup(hass, entry)
assert coordinator.data["heat_lag"] == 0.5
assert coordinator.data["heat_lag_uncertainty"] == 0.5
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.data["sample_result"] == "lag_uncertain"
assert coordinator.learners["sensor.room"].count == 0
async def test_relay_listener_integrates_changes_between_polls(hass, entry, inputs, freezer):
coordinator = await setup(hass, entry)
start = dt_util.utcnow()
for offset, state in ((60, "on"), (200, "off")): # on/off round trip inside one poll
freezer.move_to(start + timedelta(seconds=offset))
hass.states.async_set("switch.boiler", state)
await hass.async_block_till_done()
for minutes, expected in ((5, "relay_changed"), (10, "learned")):
future = start + timedelta(minutes=minutes, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.data["sample_result"] == expected
# Without the listener this round trip would restart the estimate (about 0.46).
assert coordinator.data["heat_lag_uncertainty"] < 0.05
assert await hass.config_entries.async_unload(entry.entry_id)
hass.states.async_set("switch.boiler", "on")
await hass.async_block_till_done()
assert not coordinator._relay_events
async def test_removed_room_model_survives_options_round_trip(hass, entry, inputs, hass_storage, freezer):
from custom_components.heating_mpc.physics import Learner
key = f"{DOMAIN}.{entry.entry_id}"
hass_storage[key] = {"version": 1, "data": {
"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
await setup(hass, entry)
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
await hass.async_block_till_done()
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
saved = hass_storage[key]["data"]
assert set(saved["learners"]) == {"sensor.new"}
assert saved["retired_learners"]["sensor.room"]["count"] == 250
hass.config_entries.async_update_entry(
entry, options={"room_entities": ["sensor.room", "sensor.new"]})
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs, freezer):
coordinator = await setup(hass, entry)
registry = er.async_get(hass)
ids = {horizon: registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_forecast_error_{horizon}min")
for horizon in (15, 30, 60, 120)}
assert all(hass.states.get(entity).state == "unknown" for entity in ids.values())
future = dt_util.utcnow()
for _ in range(3): # 15 minutes of regular polls
future += timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
state = hass.states.get(ids[15])
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
assert state.attributes["samples"] == 1
assert state.attributes["rooms"]["sensor.room"]["samples"] == 1
assert state.attributes["unit_of_measurement"] == "°C"
assert "rooms" in state.state_info["unrecorded_attributes"]
assert hass.states.get(ids[30]).state == "unknown"