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

+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": {