From c0e4de832b9d870c1d000ddd34e97f417649b27e Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Mon, 26 Oct 2026 21:50:36 +0200 Subject: [PATCH] Keep models of rooms removed in Options Saving wrote only the currently configured rooms, so removing a room in Configure (even by accident) discarded its learned model at the next save, and adding it back started from scratch. Keep validated models of removed rooms (at most 24, oldest dropped first) in an optional retired_learners payload key and restore them when the room is added back. Payload schema_version stays 2: the key is additive and older 0.2.x readers ignore it. Replacing the relay still starts a fresh model, as before. Status lists retired_room_models. Co-Authored-By: Claude Opus 5.5 --- custom_components/heating_mpc/coordinator.py | 30 +++++++++++++++----- tests/ha/test_lifecycle.py | 23 +++++++++++++++ tests/test_coordinator.py | 26 +++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 8db4c74..9bf6f0f 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -29,6 +29,8 @@ from .weather_sources import choose_weather_sources, sources_for, normalize _LOGGER = logging.getLogger(__name__) # Relay changes kept between polls; more than this in five minutes is chatter. MAX_RELAY_EVENTS = 256 +# Models of rooms removed in Options, kept in case a room is added back. +MAX_RETIRED_ROOMS = 24 class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -41,6 +43,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.config = {**DEFAULTS, **entry.data, **entry.options} self.rooms: list[str] = self.config[CONF_ROOMS] self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} + self._retired: dict[str, dict[str, Any]] = {} self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") self._previous: dict[str, Any] | None = None # Radiator-lag proxy at the last poll with a valid relay state. Tracked @@ -62,13 +65,23 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]: _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") return - # Models carry room identity, so changing the room list is safe. - saved = stored.get("learners", {}) - if not isinstance(saved, dict): - return - for room, data in saved.items(): - if room in self.learners: - self.learners[room] = Learner.from_dict(data) + # Models carry room identity, so changing the room list is safe. Models of + # rooms no longer configured are retained, most recently removed last. + for key in ("retired_learners", "learners"): # current learners take precedence + saved = stored.get(key) + if not isinstance(saved, dict): + continue + for room, data in saved.items(): + if not isinstance(room, str): + continue + learner = Learner.from_dict(data) + if room in self.learners: + self.learners[room] = learner + elif learner.count: + self._retired.pop(room, None) + self._retired[room] = learner.as_dict() + while len(self._retired) > MAX_RETIRED_ROOMS: + self._retired.pop(next(iter(self._retired))) self._update_counts() def _update_counts(self) -> None: @@ -210,6 +223,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "weather_active_sources": {k: v for k, v in active_sources.items() if v}, "weather_comparison": {k: v for k, v in weather_comparisons.items() if v}, "weather_model_inputs": ["temperature"], + "retired_room_models": sorted(self._retired), } heater_valid = heater is not None and heater.state in ("on", "off") heater_on = heater_valid and heater.state == "on" @@ -256,6 +270,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self._store.async_save({ "schema_version": 2, "learners": {room: learner.as_dict() for room, learner in self.learners.items()}, + # Optional; older 0.2.x readers ignore it and keep working. + "retired_learners": self._retired, "on_samples": self._on_samples, "off_samples": self._off_samples, "heater_entity": self.config[CONF_HEATER], "outside_source": active_sources["temperature"], diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index c0d8805..137149b 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -182,3 +182,26 @@ async def test_relay_listener_integrates_changes_between_polls(hass, entry, inpu 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 diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index b328139..353526f 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -498,3 +498,29 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): for _ in range(coordinator.MAX_RELAY_EVENTS): self.c.record_relay_state(self.now, 'on') self.assertTrue(self.c._relay_events_dropped) + + async def test_removed_room_model_is_kept_and_restored(self): + old = coordinator.Learner(count=500, on_count=250, off_count=250) + self.c._store.async_load.return_value = {'schema_version': 2, 'learners': { + 'sensor.room': coordinator.Learner(count=7).as_dict(), 'sensor.gone': old.as_dict()}} + await self.c.async_restore_model() + self.assertEqual(self.c.learners['sensor.room'].count, 7) + await self.sample() + self.assertEqual((await self.sample())['retired_room_models'], ['sensor.gone']) + payload = self.c._store.async_save.call_args.args[0] + self.assertEqual(set(payload['learners']), {'sensor.room'}) + self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500) + self.entry.options['room_entities'] = ['sensor.room', 'sensor.gone'] + new = coordinator.HeatingMPCCoordinator(self.hass, self.entry) + new._store.async_load.return_value = payload + await new.async_restore_model() + self.assertEqual(new.learners['sensor.gone'].count, 500) + self.assertEqual(new.learners['sensor.room'].count, 8) + self.assertEqual(new._retired, {}) + + async def test_retired_room_models_are_bounded_and_validated(self): + retired = {f'sensor.old{i}': coordinator.Learner(count=i + 1).as_dict() for i in range(30)} + retired.update({'sensor.bad': 'broken', 'sensor.empty': coordinator.Learner().as_dict()}) + self.c._store.async_load.return_value = {'retired_learners': retired} + await self.c.async_restore_model() + self.assertEqual(list(self.c._retired), [f'sensor.old{i}' for i in range(6, 30)])