"""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["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"]