Files
heating-mpc-ha/tests/ha/test_lifecycle.py
T
buzeraandClaude Opus 5.5 2cfba024ff Keep spans with unknown inputs out of Model Error
Reproduced: a window unknown for a whole 15-minute span was replayed as
closed and scored as a Model Error sample (MAE 0.76 °C on one sample).

- A zone's replay is complete only if every step's model inputs were
  observed: window openings (and a confirmed recovery), the wind, direction
  and other zones' windows that an open window's terms use, and, for the
  extended layer, electrical heat and neighbours, whose own replay must be
  complete too.
- Incomplete spans are counted, not scored; the base model's error on them
  is kept separately (it needs only the relay and outdoor temperature).
  Spans that could not be replayed (uncertain radiator lag, no reading at
  the end) are counted as skipped. Model Error sensors show incomplete,
  skipped and incomplete_base_model_mae; stored data gains an "excluded"
  section and older data loads unchanged.
- Window inputs no longer stand in for unknown values: the facing term is
  unknown while a window is open and the wind direction is not, and the
  cross-ventilation input is unknown while another zone's window is. With
  every window closed, opening terms are 0 and need no weather input.
  Learning refuses such samples; forecasts still assume calm air.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:46:21 +02:00

393 lines
20 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
from homeassistant.config_entries import ConfigSubentry
from types import MappingProxyType
def zone_subentry(entry, key):
return next(sub for sub in entry.subentries.values() if sub.unique_id == key)
def add_zone(hass, entry, key, sensors, name=None):
"""What the Add zone flow stores, added directly."""
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
data=MappingProxyType({"key": key, "name": name or key, "sensors": sensors,
"sensor_weights": {s: 1.0 for s in sensors}, "comfort_weight": 1.0,
"target_offset": 0.0, "neighbors": []}),
subentry_type="zone", title=name or key, unique_id=key))
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) == 12 # 7 core + 4 model-error sensors + 1 forecast-error sensor
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"])
zone_quality = status.attributes["input_quality"]["zones"]["sensor.room"]
assert zone_quality["sensors"]["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_replacing_a_zone_removes_only_its_forecast(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"})
add_zone(hass, entry, "zone_new", ["sensor.new"], "New")
await hass.async_block_till_done()
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].rooms == ["zone_new"]
assert registry.async_get(old) is None
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 12
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.attributes["forecast"]["60_min"] == float(forecast.state)
assert set(forecast.attributes["forecast"]) == {"15_min", "30_min", "60_min"} # 60-minute horizon
assert set(forecast.attributes["contributions_c_per_h"]) == {"loss", "heat", "bias"}
assert forecast.attributes["model_in_use"] == "base"
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
async def test_reloads_leave_one_relay_listener_and_one_polling_coordinator(hass, entry, inputs, freezer):
coordinators = [await setup(hass, entry)]
for _ in range(3):
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
coordinators.append(hass.data[DOMAIN][entry.entry_id])
*old, current = coordinators
assert len({id(c) for c in coordinators}) == 4
# A relay change reaches only the current coordinator.
hass.states.async_set("switch.boiler", "on")
await hass.async_block_till_done()
assert [state for _, state in current._relay_events] == ["on"]
assert not any(c._relay_events for c in old)
# Only the current coordinator polls; the replaced ones neither learn nor publish.
status_id = er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")
updates = []
hass.bus.async_listen("state_changed", lambda event: updates.append(event.data["entity_id"]))
before = [(c.learners["sensor.room"].count, c.data) for c in old]
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 [(c.learners["sensor.room"].count, c.data) for c in old] == before
assert updates.count(status_id) <= 1
assert hass.states.get(status_id).state == current.data["status"]
# After unload, a relay change is recorded nowhere and nothing is published.
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
events = list(current._relay_events)
updates.clear()
hass.states.async_set("switch.boiler", "off")
future += timedelta(minutes=5)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert list(current._relay_events) == events
assert status_id not in updates
@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)
add_zone(hass, entry, "sensor.new", ["sensor.new"])
await hass.async_block_till_done()
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
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
add_zone(hass, entry, "sensor.room", ["sensor.room"])
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])
accuracy = coordinator.data["forecast_accuracy"][15]
assert accuracy["samples"] == 1
# One replayed forecast is no accuracy figure: the state stays unknown, the numbers
# are in the attributes with their quality.
assert state.state == "unknown"
assert state.attributes["mae"] == accuracy["mae"] > 0
assert state.attributes["quality"] == "insufficient"
assert state.attributes["samples"] == 1
assert (state.attributes["incomplete"], state.attributes["skipped"]) == (0, 0)
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
assert state.attributes["unit_of_measurement"] == "°C"
assert state.attributes["friendly_name"] == "Heating MPC Model Error 15 min"
assert "zones" in state.state_info["unrecorded_attributes"]
assert hass.states.get(ids[30]).state == "unknown"
for step in range(15): # enough 15-minute spans for an indicative figure
future += timedelta(minutes=5, seconds=1)
freezer.move_to(future)
# Fresh readings, or the sensors turn stale after 30 minutes.
hass.states.async_set("sensor.room", str(20 - 0.01 * step), {"unit_of_measurement": "°C"})
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"}, force_update=True)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
accuracy = coordinator.data["forecast_accuracy"][15]
assert accuracy["quality"] == "indicative", accuracy
assert float(hass.states.get(ids[15]).state) == accuracy["mae"]
# Published forecasts are scored separately; nothing was published while learning.
operational = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_operational_forecast_error")
state = hass.states.get(operational)
assert state.state == "unknown"
assert state.attributes["friendly_name"] == "Heating MPC Forecast Error"
assert "horizons" in state.state_info["unrecorded_attributes"]
async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer):
await setup(hass, entry)
registry = er.async_get(hass)
progress_id = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_learning_progress")
last_id = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_last_sample")
assert hass.states.get(progress_id).state == "0.0"
last = hass.states.get(last_id)
assert last.state == "first_sample"
assert "lag_uncertain" in last.attributes["options"]
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 hass.states.get(last_id).state == "learned"
# One OFF sample: (1/200 + 0/30 + 1/30) / 3
assert float(hass.states.get(progress_id).state) == 1.3
progress = hass.states.get(progress_id).attributes
assert (progress["samples"], progress["off_samples"], progress["required"]["samples"]) == (1, 1, 200)
hass.states.async_set("switch.boiler", "unavailable")
future += timedelta(minutes=5)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
last = hass.states.get(last_id)
assert last.state == "waiting_for_inputs"
assert last.attributes["counts_since_start"] == {"first_sample": 1, "learned": 1, "waiting_for_inputs": 1}
async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs, caplog):
await setup(hass, entry)
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
await hass.async_block_till_done()
assert entry.state.name == "LOADED"
coordinator = hass.data[DOMAIN][entry.entry_id]
assert (coordinator.rooms, coordinator.data["status"]) == ([], "no_zones")
assert coordinator.data["learning_progress"] == 0
assert "Error setting up entry" not in caplog.text
# Adding a zone back resumes normally.
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"]