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:
1 parent
3c949333bd
commit
d2bd398a0f
9 files changed
+179
-11
No files matched your search
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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", {})}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user