The Forecast Error sensors replayed the model with the relay, electrical heat and window history that actually followed, so they measured the model, not a forecast anyone could have made. They are renamed Model Error (unique IDs unchanged) and gain RMSE, effective sample count and a quality label; their state stays unknown until there are at least 5 roughly independent spans. A new Forecast Error sensor scores forecasts as published, with the plan and assumptions of the moment they were made. A forecast is scored only when the relay followed its plan; otherwise it is counted as counterfactual, not as error. Statistics are saved with the model and dropped with a zone's recalibration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
353 lines
18 KiB
Python
353 lines
18 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
|
|
|
|
|
|
@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"]
|