From 3076beebfb8335bf94055b3332c87142d0cac430 Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Mon, 26 Oct 2026 22:07:18 +0200 Subject: [PATCH] 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 --- README.md | 40 +++++++--- custom_components/heating_mpc/config_flow.py | 4 +- custom_components/heating_mpc/const.py | 6 ++ custom_components/heating_mpc/coordinator.py | 40 +++++++--- custom_components/heating_mpc/strings.json | 12 ++- .../heating_mpc/translations/en.json | 12 ++- .../heating_mpc/translations/ro.json | 12 ++- tests/ha/test_config_flow.py | 12 ++- tests/test_coordinator.py | 76 +++++++++++++++++++ tests/test_data_quality.py | 4 +- 10 files changed, 184 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 123ae99..f0c1ba7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/heating_mpc/config_flow.py b/custom_components/heating_mpc/config_flow.py index 789a9a7..33f89e0 100644 --- a/custom_components/heating_mpc/config_flow.py +++ b/custom_components/heating_mpc/config_flow.py @@ -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]), diff --git a/custom_components/heating_mpc/const.py b/custom_components/heating_mpc/const.py index 2724fe4..3b01f1b 100644 --- a/custom_components/heating_mpc/const.py +++ b/custom_components/heating_mpc/const.py @@ -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 diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index a491eff..1baacfb 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -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]), diff --git a/custom_components/heating_mpc/strings.json b/custom_components/heating_mpc/strings.json index d7f7ea0..c87db87 100644 --- a/custom_components/heating_mpc/strings.json +++ b/custom_components/heating_mpc/strings.json @@ -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": { diff --git a/custom_components/heating_mpc/translations/en.json b/custom_components/heating_mpc/translations/en.json index d7f7ea0..c87db87 100644 --- a/custom_components/heating_mpc/translations/en.json +++ b/custom_components/heating_mpc/translations/en.json @@ -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": { diff --git a/custom_components/heating_mpc/translations/ro.json b/custom_components/heating_mpc/translations/ro.json index e381570..fb11683 100644 --- a/custom_components/heating_mpc/translations/ro.json +++ b/custom_components/heating_mpc/translations/ro.json @@ -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": { diff --git a/tests/ha/test_config_flow.py b/tests/ha/test_config_flow.py index 8c8d99e..9888e7c 100644 --- a/tests/ha/test_config_flow.py +++ b/tests/ha/test_config_flow.py @@ -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): diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 564f233..c1e37d6 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -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') diff --git a/tests/test_data_quality.py b/tests/test_data_quality.py index 310ff30..fdb0502 100644 --- a/tests/test_data_quality.py +++ b/tests/test_data_quality.py @@ -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'])