Leave out unusable rooms instead of pausing; add keep-last-value option

A single stale, unavailable, missing or invalid room sensor paused
learning and forecasts for every room. That is not acceptable once the
MPC may control heating.

Now such a room is left out: the weighted temperature, learning and the
optimizer use the remaining rooms with their weights re-normalized, and
a room learns only across poll pairs where it was usable at both ends.
Readiness considers only the rooms used. Everything pauses only when no
room is usable or a shared input (relay, thermostat, outdoor
temperature) is missing.

A new setup/Configure checkbox, room_stale_use_last_value (default off),
keeps using a stale room sensor's last value instead, for sensors that
report only on change. It never applies to unavailable or invalid
sensors, or to a sensor silent for more than 24 hours.

Status adds excluded_rooms, weight_coverage and a per-room "used" flag.
English and Romanian labels and help text added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 22:07:18 +02:00
1 parent cd8b65ac2f
commit 3076beebfb
10 files changed
+184 -34

No files matched your search

+28 -12
View File
@@ -74,7 +74,10 @@ is available only while it is `ready`. Useful attributes:
`invalid_timestamp`, `future_timestamp` or `invalid_value_or_unit`),
`age_seconds` (time since the sensor last reported to HA) and
`max_age_seconds_24h` (largest age seen at polls in the last 24 hours; resets on
reload and can read up to one poll, 5 minutes, low).
reload and can read up to one poll, 5 minutes, low). Rooms also have `used`:
whether that room's reading was used in this poll.
- `excluded_rooms` and `weight_coverage` — rooms left out of this poll, and the
share of the total comfort weight still covered by the rooms used (1.0 = all).
- `heat_lag` and `heat_lag_uncertainty` — the radiator-lag estimate (0 cold, 1 fully
warm) and its error bound. Learning waits while the bound is above 0.1, for
example for about 30 minutes after an HA restart.
@@ -91,18 +94,31 @@ written to the Recorder; the other attributes are.
### Choosing freshness limits
Room and weather report-age limits are set during setup and in **Configure**
(5–1440 minutes; defaults 30 for rooms and 120 for weather). A room sensor older
than its limit pauses learning and forecasts for **all** rooms until it reports
again. A stale outdoor source falls back to the next one in its list; optional
weather metrics never block. Recovery starts a new sampling interval, so nothing is
learned across an outage.
(5–1440 minutes; defaults 30 for rooms and 120 for weather).
Within the limit, the last reading counts as current, so a too-generous limit lets
a frozen value be learned for a while. A too-tight limit pauses the integration
whenever a sensor is quietly between reports. Freshness uses HA's `last_reported`:
the time a sensor last reported to HA, even with an unchanged value. It is not the
age of the physical measurement, so a provider re-publishing cached data looks
fresh, and sensors that report only on change can look stale in a steady room.
A room sensor that is older than its limit, unavailable, missing or invalid is
**left out**: the weighted temperature, learning and forecasts continue with the
other rooms, whose weights are re-normalized. Status shows it in `excluded_rooms`.
Learning and forecasts pause only when no room is usable, or when the relay,
thermostat or outdoor temperature is missing. A room that reports again learns
from its second poll onwards, so nothing is learned across its gap.
The checkbox **Keep using the last value of a room sensor that stopped reporting**
changes this for stale sensors: their last value is still used, for up to 24 hours
without a report. Use it for sensors that report only when the value changes.
Unavailable or invalid sensors are always left out, and so is any sensor silent
for more than 24 hours, since it is most likely dead.
A stale outdoor source falls back to the next one in its list; optional weather
metrics never block.
Within the limit (or with the checkbox on), the last reading counts as current,
so a frozen value can be learned for a while. A too-tight limit (with the checkbox
off) drops a room whenever its sensor is quietly between reports. Freshness uses
HA's `last_reported`: the time a sensor last reported to HA, even with an unchanged
value. It is not the age of the physical measurement, so a provider re-publishing
cached data looks fresh, and sensors that report only on change can look stale in
a steady room.
After a day of running, set each limit comfortably above (about 1.5–2×) the largest
`max_age_seconds_24h` among the sensors it covers. The relay and thermostat have no
+3 -1
View File
@@ -9,7 +9,7 @@ from homeassistant.core import callback
from homeassistant.helpers import selector
from .const import (
CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE,
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE,
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
@@ -41,6 +41,8 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
vol.Coerce(int), vol.Range(min=5, max=1440))
schema[vol.Required(CONF_ROOM_STALE_KEEP, default=bool(
data.get(CONF_ROOM_STALE_KEEP, DEFAULTS[CONF_ROOM_STALE_KEEP])))] = selector.BooleanSelector()
if detailed:
for key, allowed in {
CONF_HORIZON: vol.In([60, 90, 120, 150, 180]),
+6
View File
@@ -12,6 +12,9 @@ CONF_SWITCH_COST = "switching_cost"
CONF_HEAT_COST = "heating_cost"
CONF_ROOM_MAX_AGE = "room_max_age_minutes"
CONF_WEATHER_MAX_AGE = "weather_max_age_minutes"
# Stale room sensors: False leaves them out (re-weighting the other rooms),
# True keeps using their last value for sensors that report only on change.
CONF_ROOM_STALE_KEEP = "room_stale_use_last_value"
CONF_BAND = "comfort_band"
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
@@ -32,6 +35,7 @@ WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "sn
DEFAULTS = {
CONF_ROOM_MAX_AGE: 30,
CONF_WEATHER_MAX_AGE: 120,
CONF_ROOM_STALE_KEEP: False,
CONF_HORIZON: 120,
CONF_MIN_ON: 15,
CONF_MIN_OFF: 10,
@@ -39,5 +43,7 @@ DEFAULTS = {
CONF_HEAT_COST: 0.025,
CONF_BAND: 0.15,
}
# A room sensor silent this long is treated as dead even when keeping last values.
ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60
SAMPLE_MINUTES = 5
BLOCK_MINUTES = 15
+28 -12
View File
@@ -14,7 +14,7 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE,
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES,
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES,
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
CONF_BAND, DEFAULTS,
@@ -186,15 +186,21 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
inputs_before = self._prediction_inputs()
room_quality = {}
temps = {}
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
for room in self.rooms:
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
value = None
if quality["status"] == "valid":
# Optionally trust sensors that report only on change, but never a
# sensor silent for a day: it is most likely dead.
if quality["status"] == "valid" or (
keep_stale and quality["status"] == "stale"
and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60):
state = snapshot[room]
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
if value is None or not 0 <= value <= 45:
quality["status"] = "invalid_value_or_unit"
value = None
quality["used"] = value is not None
room_quality[room] = quality
temps[room] = value
weather_states = {}
@@ -229,6 +235,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
"weather_model_inputs": ["temperature"],
"retired_room_models": sorted(self._retired),
"excluded_rooms": [room for room in self.rooms if temps[room] is None],
}
heater_valid = heater is not None and heater.state in ("on", "off")
heater_on = heater_valid and heater.state == "on"
@@ -241,14 +248,16 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._lag = None
self._relay_events.clear()
self._relay_events_dropped = False
if (not heater_valid or climate is None or outside is None or
any(temp is None for temp in temps.values())):
# Rooms without a usable reading are left out and the others carry on;
# only the shared inputs, or losing every room, pause the integration.
vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None}
if not heater_valid or climate is None or outside is None or not vals:
self._previous = None
return status
# No casting surprises: all rooms are valid here.
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms}
if self._previous is None:
# A room learns only across a poll pair where it was usable at both ends.
pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
if not pairs:
status["sample_result"] = "first_sample"
else:
dt = (now - self._previous["time"]).total_seconds()
@@ -267,7 +276,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.learners[room].learn(
self._previous["temps"][room], vals[room],
self._previous["outside"], self._lag["lag"], dt, heater_on)
for room in self.rooms
for room in pairs
]
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts()
@@ -294,9 +303,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except (AttributeError, TypeError, ValueError, OverflowError):
status["status"] = "invalid_weights"
return status
weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values())
# Left-out rooms take their weight with them; the rest are re-normalized.
used_weights = {room: weights[room] for room in vals}
weighted_temp = sum(used_weights[r] * vals[r] for r in vals) / sum(used_weights.values())
status.update({
"weighted_temperature": round(weighted_temp, 3),
"weight_coverage": round(sum(used_weights.values()) / sum(weights.values()), 3),
"outside_temperature": outside,
"reference_heat": heater_on,
"on_samples": self._on_samples,
@@ -325,8 +337,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
status["status"] = "waiting_for_target"
return status
status["target"] = target
# Do not act on a cold model: need ~17 hours of samples plus both conditions.
if status["samples"] < 200 or self._on_samples < 30 or self._off_samples < 30:
# Do not act on a cold model: every room used needs ~17 hours of samples
# plus both relay conditions.
used_models = [self.learners[room] for room in vals]
if (min(m.count for m in used_models) < 200 or min(m.on_count for m in used_models) < 30
or min(m.off_count for m in used_models) < 30):
status["status"] = "learning"
return status
try:
@@ -334,7 +349,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# never block Home Assistant's event loop with CPU work.
job = partial(
optimize,
vals, outside, self._lag["lag"], target, weights, self.learners,
vals, outside, self._lag["lag"], target, used_weights,
{room: self.learners[room] for room in vals},
heater_on, (now - heater.last_changed).total_seconds(),
horizon_minutes=int(self.config[CONF_HORIZON]),
minimum_on_minutes=int(self.config[CONF_MIN_ON]),
+10 -2
View File
@@ -10,7 +10,11 @@
"heater_entity": "Physical heating switch (read-only)",
"room_entities": "Individual room temperature sensors",
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting"
},
"data_description": {
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable."
}
},
"weather": {
@@ -63,7 +67,11 @@
"heating_cost": "Heating runtime cost",
"comfort_band": "Comfort tolerance (°C)",
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting"
},
"data_description": {
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable."
}
},
"weather": {
@@ -10,7 +10,11 @@
"heater_entity": "Physical heating switch (read-only)",
"room_entities": "Individual room temperature sensors",
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting"
},
"data_description": {
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable."
}
},
"weather": {
@@ -63,7 +67,11 @@
"heating_cost": "Heating runtime cost",
"comfort_band": "Comfort tolerance (°C)",
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting"
},
"data_description": {
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable."
}
},
"weather": {
@@ -10,7 +10,11 @@
"heater_entity": "Releul fizic (numai citire)",
"room_entities": "Senzorii individuali din camere",
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)"
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)",
"room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează"
},
"data_description": {
"room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită."
}
},
"weather": {
@@ -63,7 +67,11 @@
"heating_cost": "Penalizare durată încălzire",
"comfort_band": "Toleranță confort (°C)",
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)"
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)",
"room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează"
},
"data_description": {
"room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită."
}
},
"weather": {
+10 -2
View File
@@ -15,6 +15,7 @@ async def test_config_flow(hass, inputs):
validated = result["data_schema"](main)
assert validated["room_max_age_minutes"] == 30
assert validated["weather_max_age_minutes"] == 120
assert validated["room_stale_use_last_value"] is False
result = await hass.config_entries.flow.async_configure(result["flow_id"], validated)
assert result["step_id"] == "weather"
result = await hass.config_entries.flow.async_configure(
@@ -35,7 +36,7 @@ async def test_options_flow_keeps_settings(hass, entry, inputs):
result = await hass.config_entries.options.async_init(entry.entry_id)
main = result["data_schema"]({"climate_entity": "climate.reference",
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"],
"room_max_age_minutes": 45})
"room_max_age_minutes": 45, "room_stale_use_last_value": True})
result = await hass.config_entries.options.async_configure(result["flow_id"], main)
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
@@ -44,7 +45,14 @@ async def test_options_flow_keeps_settings(hass, entry, inputs):
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["room_max_age_minutes"] == 45
assert hass.data[DOMAIN][entry.entry_id].config["room_max_age_minutes"] == 45
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.config["room_max_age_minutes"] == 45
assert coordinator.config["room_stale_use_last_value"] is True
# The next options form shows the saved choice.
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["data_schema"]({"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"]})["room_stale_use_last_value"] is True
hass.config_entries.options.async_abort(result["flow_id"])
async def test_v1_entry_migrates_on_setup(hass, inputs):
+76
View File
@@ -539,3 +539,79 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
('valid', 0, 2400))
weather = data['input_quality']['weather']['temperature']['sensor.outside']
self.assertEqual(weather['max_age_seconds_24h'], 0) # kept reporting; tracked separately
def two_rooms(self, keep_last=False):
self.entry.data['room_entities'] = ['sensor.room', 'sensor.bedroom']
self.entry.data['weights'] = {'sensor.room': 3, 'sensor.bedroom': 1}
self.entry.data['room_stale_use_last_value'] = keep_last
self.set_state('sensor.bedroom', '18', unit_of_measurement='°C')
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
def fresh(self, *entities):
for entity in entities or ('sensor.room', 'sensor.bedroom', 'sensor.outside'):
self.states[entity].last_reported = self.now
async def test_stale_room_is_left_out_and_other_rooms_continue(self):
self.two_rooms()
await self.sample()
self.fresh('sensor.room', 'sensor.outside')
self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31)
data = await self.sample()
self.assertEqual(data['status'], 'learning')
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
rooms = data['input_quality']['rooms']
self.assertEqual((rooms['sensor.bedroom']['status'], rooms['sensor.bedroom']['used']), ('stale', False))
self.assertTrue(rooms['sensor.room']['used'])
self.assertEqual((data['weighted_temperature'], data['weight_coverage']), (20, 0.75))
self.assertEqual(data['sample_result'], 'learned')
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
self.assertEqual(counts(), (1, 0))
self.fresh() # bedroom reports again: no learning across its gap
data = await self.sample()
self.assertEqual((data['excluded_rooms'], data['weight_coverage']), ([], 1.0))
self.assertEqual(data['weighted_temperature'], 19.5)
self.assertEqual(counts(), (2, 0))
self.fresh()
await self.sample()
self.assertEqual(counts(), (3, 1))
async def test_left_out_room_is_not_used_for_prediction(self):
self.two_rooms()
model = self.c.learners['sensor.room'] # the bedroom stays untrained
model.count, model.on_count, model.off_count = 200, 100, 100
self.states['sensor.bedroom'].state = 'unavailable'
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
data = await self.sample()
self.assertEqual(data['status'], 'ready')
temps, weights, learners = (spy.call_args.args[i] for i in (0, 4, 5))
self.assertEqual((set(temps), weights, set(learners)),
({'sensor.room'}, {'sensor.room': 3.0}, {'sensor.room'}))
self.assertEqual(set(data['prediction_60m']), {'sensor.room'})
async def test_all_rooms_unusable_still_pauses(self):
self.two_rooms()
for room in ('sensor.room', 'sensor.bedroom'):
self.states[room].last_reported = self.now - timedelta(minutes=31)
data = await self.sample()
self.assertEqual(data['status'], 'waiting_for_sensors')
self.assertEqual(data['excluded_rooms'], ['sensor.room', 'sensor.bedroom'])
self.assertIsNone(data['proposal'])
async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self):
self.two_rooms(keep_last=True)
self.states['sensor.bedroom'].last_reported = self.now - timedelta(hours=2)
await self.sample()
self.fresh('sensor.room', 'sensor.outside')
data = await self.sample()
bedroom = data['input_quality']['rooms']['sensor.bedroom']
self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True))
self.assertEqual((data['excluded_rooms'], data['temperatures']['sensor.bedroom']), ([], 18))
self.assertEqual(self.c.learners['sensor.bedroom'].count, 1)
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
with self.subTest(state=state):
self.fresh('sensor.room', 'sensor.outside')
self.states['sensor.bedroom'].state = state
self.states['sensor.bedroom'].last_reported = self.now - age
data = await self.sample()
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
self.assertEqual(data['status'], 'learning')
+3 -1
View File
@@ -48,5 +48,7 @@ class FreshnessTests(unittest.TestCase):
data = json.loads((ROOT / name).read_text())
for section, step in (('config', 'user'), ('options', 'init')):
labels = data[section]['step'][step]['data']
for key in ('room_max_age_minutes', 'weather_max_age_minutes'):
for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'):
self.assertTrue(labels[key])
help_text = data[section]['step'][step]['data_description']
self.assertIn('24', help_text['room_stale_use_last_value'])