From d2bd398a0f0030e4f161b2bb2e2d88f136086730 Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Mon, 26 Oct 2026 22:39:25 +0200 Subject: [PATCH] Show learning activity with Learning Progress and Last Sample sensors During a first test the Status entity only said "learning" for the first half hour (the radiator-lag wait after a restart), and the attributes explaining why were hard to find. Add two sensors: - Learning Progress (%): for the least trained room, the mean of its three readiness requirements (200 samples, 30 ON, 30 OFF), each capped at 100 %; 100 % means ready. Attributes show the counts. - Last Sample: an enum of the last poll's learning outcome, with English and Romanian state names (e.g. "Waiting for radiators to settle"). Its history shows when learning happens. Polls blocked by missing shared inputs now report waiting_for_inputs. Readiness thresholds move to const.py. Co-Authored-By: Claude Opus 5.5 --- custom_components/heating_mpc/const.py | 9 +++ custom_components/heating_mpc/coordinator.py | 23 +++++-- custom_components/heating_mpc/sensor.py | 61 ++++++++++++++++++- custom_components/heating_mpc/strings.json | 16 +++++ .../heating_mpc/translations/en.json | 16 +++++ .../heating_mpc/translations/ro.json | 16 +++++ tests/ha/test_lifecycle.py | 32 +++++++++- tests/test_coordinator.py | 11 ++++ tests/test_data_quality.py | 6 ++ 9 files changed, 179 insertions(+), 11 deletions(-) diff --git a/custom_components/heating_mpc/const.py b/custom_components/heating_mpc/const.py index 3b01f1b..06e06f7 100644 --- a/custom_components/heating_mpc/const.py +++ b/custom_components/heating_mpc/const.py @@ -46,4 +46,13 @@ DEFAULTS = { # 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 +# A room's model is trusted for forecasts after this many accepted samples. +READY_SAMPLES = 200 +READY_ON_SAMPLES = 30 +READY_OFF_SAMPLES = 30 +# Outcome of each poll for learning (Status sample_result, Last Sample sensor). +SAMPLE_RESULTS = ( + "waiting_for_inputs", "first_sample", "learned", "partly_rejected", + "interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain", +) BLOCK_MINUTES = 15 diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 06549bd..a73c9ba 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -17,7 +17,7 @@ from .const import ( 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, + CONF_BAND, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES, ) from .data_quality import ReportAgeHistory, freshness from .evaluation import ForecastEvaluator @@ -159,6 +159,17 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._relay_events_dropped = False return steady + def _count_result(self, status: dict[str, Any], result: str) -> None: + """Record how this poll was handled for learning, and the learning progress.""" + status["sample_result"] = result + self._sample_results[result] = self._sample_results.get(result, 0) + 1 + status["sample_results_since_start"] = dict(self._sample_results) + # Least trained room: mean of its three readiness requirements (100 = ready). + status["learning_progress"] = round(100 * min( + (min(1.0, m.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES) + + min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3 + for m in self.learners.values()), 1) + def _prediction_inputs(self) -> tuple: """Values the prediction depends on, used to detect changes during calculation.""" heater = self.config[CONF_HEATER] @@ -260,6 +271,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._previous = None self._evaluator.reset_window() status["forecast_accuracy"] = self._evaluator.summary(self.rooms) + self._count_result(status, "waiting_for_inputs") return status # A room learns only across a poll pair where it was usable at both ends. @@ -290,9 +302,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._update_counts() learned = True - result = status["sample_result"] - self._sample_results[result] = self._sample_results.get(result, 0) + 1 - status["sample_results_since_start"] = dict(self._sample_results) + self._count_result(status, status["sample_result"]) self._previous = { "time": now, "temps": vals, "outside": outside, "heater_on": heater_on, "outside_source": active_sources["temperature"], @@ -359,8 +369,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): # 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): + if (min(m.count for m in used_models) < READY_SAMPLES + or min(m.on_count for m in used_models) < READY_ON_SAMPLES + or min(m.off_count for m in used_models) < READY_OFF_SAMPLES): status["status"] = "learning" return status try: diff --git a/custom_components/heating_mpc/sensor.py b/custom_components/heating_mpc/sensor.py index e2522a1..b2fd8c4 100644 --- a/custom_components/heating_mpc/sensor.py +++ b/custom_components/heating_mpc/sensor.py @@ -2,16 +2,19 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass -from homeassistant.const import UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import ( + DOMAIN, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES, SAMPLE_RESULTS, +) from .evaluation import HORIZONS, WINDOW_DAYS async def async_setup_entry(hass, entry, async_add_entities) -> None: coordinator = hass.data[DOMAIN][entry.entry_id] - entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id)] + entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id), + MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)] entities += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms] entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS] async_add_entities(entities) @@ -147,3 +150,55 @@ class MPCForecastError(CoordinatorEntity, SensorEntity): "basis": ("thermal model replayed with the actual relay history; outdoor " "temperature held at the start value"), } + + +class MPCLearningProgress(CoordinatorEntity, SensorEntity): + """How close the least trained room is to being used for forecasts.""" + _attr_has_entity_name = True + _attr_name = "Heating MPC Learning Progress" + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 0 + _attr_icon = "mdi:school-outline" + + def __init__(self, coordinator, entry_id): + super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}_learning_progress" + + @property + def native_value(self): + return self.coordinator.data.get("learning_progress") + + @property + def extra_state_attributes(self): + data = self.coordinator.data + return { + "samples": data.get("samples"), + "on_samples": data.get("on_samples"), + "off_samples": data.get("off_samples"), + "required": {"samples": READY_SAMPLES, "on_samples": READY_ON_SAMPLES, + "off_samples": READY_OFF_SAMPLES}, + } + + +class MPCLastSample(CoordinatorEntity, SensorEntity): + """What the last poll did for learning; its history shows when learning happens.""" + _attr_has_entity_name = True + _attr_name = "Heating MPC Last Sample" + _attr_translation_key = "last_sample" # translated state names + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = list(SAMPLE_RESULTS) + _attr_icon = "mdi:clipboard-pulse-outline" + _unrecorded_attributes = frozenset({"counts_since_start"}) + + def __init__(self, coordinator, entry_id): + super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}_last_sample" + + @property + def native_value(self): + return self.coordinator.data.get("sample_result") + + @property + def extra_state_attributes(self): + return {"counts_since_start": self.coordinator.data.get("sample_results_since_start", {})} diff --git a/custom_components/heating_mpc/strings.json b/custom_components/heating_mpc/strings.json index c87db87..85de6e8 100644 --- a/custom_components/heating_mpc/strings.json +++ b/custom_components/heating_mpc/strings.json @@ -104,5 +104,21 @@ "invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." } + }, + "entity": { + "sensor": { + "last_sample": { + "state": { + "waiting_for_inputs": "Waiting for inputs", + "first_sample": "First sample", + "learned": "Learned", + "partly_rejected": "Partly rejected (outlier)", + "interval_out_of_range": "Irregular interval", + "relay_changed": "Relay switched", + "outside_source_changed": "Outdoor source changed", + "lag_uncertain": "Waiting for radiators to settle" + } + } + } } } diff --git a/custom_components/heating_mpc/translations/en.json b/custom_components/heating_mpc/translations/en.json index c87db87..85de6e8 100644 --- a/custom_components/heating_mpc/translations/en.json +++ b/custom_components/heating_mpc/translations/en.json @@ -104,5 +104,21 @@ "invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." } + }, + "entity": { + "sensor": { + "last_sample": { + "state": { + "waiting_for_inputs": "Waiting for inputs", + "first_sample": "First sample", + "learned": "Learned", + "partly_rejected": "Partly rejected (outlier)", + "interval_out_of_range": "Irregular interval", + "relay_changed": "Relay switched", + "outside_source_changed": "Outdoor source changed", + "lag_uncertain": "Waiting for radiators to settle" + } + } + } } } diff --git a/custom_components/heating_mpc/translations/ro.json b/custom_components/heating_mpc/translations/ro.json index fb11683..518635e 100644 --- a/custom_components/heating_mpc/translations/ro.json +++ b/custom_components/heating_mpc/translations/ro.json @@ -104,5 +104,21 @@ "invalid_rooms": "Selectează 1–12 senzori diferiți.", "invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor." } + }, + "entity": { + "sensor": { + "last_sample": { + "state": { + "waiting_for_inputs": "Așteaptă datele de intrare", + "first_sample": "Prima măsurătoare", + "learned": "Învățat", + "partly_rejected": "Respins parțial (valoare aberantă)", + "interval_out_of_range": "Interval neregulat", + "relay_changed": "Releul a comutat", + "outside_source_changed": "Sursa exterioară s-a schimbat", + "lag_uncertain": "Așteaptă stabilizarea caloriferelor" + } + } + } } } diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index f98a16b..cc9cc25 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -24,7 +24,7 @@ async def test_setup_reload_unload(hass, entry, inputs): assert coordinator.config_entry is entry registry = er.async_get(hass) entities = er.async_entries_for_config_entry(registry, entry.entry_id) - assert len(entities) == 9 # 5 core + 4 forecast-error sensors + assert len(entities) == 11 # 7 core + 4 forecast-error sensors ids = {e.unique_id: e.entity_id for e in entities} assert coordinator.data["status"] == "learning" status = hass.states.get(ids[f"{entry.entry_id}_status"]) @@ -55,7 +55,7 @@ async def test_options_reload_removes_only_old_room(hass, entry, inputs): assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"] assert registry.async_get(old) is None assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min") - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 9 + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11 async def test_storage_restore_and_remove(hass, entry, inputs): @@ -238,3 +238,31 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs, assert state.attributes["unit_of_measurement"] == "°C" assert "rooms" in state.state_info["unrecorded_attributes"] assert hass.states.get(ids[30]).state == "unknown" + + +async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer): + await setup(hass, entry) + registry = er.async_get(hass) + progress_id = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_learning_progress") + last_id = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_last_sample") + assert hass.states.get(progress_id).state == "0.0" + last = hass.states.get(last_id) + assert last.state == "first_sample" + assert "lag_uncertain" in last.attributes["options"] + future = dt_util.utcnow() + timedelta(minutes=5, seconds=1) + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(last_id).state == "learned" + # One OFF sample: (1/200 + 0/30 + 1/30) / 3 + assert float(hass.states.get(progress_id).state) == 1.3 + progress = hass.states.get(progress_id).attributes + assert (progress["samples"], progress["off_samples"], progress["required"]["samples"]) == (1, 1, 200) + hass.states.async_set("switch.boiler", "unavailable") + future += timedelta(minutes=5) + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + last = hass.states.get(last_id) + assert last.state == "waiting_for_inputs" + assert last.attributes["counts_since_start"] == {"first_sample": 1, "learned": 1, "waiting_for_inputs": 1} diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index a74e2f6..d119152 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -686,3 +686,14 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(data['forecast_accuracy'][15]['samples'], 2) self.fresh('sensor.room', 'sensor.outside') self.assertEqual((await self.sample())['forecast_accuracy'][15]['samples'], 3) + + async def test_learning_progress_and_waiting_result(self): + data = await self.sample() + self.assertEqual((data['sample_result'], data['learning_progress']), ('first_sample', 0)) + self.ready() + self.fresh('sensor.room', 'sensor.outside') + self.assertEqual((await self.sample())['learning_progress'], 100) + self.states['sensor.outside'].state = 'unavailable' + data = await self.sample() + self.assertEqual(data['sample_result'], 'waiting_for_inputs') + self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1) diff --git a/tests/test_data_quality.py b/tests/test_data_quality.py index fdb0502..41f38bf 100644 --- a/tests/test_data_quality.py +++ b/tests/test_data_quality.py @@ -9,6 +9,9 @@ ROOT = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc' spec = importlib.util.spec_from_file_location('quality', ROOT / 'data_quality.py') quality = importlib.util.module_from_spec(spec) spec.loader.exec_module(quality) +spec = importlib.util.spec_from_file_location('heating_mpc_const', ROOT / 'const.py') +const = importlib.util.module_from_spec(spec) +spec.loader.exec_module(const) class FreshnessTests(unittest.TestCase): @@ -52,3 +55,6 @@ class FreshnessTests(unittest.TestCase): self.assertTrue(labels[key]) help_text = data[section]['step'][step]['data_description'] self.assertIn('24', help_text['room_stale_use_last_value']) + # Every poll outcome has a translated state name. + states = data['entity']['sensor']['last_sample']['state'] + self.assertEqual(set(states), set(const.SAMPLE_RESULTS))