From 2a0a94c6408a6e1f988c58742ea795f83f3927aa Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Mon, 26 Oct 2026 21:21:25 +0200 Subject: [PATCH] Discard predictions only when their inputs change A finished plan was discarded whenever any watched State object changed during the save or calculation, including diagnostic-only weather sensors and the thermostat's current_temperature/hvac_action. The proposed-heat entity then went unavailable for five minutes without cause. Compare only the values the prediction uses: room and outdoor temperature states and units, thermostat mode and target, and relay state and last change. Also pass config_entry to the coordinator explicitly instead of relying on HA's ContextVar fallback, which HA reports as deprecated usage. Co-Authored-By: Claude Opus 5.5 --- custom_components/heating_mpc/coordinator.py | 33 +++++++++++++++----- tests/ha/test_lifecycle.py | 1 + tests/test_coordinator.py | 13 ++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 5646dd8..b661d85 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -32,7 +32,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The existing generic thermostat stays the only actuator owner.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=5)) + super().__init__(hass, _LOGGER, config_entry=entry, name=DOMAIN, + update_interval=timedelta(minutes=5)) self.entry = entry self.config = {**DEFAULTS, **entry.data, **entry.options} self.rooms: list[str] = self.config[CONF_ROOMS] @@ -106,6 +107,23 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._lag = {"time": now, "heater_on": heater_on, "lag": lag, "uncertainty": uncertainty} return steady + def _prediction_inputs(self) -> tuple: + """Values the prediction depends on, used to detect changes during calculation.""" + heater = self.config[CONF_HEATER] + entities = sorted(set(self.rooms) | set(sources_for(self.config, "temperature")) | + {self.config[CONF_CLIMATE], heater}) + values = [] + for entity_id in entities: + state = self.hass.states.get(entity_id) + if state is None: + values.append((entity_id, None)) + continue + attrs = state.attributes + values.append((entity_id, state.state, attrs.get("unit_of_measurement"), + attrs.get("temperature"), attrs.get("temperature_unit"), + state.last_changed if entity_id == heater else None)) + return tuple(values) + async def _async_update_data(self) -> dict[str, Any]: now = datetime.now(timezone.utc) heater = self.hass.states.get(self.config[CONF_HEATER]) @@ -116,9 +134,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): entity_id for metric in WEATHER_SOURCES for entity_id in sources_for(self.config, metric) } - watched = set(self.rooms) | weather_entity_ids | { - self.config[CONF_CLIMATE], self.config[CONF_HEATER]} - snapshot = {entity: self.hass.states.get(entity) for entity in watched} + snapshot = {entity: self.hass.states.get(entity) + for entity in set(self.rooms) | weather_entity_ids} + inputs_before = self._prediction_inputs() room_quality = {} temps = {} for room in self.rooms: @@ -273,10 +291,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): heating_cost=float(self.config[CONF_HEAT_COST]), comfort_band=float(self.config[CONF_BAND]), ) - # HA State objects are immutable snapshots. Discard a result if any - # input changed while the worker was calculating the hypothetical plan. + # Discard a result if a value used by the prediction changed while + # saving or while the worker calculated the hypothetical plan. Optional + # diagnostic weather sensors are not prediction inputs. output = await self.hass.async_add_executor_job(job) - if any(self.hass.states.get(entity) is not state for entity, state in snapshot.items()): + if self._prediction_inputs() != inputs_before: status["status"] = "inputs_changed" return status except (ValueError, RuntimeError, OverflowError) as err: diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index 758135e..f12aeba 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -21,6 +21,7 @@ async def setup(hass, entry): 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) == 5 diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 04c78ed..9afa5b2 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -388,6 +388,19 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(data['sample_result'], 'first_sample') self.assertGreater(data['heat_lag_uncertainty'], 0.4) + async def test_diagnostic_input_changes_do_not_discard_prediction(self): + self.ready() + self.c.config['outside_pressure_sources'] = ['sensor.pressure'] + self.set_state('sensor.pressure', '1010', unit_of_measurement='hPa') + self.set_state('climate.reference', 'heat', temperature=22, current_temperature=20.0) + async def changed(job): + output = job() + self.set_state('sensor.pressure', '1011', unit_of_measurement='hPa') + self.set_state('climate.reference', 'heat', temperature=22, current_temperature=20.1) + return output + self.hass.async_add_executor_job = changed + self.assertEqual((await self.sample())['status'], 'ready') + async def test_heat_gain_identified_with_mid_interval_switching(self): """Synthetic plant with radiator lag, cycled by a hysteresis thermostat.""" a, b, c = 0.004, 0.12, 0.0005