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>
205 lines
7.9 KiB
Python
205 lines
7.9 KiB
Python
"""Diagnostics and predicted temperatures exposed as HA sensors."""
|
|
from __future__ import annotations
|
|
|
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
|
from homeassistant.const import PERCENTAGE, UnitOfTemperature
|
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
|
|
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),
|
|
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)
|
|
|
|
|
|
class MPCStatus(CoordinatorEntity, SensorEntity):
|
|
_attr_has_entity_name = True
|
|
_attr_name = "Heating MPC Status"
|
|
_attr_icon = "mdi:brain"
|
|
# Per-source report ages change every poll; recording them adds a new
|
|
# attribute row to the database every five minutes. Current values stay visible.
|
|
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison"})
|
|
|
|
def __init__(self, coordinator, entry_id):
|
|
super().__init__(coordinator)
|
|
self._attr_unique_id = f"{entry_id}_status"
|
|
|
|
@property
|
|
def native_value(self):
|
|
return self.coordinator.data.get("status")
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
data = self.coordinator.data
|
|
# Forecast details live on their own entities.
|
|
return {key: value for key, value in data.items() if key not in (
|
|
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy")}
|
|
|
|
|
|
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
|
_attr_has_entity_name = True
|
|
_attr_name = "Heating MPC Weighted Temperature"
|
|
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
|
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
|
|
|
def __init__(self, coordinator, entry_id):
|
|
super().__init__(coordinator)
|
|
self._attr_unique_id = f"{entry_id}_weighted_temp"
|
|
|
|
@property
|
|
def native_value(self):
|
|
return self.coordinator.data.get("weighted_temperature")
|
|
|
|
|
|
class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
|
_attr_has_entity_name = True
|
|
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
|
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
|
_attr_icon = "mdi:chart-line"
|
|
|
|
def __init__(self, coordinator, entry_id, room):
|
|
super().__init__(coordinator)
|
|
self.room = room
|
|
self._attr_unique_id = f"{entry_id}_{room}_predicted_60min"
|
|
self._attr_name = f"MPC {room} 60min Prediction"
|
|
|
|
# Change every poll; the 60-minute value itself is recorded.
|
|
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min"})
|
|
|
|
@property
|
|
def native_value(self):
|
|
return self.coordinator.data.get("prediction_60m", {}).get(self.room)
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
data = self.coordinator.data
|
|
return {
|
|
"forecast_15min": data.get("prediction_trajectory", {}).get(self.room),
|
|
"plan_15min": data.get("plan_15min"),
|
|
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
|
|
}
|
|
|
|
class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
|
"""Display chosen outdoor temperature and all currently valid alternatives."""
|
|
_attr_has_entity_name = True
|
|
_attr_name = "MPC Outdoor Temperature"
|
|
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
|
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
|
_attr_icon = "mdi:thermometer"
|
|
|
|
def __init__(self, coordinator, entry_id):
|
|
super().__init__(coordinator)
|
|
self._attr_unique_id = f"{entry_id}_outside_temperature"
|
|
|
|
@property
|
|
def native_value(self):
|
|
return self.coordinator.data.get("weather", {}).get("temperature")
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
data = self.coordinator.data
|
|
return {
|
|
"active_source": data.get("weather_active_sources", {}).get("temperature"),
|
|
"alternatives": data.get("weather_comparison", {}).get("temperature", {}),
|
|
}
|
|
|
|
|
|
class MPCForecastError(CoordinatorEntity, SensorEntity):
|
|
"""Mean absolute error of the thermal model at one horizon, replayed against reality."""
|
|
_attr_has_entity_name = True
|
|
_attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA
|
|
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
|
_attr_suggested_display_precision = 2
|
|
_attr_icon = "mdi:target"
|
|
_unrecorded_attributes = frozenset({"rooms"})
|
|
|
|
def __init__(self, coordinator, entry_id, horizon):
|
|
super().__init__(coordinator)
|
|
self.horizon = horizon
|
|
self._attr_unique_id = f"{entry_id}_forecast_error_{horizon}min"
|
|
self._attr_name = f"Heating MPC Forecast Error {horizon} min"
|
|
|
|
@property
|
|
def _accuracy(self):
|
|
return self.coordinator.data.get("forecast_accuracy", {}).get(self.horizon, {})
|
|
|
|
@property
|
|
def native_value(self):
|
|
return self._accuracy.get("mae")
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
accuracy = self._accuracy
|
|
return {
|
|
"samples": accuracy.get("samples", 0),
|
|
"bias": accuracy.get("bias"),
|
|
"persistence_mae": accuracy.get("persistence_mae"),
|
|
"skill": accuracy.get("skill"),
|
|
"rooms": accuracy.get("rooms", {}),
|
|
"window_days": WINDOW_DAYS,
|
|
"unit_of_attributes": "°C",
|
|
"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", {})}
|