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

+31 -2
View File
@@ -1117,8 +1117,8 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
vent.count = 80
vent.theta[vent.features.index('vent_natural')] = 0.01
vent.theta[vent.features.index('vent_wind_dir')] = 0.0005
c._evaluator._days = {'2026-10-27': {'60': {'living': [10, 0.1, 0.2, 0.3, 0.4],
'bed': [10, 0.1, 0.2, 0.3, 0.4]}}}
c._evaluator._days = {'2026-10-27': {'60': {'living': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10],
'bed': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10]}}}
c._heat_sources._baselines['ups'].samples.extend([200.0] * 24)
return c._payload('sensor.outside')
@@ -1261,6 +1261,35 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(newer['status'], 'ready')
self.assertIs(result, newer)
async def test_published_forecasts_are_scored_only_when_the_relay_followed_the_plan(self):
# The relay stays off. A warm room's plan says off, so its forecast is scored;
# a cold room's plan says heat, so its forecast is counterfactual, never an error.
for room, follows in (('23.5', True), ('18', False)):
with self.subTest(room=room):
self.setUp()
self.ready_office()
self.set_state('sensor.room', room, unit_of_measurement='°C')
first = await self.sample()
self.assertEqual(first['status'], 'ready')
self.assertEqual(first['plan_15min'][0], 0 if follows else 1)
for _ in range(3):
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
data = await self.sample()
fifteen = data['operational_forecast'][15]
if follows:
self.assertEqual((fifteen['samples'], fifteen['counterfactual']), (1, 0))
predicted = first['prediction_trajectory']['office'][0]
self.assertAlmostEqual(fifteen['mae'], abs(predicted - 23.5), places=3)
self.assertEqual(fifteen['persistence_mae'], 0.0)
self.assertEqual(fifteen['quality'], 'insufficient')
else:
self.assertEqual((fifteen['samples'], fifteen['counterfactual'], fifteen['mae']), (0, 1, None))
# Longer horizons are not due yet.
self.assertEqual(data['operational_forecast'][30]['samples'], 0)
stored = self.c._payload('sensor.outside')['operational_evaluation']
self.assertEqual(sum(sum(v.get('15', {}).values()) for v in stored['verdicts'].values()),
0 if follows else 1)
async def test_unloading_during_save_or_optimization_publishes_nothing_new(self):
self.ready_office()
await self.sample() # a first sample to learn from