Files
heating-mpc-ha/tests/test_compatibility.py
T
buzeraandClaude Opus 5.5 8eef04c385 Reset neighbours' cross-ventilation when a window's polarity flips
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>
2026-10-27 03:53:39 +02:00

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