There was no evidence of how accurate the thermal model is: the only number was an in-sample 5-minute error. At every poll, record each room's reading, the outdoor temperature, the radiator-lag estimate and the model parameters. 15, 30, 60 and 120 minutes later, replay the room from that poll with the parameters it had then (out of sample), the outdoor temperature held constant and the radiator lag that actually followed from the relay's real history, and compare with the reading now. A no-change forecast is scored alongside. Only spans with regular polls and a well-known lag are scored. 7 days of per-room statistics are saved with the model (optional "evaluation" payload key; about 18 KB at most). Four new sensors, Heating MPC Forecast Error 15/30/60/120 min, show the mean absolute error with bias, persistence error, skill and per-room detail. On the synthetic lagged plant the 60-minute error is 0.03 °C against 0.39 °C for persistence. Room forecast entities gain forecast_15min and plan_15min attributes and are labelled counterfactual. Volatile attributes are unrecorded. The model is now saved after scoring, so saved statistics are current. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.8 KiB
Python
150 lines
5.8 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 UnitOfTemperature
|
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
|
|
from .const import DOMAIN
|
|
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 += [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"),
|
|
}
|