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:
1 parent
c3646ba876
commit
2a0a94c640
3 files changed
+40
-7
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user