"""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"), }