Score forecasts against reality; expose forecast trajectories

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>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 22:35:13 +02:00
1 parent 43915ba810
commit 3afb6688a5
9 files changed
+454 -16

No files matched your search

+24 -10
View File
@@ -20,6 +20,7 @@ from .const import (
CONF_BAND, DEFAULTS,
)
from .data_quality import ReportAgeHistory, freshness
from .evaluation import ForecastEvaluator
from .physics import (
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
lag_from_time_in_state, optimize, relay_segments,
@@ -45,6 +46,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory()
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
# How each poll was handled since start, to explain slow learning.
self._sample_results: dict[str, int] = {}
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
@@ -68,6 +70,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]:
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return
self._evaluator.load(stored.get("evaluation"))
# Models carry room identity, so changing the room list is safe. Models of
# rooms no longer configured are retained, most recently removed last.
for key in ("retired_learners", "learners"): # current learners take precedence
@@ -255,10 +258,13 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None}
if not heater_valid or climate is None or outside is None or not vals:
self._previous = None
self._evaluator.reset_window()
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
return status
# A room learns only across a poll pair where it was usable at both ends.
pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
learned = False
if not pairs:
status["sample_result"] = "first_sample"
else:
@@ -282,16 +288,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
]
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts()
# Persist at each sample so restart does not erase hours of training.
await self._store.async_save({
"schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
# Optional; older 0.2.x readers ignore it and keep working.
"retired_learners": self._retired,
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
"outside_source": active_sources["temperature"],
})
learned = True
result = status["sample_result"]
self._sample_results[result] = self._sample_results.get(result, 0) + 1
@@ -300,6 +297,22 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
"outside_source": active_sources["temperature"],
}
# Parameters after this poll's learning: forecasts from here use only data so far.
self._evaluator.observe(now, vals, outside, self._lag["lag"], self._lag["uncertainty"],
{room: tuple(self.learners[room].theta) for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned:
# Persist at each sample so restart does not erase hours of training.
await self._store.async_save({
"schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
# Optional keys; older 0.2.x readers ignore them and keep working.
"retired_learners": self._retired,
"evaluation": self._evaluator.as_dict(),
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
"outside_source": active_sources["temperature"],
})
weights = self.config.get(CONF_WEIGHTS, {})
try:
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
@@ -381,6 +394,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"prediction_60m": output["predicted_60m"],
"prediction_end": output["predicted_end"],
"plan_15min": output["plan_15min"], "objective": output["cost"],
"prediction_trajectory": output["trajectory_15min"],
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error",
})
return status
+161
View File
@@ -0,0 +1,161 @@
"""Walk-forward evaluation of the thermal model against what actually happened.
Pure Python; no Home Assistant imports.
At every poll the readings, the outdoor temperature, the radiator-lag estimate
and each room's model parameters are recorded. When 15/30/60/120 minutes have
passed, each room is replayed from that earlier poll with the parameters it had
then (so later data is never used), the outdoor temperature held at its starting
value, and the radiator lag that actually followed from the relay's real ON/OFF
history. The replayed temperature is compared with the reading now.
This scores the thermal model, not the MPC's proposed plan: Generic Thermostat
controls the relay, so the plan is never executed. A persistence forecast (no
change) is scored alongside as the baseline to beat.
"""
from __future__ import annotations
from collections import deque
from datetime import datetime, timedelta
from math import isfinite
from typing import Any
from .physics import Learner
HORIZONS = (15, 30, 60, 120) # minutes
WINDOW_DAYS = 7
STEP_SECONDS = 300
def replay(temp: float, outside: float, theta: tuple[float, float, float],
steps: list[tuple[float, float]]) -> float:
"""Advance one room through (end-of-step lag, step seconds) with fixed parameters."""
model = Learner(theta=list(theta))
for lag, seconds in steps:
# Same 5-minute model and jitter normalization as learning.
temp += model.predict_delta(temp, outside, lag) * seconds / STEP_SECONDS
return temp
class ForecastEvaluator:
"""Score replayed forecasts per room and horizon over the last WINDOW_DAYS days."""
def __init__(self, max_lag_uncertainty: float) -> None:
self._max_lag_uncertainty = max_lag_uncertainty
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
# UTC day -> horizon -> room -> [count, sum error, sum |error|, sum |persistence error|]
self._days: dict[str, dict[str, dict[str, list[float]]]] = {}
def reset_window(self) -> None:
"""Inputs were missing: no forecast may be scored across this poll."""
self._history.clear()
def observe(self, time: datetime, temps: dict[str, float], outside: float, lag: float,
lag_uncertainty: float, theta: dict[str, tuple[float, float, float]]) -> None:
"""Record a poll, then score every forecast that has reached its horizon."""
if self._history and not 270 <= (time - self._history[-1]["time"]).total_seconds() <= 330:
self._history.clear() # irregular poll spacing: replays would be unreliable
self._history.append({"time": time, "temps": dict(temps), "outside": outside, "lag": lag,
"lag_uncertainty": lag_uncertainty, "theta": dict(theta)})
self._score()
self._prune(time)
def _score(self) -> None:
history = list(self._history)
latest = history[-1]
day = latest["time"].date().isoformat()
for horizon in HORIZONS:
steps = horizon * 60 // STEP_SECONDS
if len(history) < steps + 1:
continue
path = history[-(steps + 1):]
# The actual heat input must be known along the way.
if any(poll["lag_uncertainty"] > self._max_lag_uncertainty for poll in path[1:]):
continue
origin = path[0]
inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds())
for before, poll in zip(path, path[1:])]
for room, actual in latest["temps"].items():
start, theta = origin["temps"].get(room), origin["theta"].get(room)
if start is None or theta is None:
continue
error = replay(start, origin["outside"], theta, inputs) - actual
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
room, [0, 0.0, 0.0, 0.0])
bucket[0] += 1
bucket[1] += error
bucket[2] += abs(error)
bucket[3] += abs(start - actual)
def _prune(self, time: datetime) -> None:
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
for day in [day for day in self._days if day < oldest]:
del self._days[day]
def summary(self, rooms: list[str]) -> dict[int, dict[str, Any]]:
"""Error statistics per horizon, overall and per configured room."""
result = {}
for horizon in HORIZONS:
per_room: dict[str, list[float]] = {}
for day in self._days.values():
for room, values in day.get(str(horizon), {}).items():
if room in rooms:
totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0])
for i, value in enumerate(values):
totals[i] += value
overall = [sum(values[i] for values in per_room.values()) for i in range(4)]
result[horizon] = {**_stats(overall),
"rooms": {room: _stats(values) for room, values in per_room.items()}}
return result
def as_dict(self) -> dict[str, Any]:
# Rounded copy: shorter JSON on every save; in-memory sums stay exact.
return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:])]
for room, v in by_room.items()}
for horizon, by_room in by_horizon.items()}
for day, by_horizon in self._days.items()}}
def load(self, data: Any) -> None:
"""Restore stored statistics, keeping only well-formed entries."""
self._days = {}
days = data.get("days") if isinstance(data, dict) else None
if not isinstance(days, dict):
return
horizons = {str(horizon) for horizon in HORIZONS}
for day, by_horizon in days.items():
try:
datetime.strptime(day, "%Y-%m-%d")
except (TypeError, ValueError):
continue
if not isinstance(by_horizon, dict):
continue
for horizon, by_room in by_horizon.items():
if horizon not in horizons or not isinstance(by_room, dict):
continue
for room, values in by_room.items():
if _valid_bucket(room, values):
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
values[0], float(values[1]), float(values[2]), float(values[3])]
def _valid_bucket(room: Any, values: Any) -> bool:
if not isinstance(room, str) or not isinstance(values, list) or len(values) != 4:
return False
count, *sums = values
return (type(count) is int and count >= 0
and all(type(v) in (int, float) and isfinite(v) for v in sums)
and sums[1] >= 0 and sums[2] >= 0)
def _stats(values: list[float]) -> dict[str, Any]:
count, error, absolute, persistence = values
if not count:
return {"samples": 0, "mae": None, "bias": None, "persistence_mae": None, "skill": None}
return {
"samples": int(count),
"mae": round(absolute / count, 3),
"bias": round(error / count, 3),
"persistence_mae": round(persistence / count, 3),
# Share of the persistence error removed by the model (1 = perfect, < 0 = worse).
"skill": round(1 - absolute / persistence, 3) if persistence > 0 else None,
}
+9
View File
@@ -278,10 +278,19 @@ def optimize(
if result is None:
raise RuntimeError("No feasible schedule")
plan, temps_60m, temps_end = result
# Re-simulate the chosen plan for its trajectory, keeping the search loop lean.
trajectory: dict[str, list[float]] = {room: [] for room in temps}
state, lag = dict(temps), effective_heat
for bit in plan:
for _ in range(nsteps_per_block):
state, lag = simulate_step(state, outside, lag, bit, learners)
for room, value in state.items():
trajectory[room].append(round(value, 3))
return {
"proposal": bool(plan[0]),
"plan_15min": [int(v) for v in plan],
"predicted_60m": {r: round(t, 3) for r, t in temps_60m.items()},
"predicted_end": {r: round(t, 3) for r, t in temps_end.items()},
"trajectory_15min": trajectory,
"cost": round(best, 4),
}
+58 -2
View File
@@ -1,17 +1,19 @@
"""Diagnostics and predicted temperatures exposed as HA sensors."""
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
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)
@@ -34,7 +36,9 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
@property
def extra_state_attributes(self):
data = self.coordinator.data
return {key: value for key, value in data.items() if key not in ("status", "prediction_60m", "prediction_end")}
# 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):
@@ -64,10 +68,22 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
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
@@ -91,3 +107,43 @@ class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
"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"),
}