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 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 21:50:36 +02:00
1 parent 955c679f5e
commit c0e4de832b
3 files changed
+72 -7

No files matched your search

+23 -7
View File
@@ -29,6 +29,8 @@ from .weather_sources import choose_weather_sources, sources_for, normalize
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Relay changes kept between polls; more than this in five minutes is chatter. # Relay changes kept between polls; more than this in five minutes is chatter.
MAX_RELAY_EVENTS = 256 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]]): class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -41,6 +43,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.config = {**DEFAULTS, **entry.data, **entry.options} self.config = {**DEFAULTS, **entry.data, **entry.options}
self.rooms: list[str] = self.config[CONF_ROOMS] self.rooms: list[str] = self.config[CONF_ROOMS]
self.learners: dict[str, Learner] = {room: Learner() for room in self.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._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
self._previous: dict[str, Any] | None = None self._previous: dict[str, Any] | None = None
# Radiator-lag proxy at the last poll with a valid relay state. Tracked # 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]: if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]:
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return return
# Models carry room identity, so changing the room list is safe. # Models carry room identity, so changing the room list is safe. Models of
saved = stored.get("learners", {}) # rooms no longer configured are retained, most recently removed last.
if not isinstance(saved, dict): for key in ("retired_learners", "learners"): # current learners take precedence
return saved = stored.get(key)
for room, data in saved.items(): if not isinstance(saved, dict):
if room in self.learners: continue
self.learners[room] = Learner.from_dict(data) 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() self._update_counts()
def _update_counts(self) -> None: 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_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_comparison": {k: v for k, v in weather_comparisons.items() if v},
"weather_model_inputs": ["temperature"], "weather_model_inputs": ["temperature"],
"retired_room_models": sorted(self._retired),
} }
heater_valid = heater is not None and heater.state in ("on", "off") heater_valid = heater is not None and heater.state in ("on", "off")
heater_on = heater_valid and heater.state == "on" heater_on = heater_valid and heater.state == "on"
@@ -256,6 +270,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
await self._store.async_save({ await self._store.async_save({
"schema_version": 2, "schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()}, "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, "on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER], "heater_entity": self.config[CONF_HEATER],
"outside_source": active_sources["temperature"], "outside_source": active_sources["temperature"],
+23
View File
@@ -182,3 +182,26 @@ async def test_relay_listener_integrates_changes_between_polls(hass, entry, inpu
hass.states.async_set("switch.boiler", "on") hass.states.async_set("switch.boiler", "on")
await hass.async_block_till_done() await hass.async_block_till_done()
assert not coordinator._relay_events 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
+26
View File
@@ -498,3 +498,29 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
for _ in range(coordinator.MAX_RELAY_EVENTS): for _ in range(coordinator.MAX_RELAY_EVENTS):
self.c.record_relay_state(self.now, 'on') self.c.record_relay_state(self.now, 'on')
self.assertTrue(self.c._relay_events_dropped) 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)])