The lag proxy was reset to the new relay command at every relay change and after any skipped interval, so it read 1.0 straight after ON and 0.0 straight after OFF. This disabled the lag model around every boiler cycle: on a synthetic lagged plant cycled by a hysteresis thermostat the learner's heat gain came out 34% low (0.079 vs 0.12) and the loss term 43% low, and the optimizer started post-transition plans from the wrong lag. Track the lag at every poll with a valid relay state, independently of room sensors. Integrate a single change between polls at its last_changed time. For ambiguous cases (round trip between polls, relay outage, restart, clock jump) restart from time-in-state with an explicit error half-width, and learn only once it is at most 0.1 (about 32 min after an HA restart). The same synthetic test now learns 0.1195/0.00389 (true 0.12/0.004). Expose heat_lag, heat_lag_uncertainty and sample_result on Status so users can see why a poll was or was not learned. The HA test fixture relay now has an hour-old timestamp; a new test covers a freshly written relay state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
7.9 KiB
Python
163 lines
7.9 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)
|
|
registry = er.async_get(hass)
|
|
entities = er.async_entries_for_config_entry(registry, entry.entry_id)
|
|
assert len(entities) == 5
|
|
ids = {e.unique_id: e.entity_id for e in entities}
|
|
assert coordinator.data["status"] == "learning"
|
|
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)) == 5
|
|
|
|
|
|
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"
|
|
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
|