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
@@ -74,7 +74,10 @@ is available only while it is `ready`. Useful attributes:
|
|||||||
`invalid_timestamp`, `future_timestamp` or `invalid_value_or_unit`),
|
`invalid_timestamp`, `future_timestamp` or `invalid_value_or_unit`),
|
||||||
`age_seconds` (time since the sensor last reported to HA) and
|
`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
|
`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
|
- `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
|
warm) and its error bound. Learning waits while the bound is above 0.1, for
|
||||||
example for about 30 minutes after an HA restart.
|
example for about 30 minutes after an HA restart.
|
||||||
@@ -91,18 +94,31 @@ written to the Recorder; the other attributes are.
|
|||||||
### Choosing freshness limits
|
### Choosing freshness limits
|
||||||
|
|
||||||
Room and weather report-age limits are set during setup and in **Configure**
|
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
|
(5–1440 minutes; defaults 30 for rooms and 120 for weather).
|
||||||
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.
|
|
||||||
|
|
||||||
Within the limit, the last reading counts as current, so a too-generous limit lets
|
A room sensor that is older than its limit, unavailable, missing or invalid is
|
||||||
a frozen value be learned for a while. A too-tight limit pauses the integration
|
**left out**: the weighted temperature, learning and forecasts continue with the
|
||||||
whenever a sensor is quietly between reports. Freshness uses HA's `last_reported`:
|
other rooms, whose weights are re-normalized. Status shows it in `excluded_rooms`.
|
||||||
the time a sensor last reported to HA, even with an unchanged value. It is not the
|
Learning and forecasts pause only when no room is usable, or when the relay,
|
||||||
age of the physical measurement, so a provider re-publishing cached data looks
|
thermostat or outdoor temperature is missing. A room that reports again learns
|
||||||
fresh, and sensors that report only on change can look stale in a steady room.
|
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
|
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
|
`max_age_seconds_24h` among the sensors it covers. The relay and thermostat have no
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from homeassistant.core import callback
|
|||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
from .const import (
|
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,
|
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
||||||
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
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):
|
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
|
||||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
|
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
|
||||||
vol.Coerce(int), vol.Range(min=5, max=1440))
|
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:
|
if detailed:
|
||||||
for key, allowed in {
|
for key, allowed in {
|
||||||
CONF_HORIZON: vol.In([60, 90, 120, 150, 180]),
|
CONF_HORIZON: vol.In([60, 90, 120, 150, 180]),
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ CONF_SWITCH_COST = "switching_cost"
|
|||||||
CONF_HEAT_COST = "heating_cost"
|
CONF_HEAT_COST = "heating_cost"
|
||||||
CONF_ROOM_MAX_AGE = "room_max_age_minutes"
|
CONF_ROOM_MAX_AGE = "room_max_age_minutes"
|
||||||
CONF_WEATHER_MAX_AGE = "weather_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"
|
CONF_BAND = "comfort_band"
|
||||||
|
|
||||||
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
|
# 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 = {
|
DEFAULTS = {
|
||||||
CONF_ROOM_MAX_AGE: 30,
|
CONF_ROOM_MAX_AGE: 30,
|
||||||
CONF_WEATHER_MAX_AGE: 120,
|
CONF_WEATHER_MAX_AGE: 120,
|
||||||
|
CONF_ROOM_STALE_KEEP: False,
|
||||||
CONF_HORIZON: 120,
|
CONF_HORIZON: 120,
|
||||||
CONF_MIN_ON: 15,
|
CONF_MIN_ON: 15,
|
||||||
CONF_MIN_OFF: 10,
|
CONF_MIN_OFF: 10,
|
||||||
@@ -39,5 +43,7 @@ DEFAULTS = {
|
|||||||
CONF_HEAT_COST: 0.025,
|
CONF_HEAT_COST: 0.025,
|
||||||
CONF_BAND: 0.15,
|
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
|
SAMPLE_MINUTES = 5
|
||||||
BLOCK_MINUTES = 15
|
BLOCK_MINUTES = 15
|
||||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.storage import Store
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
from .const import (
|
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,
|
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_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
|
||||||
CONF_BAND, DEFAULTS,
|
CONF_BAND, DEFAULTS,
|
||||||
@@ -186,15 +186,21 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
inputs_before = self._prediction_inputs()
|
inputs_before = self._prediction_inputs()
|
||||||
room_quality = {}
|
room_quality = {}
|
||||||
temps = {}
|
temps = {}
|
||||||
|
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
||||||
for room in self.rooms:
|
for room in self.rooms:
|
||||||
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
||||||
value = None
|
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]
|
state = snapshot[room]
|
||||||
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||||
if value is None or not 0 <= value <= 45:
|
if value is None or not 0 <= value <= 45:
|
||||||
quality["status"] = "invalid_value_or_unit"
|
quality["status"] = "invalid_value_or_unit"
|
||||||
value = None
|
value = None
|
||||||
|
quality["used"] = value is not None
|
||||||
room_quality[room] = quality
|
room_quality[room] = quality
|
||||||
temps[room] = value
|
temps[room] = value
|
||||||
weather_states = {}
|
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_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
||||||
"weather_model_inputs": ["temperature"],
|
"weather_model_inputs": ["temperature"],
|
||||||
"retired_room_models": sorted(self._retired),
|
"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_valid = heater is not None and heater.state in ("on", "off")
|
||||||
heater_on = heater_valid and heater.state == "on"
|
heater_on = heater_valid and heater.state == "on"
|
||||||
@@ -241,14 +248,16 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._lag = None
|
self._lag = None
|
||||||
self._relay_events.clear()
|
self._relay_events.clear()
|
||||||
self._relay_events_dropped = False
|
self._relay_events_dropped = False
|
||||||
if (not heater_valid or climate is None or outside is None or
|
# Rooms without a usable reading are left out and the others carry on;
|
||||||
any(temp is None for temp in temps.values())):
|
# 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
|
self._previous = None
|
||||||
return status
|
return status
|
||||||
|
|
||||||
# No casting surprises: all rooms are valid here.
|
# A room learns only across a poll pair where it was usable at both ends.
|
||||||
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms}
|
pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
|
||||||
if self._previous is None:
|
if not pairs:
|
||||||
status["sample_result"] = "first_sample"
|
status["sample_result"] = "first_sample"
|
||||||
else:
|
else:
|
||||||
dt = (now - self._previous["time"]).total_seconds()
|
dt = (now - self._previous["time"]).total_seconds()
|
||||||
@@ -267,7 +276,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.learners[room].learn(
|
self.learners[room].learn(
|
||||||
self._previous["temps"][room], vals[room],
|
self._previous["temps"][room], vals[room],
|
||||||
self._previous["outside"], self._lag["lag"], dt, heater_on)
|
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"
|
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
|
||||||
self._update_counts()
|
self._update_counts()
|
||||||
@@ -294,9 +303,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
except (AttributeError, TypeError, ValueError, OverflowError):
|
except (AttributeError, TypeError, ValueError, OverflowError):
|
||||||
status["status"] = "invalid_weights"
|
status["status"] = "invalid_weights"
|
||||||
return status
|
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({
|
status.update({
|
||||||
"weighted_temperature": round(weighted_temp, 3),
|
"weighted_temperature": round(weighted_temp, 3),
|
||||||
|
"weight_coverage": round(sum(used_weights.values()) / sum(weights.values()), 3),
|
||||||
"outside_temperature": outside,
|
"outside_temperature": outside,
|
||||||
"reference_heat": heater_on,
|
"reference_heat": heater_on,
|
||||||
"on_samples": self._on_samples,
|
"on_samples": self._on_samples,
|
||||||
@@ -325,8 +337,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
status["status"] = "waiting_for_target"
|
status["status"] = "waiting_for_target"
|
||||||
return status
|
return status
|
||||||
status["target"] = target
|
status["target"] = target
|
||||||
# Do not act on a cold model: need ~17 hours of samples plus both conditions.
|
# Do not act on a cold model: every room used needs ~17 hours of samples
|
||||||
if status["samples"] < 200 or self._on_samples < 30 or self._off_samples < 30:
|
# 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"
|
status["status"] = "learning"
|
||||||
return status
|
return status
|
||||||
try:
|
try:
|
||||||
@@ -334,7 +349,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# never block Home Assistant's event loop with CPU work.
|
# never block Home Assistant's event loop with CPU work.
|
||||||
job = partial(
|
job = partial(
|
||||||
optimize,
|
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(),
|
heater_on, (now - heater.last_changed).total_seconds(),
|
||||||
horizon_minutes=int(self.config[CONF_HORIZON]),
|
horizon_minutes=int(self.config[CONF_HORIZON]),
|
||||||
minimum_on_minutes=int(self.config[CONF_MIN_ON]),
|
minimum_on_minutes=int(self.config[CONF_MIN_ON]),
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
"heater_entity": "Physical heating switch (read-only)",
|
"heater_entity": "Physical heating switch (read-only)",
|
||||||
"room_entities": "Individual room temperature sensors",
|
"room_entities": "Individual room temperature sensors",
|
||||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
"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": {
|
"weather": {
|
||||||
@@ -63,7 +67,11 @@
|
|||||||
"heating_cost": "Heating runtime cost",
|
"heating_cost": "Heating runtime cost",
|
||||||
"comfort_band": "Comfort tolerance (°C)",
|
"comfort_band": "Comfort tolerance (°C)",
|
||||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
"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": {
|
"weather": {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
"heater_entity": "Physical heating switch (read-only)",
|
"heater_entity": "Physical heating switch (read-only)",
|
||||||
"room_entities": "Individual room temperature sensors",
|
"room_entities": "Individual room temperature sensors",
|
||||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
"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": {
|
"weather": {
|
||||||
@@ -63,7 +67,11 @@
|
|||||||
"heating_cost": "Heating runtime cost",
|
"heating_cost": "Heating runtime cost",
|
||||||
"comfort_band": "Comfort tolerance (°C)",
|
"comfort_band": "Comfort tolerance (°C)",
|
||||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
"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": {
|
"weather": {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
"heater_entity": "Releul fizic (numai citire)",
|
"heater_entity": "Releul fizic (numai citire)",
|
||||||
"room_entities": "Senzorii individuali din camere",
|
"room_entities": "Senzorii individuali din camere",
|
||||||
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
|
"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": {
|
"weather": {
|
||||||
@@ -63,7 +67,11 @@
|
|||||||
"heating_cost": "Penalizare durată încălzire",
|
"heating_cost": "Penalizare durată încălzire",
|
||||||
"comfort_band": "Toleranță confort (°C)",
|
"comfort_band": "Toleranță confort (°C)",
|
||||||
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
|
"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": {
|
"weather": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async def test_config_flow(hass, inputs):
|
|||||||
validated = result["data_schema"](main)
|
validated = result["data_schema"](main)
|
||||||
assert validated["room_max_age_minutes"] == 30
|
assert validated["room_max_age_minutes"] == 30
|
||||||
assert validated["weather_max_age_minutes"] == 120
|
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)
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], validated)
|
||||||
assert result["step_id"] == "weather"
|
assert result["step_id"] == "weather"
|
||||||
result = await hass.config_entries.flow.async_configure(
|
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)
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
main = result["data_schema"]({"climate_entity": "climate.reference",
|
main = result["data_schema"]({"climate_entity": "climate.reference",
|
||||||
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"],
|
"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"], main)
|
||||||
result = await hass.config_entries.options.async_configure(
|
result = await hass.config_entries.options.async_configure(
|
||||||
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
|
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
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert entry.options["room_max_age_minutes"] == 45
|
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):
|
async def test_v1_entry_migrates_on_setup(hass, inputs):
|
||||||
|
|||||||
@@ -539,3 +539,79 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
('valid', 0, 2400))
|
('valid', 0, 2400))
|
||||||
weather = data['input_quality']['weather']['temperature']['sensor.outside']
|
weather = data['input_quality']['weather']['temperature']['sensor.outside']
|
||||||
self.assertEqual(weather['max_age_seconds_24h'], 0) # kept reporting; tracked separately
|
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')
|
||||||
@@ -48,5 +48,7 @@ class FreshnessTests(unittest.TestCase):
|
|||||||
data = json.loads((ROOT / name).read_text())
|
data = json.loads((ROOT / name).read_text())
|
||||||
for section, step in (('config', 'user'), ('options', 'init')):
|
for section, step in (('config', 'user'), ('options', 'init')):
|
||||||
labels = data[section]['step'][step]['data']
|
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])
|
self.assertTrue(labels[key])
|
||||||
|
help_text = data[section]['step'][step]['data_description']
|
||||||
|
self.assertIn('24', help_text['room_stale_use_last_value'])
|
||||||
Reference in new issue
Block a user