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:
1 parent
cd8b65ac2f
commit
3076beebfb
10 files changed
+184
-34
No files matched your search
@@ -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]),
|
||||
|
||||
@@ -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
|
||||
@@ -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,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": {
|
||||
|
||||
Reference in new issue
Block a user