Score the full model against the base model; compile the search step

Validation: the walk-forward evaluator now replays all zones together
with the full model (electrical heat, neighbour exchange, ventilation
and recovery) using the parameters frozen at the forecast start and the
electrical heat and window openings that actually happened, and scores
the base 3-parameter model on the same spans. Forecast Error sensors
add base_model_mae and improvement_vs_base (share of the base model's
error the new features remove; 0 = no gain, negative = worse). Saved
0.3 statistics load with their own error as the base column.

On synthetic apartments, 60-minute error full vs base: gaming office
0.031 vs 0.182 °C (83 % better), bedroom with ventilation 0.026 vs
0.218 °C (88 %), two coupled rooms 0.024 vs 0.037 °C (34 %; gone by
120 min, where the constant-outdoor assumption dominates). The same
ventilation week without window sensors shows no improvement, as it
should. These check the method, not real-world accuracy.

Performance: every term of a zone's 5-minute change is affine in the
zone temperatures and the radiator lag, and which layer applies depends
only on which inputs exist. compile_step_model reads the coefficients
off the real model per zone and step, so the plan search does a few
multiply-adds per step. It matches the direct model to 1e-12 and makes
a 180-minute, 5-zone search 4.5x faster (0.73 s here, close to the old
base-only search).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:55:57 +02:00
1 parent 8d197fabcd
commit b1577a0c78
7 files changed
+266 -31

No files matched your search

