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 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 21:21:25 +02:00
1 parent c3646ba876
commit 2a0a94c640
3 files changed
+40 -7

No files matched your search

+26 -7
View File
@@ -32,7 +32,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""The existing generic thermostat stays the only actuator owner.""" """The existing generic thermostat stays the only actuator owner."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 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.entry = entry
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]
@@ -106,6 +107,23 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._lag = {"time": now, "heater_on": heater_on, "lag": lag, "uncertainty": uncertainty} self._lag = {"time": now, "heater_on": heater_on, "lag": lag, "uncertainty": uncertainty}
return steady 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]: async def _async_update_data(self) -> dict[str, Any]:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
heater = self.hass.states.get(self.config[CONF_HEATER]) 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 entity_id for metric in WEATHER_SOURCES
for entity_id in sources_for(self.config, metric) for entity_id in sources_for(self.config, metric)
} }
watched = set(self.rooms) | weather_entity_ids | { snapshot = {entity: self.hass.states.get(entity)
self.config[CONF_CLIMATE], self.config[CONF_HEATER]} for entity in set(self.rooms) | weather_entity_ids}
snapshot = {entity: self.hass.states.get(entity) for entity in watched} inputs_before = self._prediction_inputs()
room_quality = {} room_quality = {}
temps = {} temps = {}
for room in self.rooms: for room in self.rooms:
@@ -273,10 +291,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
heating_cost=float(self.config[CONF_HEAT_COST]), heating_cost=float(self.config[CONF_HEAT_COST]),
comfort_band=float(self.config[CONF_BAND]), comfort_band=float(self.config[CONF_BAND]),
) )
# HA State objects are immutable snapshots. Discard a result if any # Discard a result if a value used by the prediction changed while
# input changed while the worker was calculating the hypothetical plan. # 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) 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" status["status"] = "inputs_changed"
return status return status
except (ValueError, RuntimeError, OverflowError) as err: except (ValueError, RuntimeError, OverflowError) as err:
+1
View File
@@ -21,6 +21,7 @@ async def setup(hass, entry):
async def test_setup_reload_unload(hass, entry, inputs): async def test_setup_reload_unload(hass, entry, inputs):
coordinator = await setup(hass, entry) coordinator = await setup(hass, entry)
assert coordinator.config_entry is entry
registry = er.async_get(hass) registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, entry.entry_id) entities = er.async_entries_for_config_entry(registry, entry.entry_id)
assert len(entities) == 5 assert len(entities) == 5
+13
View File
@@ -388,6 +388,19 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data['sample_result'], 'first_sample') self.assertEqual(data['sample_result'], 'first_sample')
self.assertGreater(data['heat_lag_uncertainty'], 0.4) 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): async def test_heat_gain_identified_with_mid_interval_switching(self):
"""Synthetic plant with radiator lag, cycled by a hysteresis thermostat.""" """Synthetic plant with radiator lag, cycled by a hysteresis thermostat."""
a, b, c = 0.004, 0.12, 0.0005 a, b, c = 0.004, 0.12, 0.0005