Separate model accuracy from forecast accuracy

The Forecast Error sensors replayed the model with the relay, electrical heat
and window history that actually followed, so they measured the model, not a
forecast anyone could have made. They are renamed Model Error (unique IDs
unchanged) and gain RMSE, effective sample count and a quality label; their
state stays unknown until there are at least 5 roughly independent spans.

A new Forecast Error sensor scores forecasts as published, with the plan and
assumptions of the moment they were made. A forecast is scored only when the
relay followed its plan; otherwise it is counted as counterfactual, not as
error. Statistics are saved with the model and dropped with a zone's
recalibration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 03:47:16 +02:00
1 parent d73e32a4b9
commit 26c415fa6c
6 files changed
+485 -36

No files matched your search

+27 -4
View File
@@ -39,7 +39,7 @@ async def test_setup_reload_unload(hass, entry, inputs):
assert coordinator.config_entry is entry
registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, entry.entry_id)
assert len(entities) == 11 # 7 core + 4 forecast-error sensors
assert len(entities) == 12 # 7 core + 4 model-error sensors + 1 forecast-error sensor
ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning"
status = hass.states.get(ids[f"{entry.entry_id}_status"])
@@ -74,7 +74,7 @@ async def test_replacing_a_zone_removes_only_its_forecast(hass, entry, inputs):
assert registry.async_get(old) is None
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 12
async def test_storage_restore_and_remove(hass, entry, inputs):
@@ -255,13 +255,36 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs,
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
state = hass.states.get(ids[15])
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
accuracy = coordinator.data["forecast_accuracy"][15]
assert accuracy["samples"] == 1
# One replayed forecast is no accuracy figure: the state stays unknown, the numbers
# are in the attributes with their quality.
assert state.state == "unknown"
assert state.attributes["mae"] == accuracy["mae"] > 0
assert state.attributes["quality"] == "insufficient"
assert state.attributes["samples"] == 1
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
assert state.attributes["unit_of_measurement"] == "°C"
assert state.attributes["friendly_name"] == "Heating MPC Model Error 15 min"
assert "zones" in state.state_info["unrecorded_attributes"]
assert hass.states.get(ids[30]).state == "unknown"
for step in range(15): # enough 15-minute spans for an indicative figure
future += timedelta(minutes=5, seconds=1)
freezer.move_to(future)
# Fresh readings, or the sensors turn stale after 30 minutes.
hass.states.async_set("sensor.room", str(20 - 0.01 * step), {"unit_of_measurement": "°C"})
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"}, force_update=True)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
accuracy = coordinator.data["forecast_accuracy"][15]
assert accuracy["quality"] == "indicative", accuracy
assert float(hass.states.get(ids[15]).state) == accuracy["mae"]
# Published forecasts are scored separately; nothing was published while learning.
operational = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_operational_forecast_error")
state = hass.states.get(operational)
assert state.state == "unknown"
assert state.attributes["friendly_name"] == "Heating MPC Forecast Error"
assert "horizons" in state.state_info["unrecorded_attributes"]
async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer):