Keep the entry loaded when the last zone is deleted

Deleting the only zone in the UI reloaded the entry with no zones, and
min() over an empty set of models raised during setup, so the entry
failed to load. Counts and learning progress now default to 0, and the
status reads "no_zones" until a zone is added again; adding one resumes
normally. HA Core regression test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 03:02:03 +02:00
1 parent e62798dc4f
commit dc92e2bac5
2 files changed
+24 -6

No files matched your search

+9 -6
View File
@@ -166,8 +166,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._update_counts() self._update_counts()
def _update_counts(self) -> None: def _update_counts(self) -> None:
self._on_samples = min(m.on_count for m in self.learners.values()) # Without zones (all deleted in the UI) there is nothing to count.
self._off_samples = min(m.off_count for m in self.learners.values()) self._on_samples = min((m.on_count for m in self.learners.values()), default=0)
self._off_samples = min((m.off_count for m in self.learners.values()), default=0)
def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict: def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict:
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
@@ -241,9 +242,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
status["sample_results_since_start"] = dict(self._sample_results) status["sample_results_since_start"] = dict(self._sample_results)
# Least trained room: mean of its three readiness requirements (100 = ready). # Least trained room: mean of its three readiness requirements (100 = ready).
status["learning_progress"] = round(100 * min( status["learning_progress"] = round(100 * min(
(min(1.0, m.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES) ((min(1.0, m.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES)
+ min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3 + min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3
for m in self.learners.values()), 1) for m in self.learners.values()), default=0.0), 1)
def _extended_diagnostics(self, room: str) -> dict[str, Any]: def _extended_diagnostics(self, room: str) -> dict[str, Any]:
model = self.extended.get(room) model = self.extended.get(room)
@@ -432,7 +433,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None}, "temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None},
"zone_names": {key: zone.name for key, zone in self.zones.items()}, "zone_names": {key: zone.name for key, zone in self.zones.items()},
"on_samples": self._on_samples, "off_samples": self._off_samples, "on_samples": self._on_samples, "off_samples": self._off_samples,
"samples": min(learner.count for learner in self.learners.values()), "samples": min((learner.count for learner in self.learners.values()), default=0),
"mode": climate.state if climate else "unavailable", "mode": climate.state if climate else "unavailable",
"weather": {k: v for k, v in weather.items() if v is not None}, "weather": {k: v for k, v in weather.items() if v is not None},
"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},
@@ -486,6 +487,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._evaluator.reset_window() self._evaluator.reset_window()
status["forecast_accuracy"] = self._evaluator.summary(self.rooms) status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
self._count_result(status, "waiting_for_inputs") self._count_result(status, "waiting_for_inputs")
if not self.zones:
status["status"] = "no_zones"
return status return status
# A zone learns only across a poll pair where it was usable at both ends with # A zone learns only across a poll pair where it was usable at both ends with
@@ -587,7 +590,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"reference_heat": heater_on, "reference_heat": heater_on,
"on_samples": self._on_samples, "on_samples": self._on_samples,
"off_samples": self._off_samples, "off_samples": self._off_samples,
"samples": min(learner.count for learner in self.learners.values()), "samples": min((learner.count for learner in self.learners.values()), default=0),
"models": { "models": {
room: {"name": self.zones[room].name, room: {"name": self.zones[room].name,
"loss": round(model.theta[0], 6), "loss": round(model.theta[0], 6),
+15
View File
@@ -290,3 +290,18 @@ async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, fr
last = hass.states.get(last_id) last = hass.states.get(last_id)
assert last.state == "waiting_for_inputs" assert last.state == "waiting_for_inputs"
assert last.attributes["counts_since_start"] == {"first_sample": 1, "learned": 1, "waiting_for_inputs": 1} 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"