"""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()