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:
1 parent
43915ba810
commit
3afb6688a5
9 files changed
+454
-16
No files matched your search
@@ -24,7 +24,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) == 5
|
||||
assert len(entities) == 9 # 5 core + 4 forecast-error sensors
|
||||
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"])
|
||||
@@ -55,7 +55,7 @@ async def test_options_reload_removes_only_old_room(hass, entry, inputs):
|
||||
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
|
||||
assert registry.async_get(old) is None
|
||||
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min")
|
||||
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 5
|
||||
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 9
|
||||
|
||||
|
||||
async def test_storage_restore_and_remove(hass, entry, inputs):
|
||||
@@ -69,6 +69,12 @@ async def test_storage_restore_and_remove(hass, entry, inputs):
|
||||
restored = hass.data[DOMAIN][entry.entry_id]
|
||||
assert restored.learners["sensor.room"].count == 250
|
||||
assert restored.data["status"] == "ready"
|
||||
forecast_id = er.async_get(hass).async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
|
||||
forecast = hass.states.get(forecast_id)
|
||||
assert len(forecast.attributes["forecast_15min"]) == len(forecast.attributes["plan_15min"]) == 4
|
||||
assert forecast.attributes["forecast_15min"][3] == float(forecast.state)
|
||||
assert {"forecast_15min", "plan_15min"} <= forecast.state_info["unrecorded_attributes"]
|
||||
assert await hass.config_entries.async_remove(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None
|
||||
@@ -210,3 +216,25 @@ async def test_removed_room_model_survives_options_round_trip(hass, entry, input
|
||||
entry, options={"room_entities": ["sensor.room", "sensor.new"]})
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
|
||||
|
||||
|
||||
async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs, freezer):
|
||||
coordinator = await setup(hass, entry)
|
||||
registry = er.async_get(hass)
|
||||
ids = {horizon: registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_forecast_error_{horizon}min")
|
||||
for horizon in (15, 30, 60, 120)}
|
||||
assert all(hass.states.get(entity).state == "unknown" for entity in ids.values())
|
||||
future = dt_util.utcnow()
|
||||
for _ in range(3): # 15 minutes of regular polls
|
||||
future += timedelta(minutes=5, seconds=1)
|
||||
freezer.move_to(future)
|
||||
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
|
||||
assert state.attributes["samples"] == 1
|
||||
assert state.attributes["rooms"]["sensor.room"]["samples"] == 1
|
||||
assert state.attributes["unit_of_measurement"] == "°C"
|
||||
assert "rooms" in state.state_info["unrecorded_attributes"]
|
||||
assert hass.states.get(ids[30]).state == "unknown"
|
||||
Reference in new issue
Block a user