Confirmed: learned models were matched only by zone key, so replacing a zone's sensors, changing sensor weights, moving or replacing a heat source or turning a window kept coefficients learned for a different configuration, silently. compatibility.py fingerprints exactly what each learned component depends on: the zone measurement (sensors and relative weights; equal scaling changes nothing), each zone's heat sources, the neighbour's measurement, the zone's windows, window orientation (direction term only), the recovery time (recovery only) and other zones' openings (cross term only). Comfort weight, target offset and names are not included. Heat-source baselines have their own fingerprint (sensor and included sources). On restore, a changed measurement recalibrates the zone: base and extended loss/heat/bias keep their values as a warm start, but their uncertainty, counts and readiness start over, other terms return to the prior, and the zone's forecast statistics are dropped. Otherwise only the changed terms are reset; everything else, including other zones, is kept. Each recalibration is logged (Status model_changes, info log) and saved at once. Data from before fingerprints (0.4.0) is assumed to match its current configuration and is fingerprinted at the first start, so the next edit is detected even before a learned sample. Tests cover comfort changes, sensor replacement and weights, window orientation and size, moved and replaced heat sources, re-added zones, 0.4.0 data and a second restart, plus an HA Core test through real subentry updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
5.6 KiB
Python
89 lines
5.6 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
|
|
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()
|