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
@@ -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]),