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))