+11
View File
@@ -784,6 +784,10 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(details['model_in_use'], 'extended')
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
self.assertTrue(details['coefficients']['extended']['internal']['identified'])
# Replayed against what happened, the electrical term removes most of the base error.
accuracy = data['forecast_accuracy'][60]
self.assertLess(accuracy['mae'], 0.06)
self.assertGreater(accuracy['improvement_vs_base'], 0.6)
async def test_unknown_power_pauses_extended_learning_only(self):
self.office_with_ups()
@@ -942,6 +946,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
mixed = without.learners['bed'].theta
self.assertLess(mixed[2], -0.008)
self.assertGreater(mixed[1], 1.05 * b)
# Forecasts replayed with the actual window history are far better than the base model's.
configured = with_window._evaluator.summary(with_window.rooms)[60]
self.assertGreater(configured['improvement_vs_base'], 0.6)
self.assertLess(configured['mae'], 0.06)
self.assertEqual(without._evaluator.summary(without.rooms)[60]['improvement_vs_base'], 0)
async def test_forecast_assumes_window_closes_then_room_recovers(self):
self.c = self.bedroom_with_window()
@@ -1016,6 +1025,8 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
# ...but a steady difference between rooms is collinear with each room's
# bias, so the split stays uncertain and is not reported as identified.
self.assertFalse(info['identified'])
# Joint multi-zone forecasts still beat the base model at an hour.
self.assertGreater(c._evaluator.summary(c.rooms)[60]['improvement_vs_base'], 0.15)
async def test_inter_zone_transfer_stays_near_zero_when_absent(self):
c = await self.coupled_rooms(0.0, bed=(0.004, 0.11, 0.0))
+24 -2
View File
@@ -76,6 +76,25 @@ class EvaluationTests(unittest.TestCase):
self.assertEqual(evaluator.summary(['sensor.room'])[15]['samples'], 7)
self.assertEqual(before, 7)
def test_full_model_replays_actual_inputs_and_is_compared_with_base(self):
base = (0.004, 0.12, 0.0005)
full = {'base': base, 'uses_extended': True,
'extended': (['loss', 'heat', 'bias', 'internal'], base + (0.15,)),
'ventilation': (['vent_natural'], (0.01,))}
evaluator, temp = ev.ForecastEvaluator(0.1), 20.0
for k in range(40):
internal = 0.4 if (k // 8) % 2 else 0.0 # gaming on and off
opening = 1.0 if k % 10 in (3, 4) else 0.0 # short ventilation
if k:
temp += 0.004 * (5 - temp) + 0.12 * 0.5 + 0.0005 + 0.15 * internal + 0.01 * opening * (5 - temp)
evaluator.observe(START + timedelta(seconds=300 * k), {'r': temp}, 5.0, 0.5, 0.0, {'r': base},
models={'r': full},
inputs={'r': {'internal': internal, 'ventilation': {'opening': opening}}})
stats = evaluator.summary(['r'])[60]
self.assertAlmostEqual(stats['mae'], 0, places=6)
self.assertGreater(stats['base_model_mae'], 0.05)
self.assertEqual(stats['improvement_vs_base'], 1.0)
def test_saved_statistics_round_trip_and_reject_garbage(self):
evaluator = plant(10)
restored = ev.ForecastEvaluator(0.1)
@@ -86,8 +105,11 @@ class EvaluationTests(unittest.TestCase):
'not-a-day': {'15': {'sensor.room': [1, 0.0, 0.1, 0.1]}},
day: {'45': {'sensor.room': [1, 0.0, 0.1, 0.1]},
'15': {'sensor.a': [1, 0.0, -0.1, 0.1], 'sensor.b': [True, 0, 0, 0],
'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4]}}}})
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {'sensor.d': [2, 0.1, 0.2, 0.4]}}}})
'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4],
'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}}})
# A 0.3 bucket has no base-model column; its model was the base model then.
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3]}}}})
for garbage in (None, [], {'days': []}):
restored.load(garbage)
self.assertEqual(restored.as_dict(), {'days': {}})
+87
View File
@@ -0,0 +1,87 @@
"""Layered zone model: fallbacks, contributions and the compiled optimizer step."""
import random
import sys
import types
import unittest
from pathlib import Path
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
from heating_mpc import learning, physics, zone_model as zm # noqa: E402
VENT = {'opening': 1.0, 'facing': -0.5, 'other_opening': 0.5, 'recovery': 0.0, 't_ref': 21.0,
'wind': 4.0, 'gust': 7.0}
def trained(features, theta):
learner = learning.FeatureLearner(features)
learner.theta = list(theta)
learner.count, learner.on_count, learner.off_count = 300, 100, 100
return learner
def models():
living = zm.ZoneModel(physics.Learner(), trained(zm.extended_features(True, ('bed',)),
(0.004, 0.12, 0.0005, 0.15, 0.01)))
bed = zm.ZoneModel(physics.Learner(), trained(zm.extended_features(False, ('living',)),
(0.006, 0.09, 0.0, 0.01)),
trained(['vent_natural', 'recovery', 'vent_wind', 'vent_wind_dir', 'vent_cross'],
(0.01, 0.15, 0.001, 0.0006, 0.002)))
return {'living': living, 'bed': bed}
class ZoneModelTests(unittest.TestCase):
def test_layers_and_fallbacks(self):
self.assertEqual(zm.extended_features(False, ()), [])
self.assertEqual(zm.extended_features(True, ('b',)), ['loss', 'heat', 'bias', 'internal', 'couple:b'])
living = models()['living']
inputs = {'outside': 5.0, 'lag': 0.5, 'internal': 0.4, 'temps': {'living': 21.0, 'bed': 20.0}}
self.assertAlmostEqual(living.delta(21.0, inputs),
0.004 * -16 + 0.06 + 0.0005 + 0.06 + 0.01 * -1)
# A missing input (electrical heat or a neighbour) falls back to the base model.
base = living.base.predict_delta(21.0, 5.0, 0.5)
self.assertAlmostEqual(living.delta(21.0, {**inputs, 'internal': None}), base)
self.assertAlmostEqual(living.delta(21.0, {**inputs, 'temps': {'living': 21.0}}), base)
# Until the extended model is as trained as the base needs to be, the base is used.
living.extended.count = 10
self.assertFalse(living.uses_extended)
self.assertAlmostEqual(living.delta(21.0, inputs), base)
def test_contributions_add_up_to_the_change(self):
bed = models()['bed']
inputs = {'outside': 5.0, 'lag': 0.5, 'temps': {'living': 21.0, 'bed': 19.0}, 'ventilation': VENT}
terms = bed.contributions(19.0, inputs)
self.assertEqual(set(terms), {'loss', 'heat', 'bias', 'neighbors', 'ventilation', 'recovery'})
self.assertAlmostEqual(sum(terms.values()), 12 * bed.delta(19.0, inputs), places=2)
self.assertLess(terms['ventilation'], 0)
# Unknown window state: no ventilation term rather than a guess.
self.assertAlmostEqual(bed.ventilation_delta(19.0, {**inputs, 'ventilation': {**VENT, 'opening': None}}), 0)
def test_compiled_step_matches_direct_model_exactly(self):
zone_models = models()
steps = 24
ventilation = [{**VENT, 'opening': 1.0 if k < 3 else 0.0, 'recovery': 0.0 if k < 3 else 0.6}
for k in range(steps)]
def inputs_at(key, k):
return {'outside': 5.0, 'internal': 0.4 if key == 'living' else None,
'ventilation': ventilation[k] if key == 'bed' else {}}
fast = zm.compile_step_model(zone_models, ['living', 'bed'], steps, inputs_at)
rng = random.Random(5)
for _ in range(300):
state = {'living': rng.uniform(15, 26), 'bed': rng.uniform(15, 26)}
lag, k = rng.random(), rng.randrange(steps + 3) # beyond the horizon: last step repeats
direct = {key: value + zone_models[key].delta(value, {
**inputs_at(key, min(k, steps - 1)), 'lag': lag, 'temps': state}) for key, value in state.items()}
compiled = fast(state, lag, k)
for key in state:
self.assertAlmostEqual(compiled[key], direct[key], places=12)
# With a zone missing, its neighbour falls back to its base model, also when compiled.
solo = zm.compile_step_model(zone_models, ['living'], steps, inputs_at)
base = zone_models['living'].base.predict_delta(20.0, 5.0, 0.3)
self.assertAlmostEqual(solo({'living': 20.0}, 0.3, 0)['living'], 20.0 + base, places=12)
if __name__ == '__main__':
unittest.main()