A flipped contact sensor reverses what every term saw as open, including the other zones' cross-ventilation term, whose fingerprint covered only the window's sensor and opening factor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95 lines
6.1 KiB
Python
95 lines
6.1 KiB
Python
"""Configuration fingerprints: what invalidates which learned coefficient."""
|
|
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 compatibility as cp, heat_sources as hs, windows as wm, zones as zn # noqa: E402
|
|
|
|
|
|
def setup(zones=None, sources=None, windows=None, recovery=20):
|
|
zones = zones or [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.l1', 'sensor.l2'],
|
|
'sensor_weights': {'sensor.l1': 1, 'sensor.l2': 1}, 'neighbors': ['bed']},
|
|
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b']}]
|
|
built = {z.key: z for z in zn.build_zones((z, None) for z in zones)}
|
|
sources = hs.build_sources(((s, None) for s in (sources if sources is not None else [
|
|
{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'},
|
|
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}])), built)
|
|
windows = wm.build_windows(((w, None) for w in (windows if windows is not None else [
|
|
{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345},
|
|
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}])), built)
|
|
return cp.zone_fingerprints(built, sources, windows, recovery), sources
|
|
|
|
|
|
def differing(a, b):
|
|
return {zone: sorted(k for k in set(a[zone]) | set(b[zone]) if a[zone].get(k) != b[zone].get(k))
|
|
for zone in a if zone in b and a[zone] != b[zone]}
|
|
|
|
|
|
class FingerprintTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.base, _ = setup()
|
|
|
|
def test_deterministic_and_comfort_settings_are_not_physical(self):
|
|
self.assertEqual(setup()[0], self.base)
|
|
zones = [{'key': 'living', 'name': 'Renamed', 'sensors': ['sensor.l1', 'sensor.l2'], 'comfort_weight': 5,
|
|
'target_offset': 1.5, 'sensor_weights': {'sensor.l1': 2, 'sensor.l2': 2}, 'neighbors': ['bed']},
|
|
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b'], 'comfort_weight': 3}]
|
|
self.assertEqual(differing(self.base, setup(zones)[0]), {}) # equal weights scaled: same average
|
|
|
|
def test_measurement_changes_reset_the_whole_zone_and_its_neighbour_term(self):
|
|
for sensors, weights in ((['sensor.l1', 'sensor.l2'], {'sensor.l1': 3, 'sensor.l2': 1}), # weight ratio
|
|
(['sensor.l1', 'sensor.l3'], {}), # replaced
|
|
(['sensor.l1'], {})): # removed
|
|
with self.subTest(sensors=sensors, weights=weights):
|
|
zones = [{'key': 'living', 'sensors': sensors, 'sensor_weights': weights, 'neighbors': ['bed']},
|
|
{'key': 'bed', 'sensors': ['sensor.b']}]
|
|
diff = differing(self.base, setup(zones)[0])
|
|
self.assertEqual(set(diff['living']), set(self.base['living']))
|
|
self.assertEqual(diff['bed'], ['couple:living']) # the bedroom's own model is untouched
|
|
|
|
def test_heat_source_changes_reset_only_the_electrical_term(self):
|
|
moved = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'bed'},
|
|
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
|
|
self.assertEqual(differing(self.base, setup(sources=moved)[0]), {'living': ['internal'], 'bed': ['internal']})
|
|
for change in ({'sensor': 'sensor.ups2'}, {'heat_fraction': 0.5}, {'response_minutes': 30},
|
|
{'baseline_mode': 'manual', 'baseline_w': 150}):
|
|
with self.subTest(change=change):
|
|
sources = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living', **change},
|
|
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
|
|
self.assertEqual(differing(self.base, setup(sources=sources)[0]), {'living': ['internal']})
|
|
|
|
def test_window_changes_are_selective(self):
|
|
turned = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 300},
|
|
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
|
self.assertEqual(differing(self.base, setup(windows=turned)[0]), {'bed': ['vent_wind_dir']})
|
|
bigger = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345, 'opening_factor': 2},
|
|
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
|
diff = differing(self.base, setup(windows=bigger)[0])
|
|
self.assertEqual(diff['bed'], ['recovery', 'vent_cross', 'vent_gust', 'vent_natural', 'vent_wind',
|
|
'vent_wind_dir'])
|
|
self.assertEqual(diff['living'], ['vent_cross']) # sees the bedroom's windows
|
|
# A flipped polarity reverses what every term saw as open, the neighbour's too.
|
|
flipped = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345, 'inverted': True},
|
|
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
|
diff = differing(self.base, setup(windows=flipped)[0])
|
|
self.assertIn('vent_natural', diff['bed'])
|
|
self.assertEqual(diff['living'], ['vent_cross'])
|
|
self.assertEqual(differing(self.base, setup(recovery=40)[0]), {'living': ['recovery'], 'bed': ['recovery']})
|
|
|
|
def test_baseline_prints_and_change_detection(self):
|
|
_, sources = setup()
|
|
prints = cp.baseline_fingerprints(sources)
|
|
_, without_pc = setup(sources=[{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'}])
|
|
self.assertNotEqual(prints['ups'], cp.baseline_fingerprints(without_pc)['ups'])
|
|
self.assertEqual(cp.changed(None, self.base['living'], ['base']), []) # 0.4.0 data: assumed to match
|
|
self.assertEqual(cp.changed({'base': 'x'}, self.base['living'], ['base', 'internal']), ['base'])
|
|
self.assertEqual(cp.reason('couple:bed'), "a neighbouring zone's sensors changed")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|