Each temperature sensor used to be its own "room", so a room with two sensors counted twice and comfort importance was tied to sensors. zones.py adds Zone: a name, one or more sensors with sensor weights, a comfort weight, an optional target offset and adjacent zones (made symmetric). Sensor weights only combine readings; comfort weights only steer the MPC; neither touches the learned thermal coefficients. Zones are config subentries (HA's native "Add zone" / edit / delete on the integration page), with validation that a sensor belongs to one zone and is not an outdoor source, and a sensor-weight step for multi-sensor zones. Initial setup still asks for room sensors and weights and creates one zone per sensor. Configure no longer asks for rooms or weights. Config entry version 3. Migrating from 2 turns each room sensor into a one-sensor zone with its comfort weight and the sensor's entity ID as the zone key, so learned models, forecast entities (now linked to their zone, so deleting a zone removes them) and accuracy statistics carry over. Older versions cannot load a version 3 entry; rolling back needs the HA backup. A zone learns only across polls that used the same sensors (a sensor dropping out shifts the mean by its offset); the new poll outcome "sensors_changed" reports this. Status renames: input_quality.zones (per-sensor detail nested), excluded_zones, retired_zone_models; adds zone_names. Invalid stored comfort weights now fall back to 1.0, so the invalid_weights status is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
9.0 KiB
Python
180 lines
9.0 KiB
Python
"""Offline tests: run python -m unittest discover -s tests -v."""
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
import sys
|
|
import types
|
|
|
|
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
|
# Fake only the package container, to avoid importing Home Assistant from __init__.
|
|
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
|
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
|
from heating_mpc import physics as m # noqa: E402
|
|
|
|
|
|
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_zone_target_offsets_shift_comfort(self):
|
|
learners = {'r': m.Learner()}
|
|
args = ({'r': 20.8}, 8, 0, 22, {'r': 1}, learners, False, 3600)
|
|
self.assertTrue(m.optimize(*args, horizon_minutes=60)['proposal'])
|
|
self.assertFalse(m.optimize(*args, horizon_minutes=60, target_offsets={'r': -1.5})['proposal'])
|
|
with self.assertRaises(ValueError):
|
|
m.optimize(*args, horizon_minutes=60, target_offsets={'r': float('nan')})
|
|
|
|
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['trajectory_15min']['r'][index], round(temps['r'], 3))
|
|
self.assertEqual(out['predicted_end']['r'], round(temps['r'], 3))
|
|
self.assertEqual(len(out['trajectory_15min']['r']), 6)
|
|
|
|
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.assertFalse(delayed.learn(20, 20.1, 5, 1.5, command=False))
|
|
self.assertEqual((delayed.count, delayed.on_count, delayed.off_count, delayed.rejected_count),
|
|
(1, 1, 0, 2))
|
|
self.assertEqual(m.Learner.from_dict(delayed.as_dict()).rejected_count, 2)
|
|
|
|
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]]}, {'rejected_count': -1},
|
|
{'rejected_count': '3'}):
|
|
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)
|
|
|
|
def test_relay_segments(self):
|
|
seg = m.relay_segments
|
|
self.assertEqual(seg(False, 0, [], 300), [(False, 300)])
|
|
self.assertEqual(seg(False, 0, [(60, 'on'), (200, 'off')], 300),
|
|
[(False, 60), (True, 140), (False, 100)])
|
|
self.assertEqual(seg(True, 0, [(300, 'off')], 300), [(True, 300), (False, 0)])
|
|
for changes in ([(60, 'unavailable')], [(60, 'off')], [(200, 'on'), (60, 'off')],
|
|
[(-1, 'on')], [(301, 'on')]):
|
|
with self.subTest(changes=changes):
|
|
self.assertIsNone(seg(False, 0, changes, 300))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|