Files
heating-mpc-ha/tests/test_physics.py
T
buzeraandClaude Opus 5.5 c3646ba876 Track radiator lag across relay changes
The lag proxy was reset to the new relay command at every relay change
and after any skipped interval, so it read 1.0 straight after ON and 0.0
straight after OFF. This disabled the lag model around every boiler
cycle: on a synthetic lagged plant cycled by a hysteresis thermostat the
learner's heat gain came out 34% low (0.079 vs 0.12) and the loss term
43% low, and the optimizer started post-transition plans from the wrong
lag.

Track the lag at every poll with a valid relay state, independently of
room sensors. Integrate a single change between polls at its
last_changed time. For ambiguous cases (round trip between polls, relay
outage, restart, clock jump) restart from time-in-state with an explicit
error half-width, and learn only once it is at most 0.1 (about 32 min
after an HA restart). The same synthetic test now learns 0.1195/0.00389
(true 0.12/0.004).

Expose heat_lag, heat_lag_uncertainty and sample_result on Status so
users can see why a poll was or was not learned. The HA test fixture
relay now has an hour-old timestamp; a new test covers a freshly
written relay state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:14 +02:00

154 lines
7.4 KiB
Python

"""Offline tests: run python -m unittest discover -s tests -v."""
import importlib.util
import unittest
from pathlib import Path
path = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc' / 'physics.py'
spec = importlib.util.spec_from_file_location('heating_mpc_physics', path)
m = importlib.util.module_from_spec(spec)
import sys
sys.modules[spec.name] = m
spec.loader.exec_module(m)
class ModelTests(unittest.TestCase):
def test_fitting_and_restore(self):
learn = m.Learner()
t = 20.0
lag = 0.0
for step in range(1000):
heater = int(step % 96 < 35)
lag = lag + m.HEAT_LAG * (heater - lag)
value = t + 0.003 * (8 - t) + 0.055 * lag + 0.001
learn.learn(t, value, 8, lag)
t = value
self.assertGreaterEqual(learn.count, 950)
self.assertAlmostEqual(learn.theta[0], 0.003, delta=0.003)
self.assertAlmostEqual(learn.theta[1], 0.055, delta=0.03)
restored = m.Learner.from_dict(learn.as_dict())
self.assertEqual(restored.count, learn.count)
self.assertEqual(restored.theta, learn.theta)
def test_optimize_warm_room_prefers_off(self):
zones = {'living': 23.5, 'bedroom': 23.4}
learners = {'living': m.Learner(), 'bedroom': m.Learner()}
out = m.optimize(zones, 12, 0, 22, {'living': 2, 'bedroom': 1},
learners, False, 3600, horizon_minutes=60)
self.assertFalse(out['proposal'])
self.assertEqual(len(out['plan_15min']), 4)
def test_optimize_cold_room_prefers_heat(self):
zones = {'living': 19.5, 'bedroom': 19.9}
learners = {'living': m.Learner(), 'bedroom': m.Learner()}
out = m.optimize(zones, 4, 0, 22, {'living': 2, 'bedroom': 1},
learners, False, 3600, horizon_minutes=60)
self.assertTrue(out['proposal'])
def test_minimum_duration_on_and_off(self):
learners = {'r': m.Learner()}
# Could not switch OFF during first 15 minutes if just turned ON.
out = m.optimize({'r': 24}, 12, 1, 22, {'r': 1}, learners, True, 120,
minimum_on_minutes=15, horizon_minutes=60)
self.assertTrue(out['proposal'])
# Could not switch ON during first 10 minutes if just turned OFF.
out = m.optimize({'r': 18}, 0, 0, 22, {'r': 1}, learners, False, 120,
minimum_off_minutes=10, horizon_minutes=60)
self.assertFalse(out['proposal'])
def test_invalid_room_weights_rejected(self):
with self.assertRaises(ValueError):
m.optimize({'r': 20}, 10, 0, 22, {'r': 0}, {'r': m.Learner()},
False, 1000, horizon_minutes=60)
def test_short_horizon_does_not_claim_60_minute_prediction(self):
out = m.optimize({'r': 20}, 5, 0, 22, {'r': 1}, {'r': m.Learner()},
False, 3600, horizon_minutes=30)
self.assertEqual(out['predicted_60m'], {})
self.assertIn('r', out['predicted_end'])
def test_prediction_matches_replay_at_60_minutes(self):
learners = {'r': m.Learner()}
out = m.optimize({'r': 20}, 5, .4, 22, {'r': 1}, learners, False, 3600,
horizon_minutes=90)
temps, lag = {'r': 20}, .4
for index, bit in enumerate(out['plan_15min']):
for _ in range(3):
temps, lag = m.simulate_step(temps, 5, lag, bit, learners)
if index == 3:
self.assertEqual(out['predicted_60m']['r'], round(temps['r'], 3))
self.assertEqual(out['predicted_end']['r'], round(temps['r'], 3))
def test_jitter_normalized_and_rejection_counted(self):
nominal, delayed = m.Learner(), m.Learner()
self.assertTrue(nominal.learn(20, 20.1, 5, .5, command=True))
self.assertTrue(delayed.learn(20, 20.11, 5, .5, 330, True))
for a, b in zip(nominal.theta, delayed.theta):
self.assertAlmostEqual(a, b)
self.assertFalse(delayed.learn(20, 25, 5, .5, command=False))
self.assertEqual((delayed.count, delayed.on_count, delayed.off_count), (1, 1, 0))
def test_corrupt_model_restore_is_atomic(self):
for changes in ({'theta': [-1, .1, 0]}, {'p': [[-1, 0, 0], [0, 1, 0], [0, 0, 1]]},
{'count': float('inf')}, {'count': '5'}, {'error_sum': float('nan')},
{'off_count': 1}, {'theta': [float('nan'), 0, 0]}, {'p': None},
{'p': [[1, .5, 0], [0, 1, 0], [0, 0, 1]]}):
with self.subTest(changes=changes):
data = m.Learner().as_dict()
data.update(changes)
self.assertEqual(m.Learner.from_dict(data), m.Learner())
for bad in (None, [], 'broken'):
self.assertEqual(m.Learner.from_dict(bad), m.Learner())
def test_nonfinite_optimizer_inputs_rejected(self):
for key, value in (('weights', {'r': float('nan')}), ('effective_heat', float('nan')),
('switching_cost', float('inf')), ('seconds_since_change', -1),
('minimum_on_minutes', -1), ('comfort_band', float('nan'))):
args = dict(temps={'r': 20}, outside=5, effective_heat=0, target=22,
weights={'r': 1}, learners={'r': m.Learner()}, is_on=False,
seconds_since_change=3600, horizon_minutes=60)
args[key] = value
with self.subTest(key=key), self.assertRaises(ValueError):
m.optimize(**args)
def test_covariance_stays_bounded_without_heat_excitation(self):
learn = m.Learner()
t = 22.0
for _ in range(30000): # about 104 days with the relay off
value = t + 0.003 * (15 - t)
self.assertTrue(learn.learn(t, value, 15, 0.0, command=False))
t = value
for i in range(3):
self.assertLessEqual(learn.p[i][i], m.PRIOR_P[i][i])
for j in range(3):
self.assertEqual(learn.p[i][j], learn.p[j][i])
restored = m.Learner.from_dict(learn.as_dict())
self.assertEqual(restored.count, 30000)
self.assertEqual(restored.p, learn.p)
def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self):
# Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry.
data = m.Learner(count=40000, off_count=40000).as_dict()
data['p'] = [[0.03, 1.0, 1e-3], [1.0 + 1e-7, 8.7e12, 0.0], [1e-3, 0.0, 0.0012]]
restored = m.Learner.from_dict(data)
self.assertEqual(restored.count, 40000)
self.assertEqual(restored.p[1][1], m.PRIOR_P[1][1])
self.assertEqual(restored.p[0][0], m.PRIOR_P[0][0])
self.assertEqual(restored.p[0][1], restored.p[1][0])
def test_lag_helpers(self):
self.assertAlmostEqual(m.advance_lag(0, True, 300), m.HEAT_LAG)
self.assertAlmostEqual(m.advance_lag(1, False, 1e9), 0)
self.assertEqual(m.lag_from_time_in_state(True, 0), (0.5, 0.5))
lag, half_width = m.lag_from_time_in_state(False, 3600)
self.assertAlmostEqual(lag, half_width)
self.assertLess(half_width, 0.03)
# Any starting lag in [0, 1] ends inside the stated interval.
for start in (0.0, 0.3, 1.0):
for command in (False, True):
lag, half_width = m.lag_from_time_in_state(command, 900)
self.assertLessEqual(abs(m.advance_lag(start, command, 900) - lag), half_width + 1e-12)
if __name__ == '__main__':
unittest.main()