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 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 22:39:25 +02:00
1 parent 3c949333bd
commit d2bd398a0f
9 files changed
+179 -11

No files matched your search

+9
View File
@@ -46,4 +46,13 @@ DEFAULTS = {
# A room sensor silent this long is treated as dead even when keeping last values. # A room sensor silent this long is treated as dead even when keeping last values.
ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60 ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60
SAMPLE_MINUTES = 5 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 BLOCK_MINUTES = 15
+17 -6
View File
@@ -17,7 +17,7 @@ from .const import (
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES, 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, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
) )
from .data_quality import ReportAgeHistory, freshness from .data_quality import ReportAgeHistory, freshness
from .evaluation import ForecastEvaluator from .evaluation import ForecastEvaluator
@@ -159,6 +159,17 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._relay_events_dropped = False self._relay_events_dropped = False
return steady 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: def _prediction_inputs(self) -> tuple:
"""Values the prediction depends on, used to detect changes during calculation.""" """Values the prediction depends on, used to detect changes during calculation."""
heater = self.config[CONF_HEATER] heater = self.config[CONF_HEATER]
@@ -260,6 +271,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._previous = None self._previous = None
self._evaluator.reset_window() self._evaluator.reset_window()
status["forecast_accuracy"] = self._evaluator.summary(self.rooms) status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
self._count_result(status, "waiting_for_inputs")
return status return status
# A room learns only across a poll pair where it was usable at both ends. # 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() self._update_counts()
learned = True learned = True
result = status["sample_result"] self._count_result(status, status["sample_result"])
self._sample_results[result] = self._sample_results.get(result, 0) + 1
status["sample_results_since_start"] = dict(self._sample_results)
self._previous = { self._previous = {
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on, "time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
"outside_source": active_sources["temperature"], "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 # Do not act on a cold model: every room used needs ~17 hours of samples
# plus both relay conditions. # plus both relay conditions.
used_models = [self.learners[room] for room in vals] 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 if (min(m.count for m in used_models) < READY_SAMPLES
or min(m.off_count for m in used_models) < 30): 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" status["status"] = "learning"
return status return status
try: try:
+58 -3
View File
@@ -2,16 +2,19 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass 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 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 from .evaluation import HORIZONS, WINDOW_DAYS
async def async_setup_entry(hass, entry, async_add_entities) -> None: async def async_setup_entry(hass, entry, async_add_entities) -> None:
coordinator = hass.data[DOMAIN][entry.entry_id] 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 += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms]
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS] entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
async_add_entities(entities) async_add_entities(entities)
@@ -147,3 +150,55 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
"basis": ("thermal model replayed with the actual relay history; outdoor " "basis": ("thermal model replayed with the actual relay history; outdoor "
"temperature held at the start value"), "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", {})}
@@ -104,5 +104,21 @@
"invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_rooms": "Select 1–12 distinct room sensors.",
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from 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"
}
}
}
} }
} }
@@ -104,5 +104,21 @@
"invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_rooms": "Select 1–12 distinct room sensors.",
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from 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"
}
}
}
} }
} }
@@ -104,5 +104,21 @@
"invalid_rooms": "Selectează 1–12 senzori diferiți.", "invalid_rooms": "Selectează 1–12 senzori diferiți.",
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor." "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"
}
}
}
} }
} }
+30 -2
View File
@@ -24,7 +24,7 @@ async def test_setup_reload_unload(hass, entry, inputs):
assert coordinator.config_entry is entry assert coordinator.config_entry is entry
registry = er.async_get(hass) registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, entry.entry_id) 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} ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning" assert coordinator.data["status"] == "learning"
status = hass.states.get(ids[f"{entry.entry_id}_status"]) 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 hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
assert registry.async_get(old) is None assert registry.async_get(old) is None
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min") 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): 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 state.attributes["unit_of_measurement"] == "°C"
assert "rooms" in state.state_info["unrecorded_attributes"] assert "rooms" in state.state_info["unrecorded_attributes"]
assert hass.states.get(ids[30]).state == "unknown" 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}
+11
View File
@@ -686,3 +686,14 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data['forecast_accuracy'][15]['samples'], 2) self.assertEqual(data['forecast_accuracy'][15]['samples'], 2)
self.fresh('sensor.room', 'sensor.outside') self.fresh('sensor.room', 'sensor.outside')
self.assertEqual((await self.sample())['forecast_accuracy'][15]['samples'], 3) 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)
+6
View File
@@ -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') spec = importlib.util.spec_from_file_location('quality', ROOT / 'data_quality.py')
quality = importlib.util.module_from_spec(spec) quality = importlib.util.module_from_spec(spec)
spec.loader.exec_module(quality) 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): class FreshnessTests(unittest.TestCase):
@@ -52,3 +55,6 @@ class FreshnessTests(unittest.TestCase):
self.assertTrue(labels[key]) self.assertTrue(labels[key])
help_text = data[section]['step'][step]['data_description'] help_text = data[section]['step'][step]['data_description']
self.assertIn('24', help_text['room_stale_use_last_value']) 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))