Recalibrate only the model parts whose configuration changed

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>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 03:35:11 +02:00
1 parent 9bb64e79ca
commit 099c63cd02
10 files changed
+475 -25

No files matched your search

+22
View File
@@ -305,3 +305,25 @@ async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs
add_zone(hass, entry, "sensor.room", ["sensor.room"])
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"
async def test_zone_edits_recalibrate_only_when_the_measurement_changes(hass, entry, inputs, hass_storage):
from custom_components.heating_mpc.physics import Learner
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
await setup(hass, entry)
zone = zone_subentry(entry, "sensor.room")
hass.config_entries.async_update_subentry(entry, zone, data={**zone.data, "comfort_weight": 3.0})
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.learners["sensor.room"].count == 250 # comfort is not physics
hass.states.async_set("sensor.second", "21", {"unit_of_measurement": "°C"})
hass.config_entries.async_update_subentry(entry, zone, data={
**zone.data, "sensors": ["sensor.room", "sensor.second"],
"sensor_weights": {"sensor.room": 1.0, "sensor.second": 1.0}})
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.learners["sensor.room"].count == 0
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
assert status.attributes["model_changes"][-1]["reasons"] == ["zone sensors or sensor weights changed"]
+88
View File
@@ -0,0 +1,88 @@
"""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()
+106
View File
@@ -1087,3 +1087,109 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1)
window = data['windows']['Bedroom window']
self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None))
ZONES = [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.room'], 'neighbors': ['bed']},
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.bedroom']}]
SOURCES = [{'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'living'}]
WINDOWS = [{'key': 'w', 'name': 'Bed window', 'sensor': 'binary_sensor.bed_window', 'zone': 'bed',
'azimuth': 345}]
def configured(self, zones=None, sources=None, windows=None):
self.entry.data.update(outside_wind_sources=['sensor.wind'], outside_wind_direction_sources=['sensor.wind_dir'])
self.entry.subentries = {}
for kind, items in (('zone', zones or self.ZONES), ('heat_source', self.SOURCES if sources is None else sources),
('window', self.WINDOWS if windows is None else windows)):
for i, data in enumerate(items):
self.entry.subentries[f'{kind}{i}'] = types.SimpleNamespace(
subentry_id=f'{kind}{i}', subentry_type=kind, data=data)
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
def trained_payload(self):
c = self.configured()
for key in ('living', 'bed'):
base = c.learners[key]
base.count, base.on_count, base.off_count, base.theta = 300, 100, 100, [0.004, 0.12, 0.001]
living = c.extended['living']
living.count, living.on_count, living.off_count = 300, 100, 100
living.theta = [0.004, 0.12, 0.001, 0.15, 0.008]
vent = c.ventilation['bed']
vent.count = 80
vent.theta[vent.features.index('vent_natural')] = 0.01
vent.theta[vent.features.index('vent_wind_dir')] = 0.0005
c._evaluator._days = {'2026-10-27': {'60': {'living': [10, 0.1, 0.2, 0.3, 0.4],
'bed': [10, 0.1, 0.2, 0.3, 0.4]}}}
c._heat_sources._baselines['ups'].samples.extend([200.0] * 24)
return c._payload('sensor.outside')
async def restored(self, payload, **config):
c = self.configured(**config)
c._store.async_load.return_value = payload
await c.async_restore_model()
return c
async def test_comfort_settings_keep_every_learned_coefficient(self):
payload = self.trained_payload()
zones = [{**self.ZONES[0], 'name': 'Lounge', 'comfort_weight': 4, 'target_offset': 1},
{**self.ZONES[1], 'comfort_weight': 0.5}]
c = await self.restored(payload, zones=zones)
self.assertEqual((c.learners['living'].count, c.extended['living'].theta, c.ventilation['bed'].count),
(300, [0.004, 0.12, 0.001, 0.15, 0.008], 80))
self.assertEqual(list(c._model_changes), [])
c._store.async_save.assert_not_called()
async def test_changed_zone_sensors_recalibrate_only_that_zone(self):
payload = self.trained_payload()
zones = [{**self.ZONES[0], 'sensors': ['sensor.room', 'sensor.second']}, self.ZONES[1]]
c = await self.restored(payload, zones=zones)
base = c.learners['living']
self.assertEqual((base.theta, base.count, base.p), ([0.004, 0.12, 0.001], 0, coordinator.Learner().p)) # prior uncertainty
living = c.extended['living']
self.assertEqual((living.count, living.theta[:3], living.coefficient('internal')), (0, [0.004, 0.12, 0.001], 0.0))
# The bedroom keeps its model; only its exchange term with the living room restarts.
self.assertEqual((c.learners['bed'].count, c.ventilation['bed'].count), (300, 80))
self.assertEqual(c.extended['bed'].coefficient('couple:living'), 0.0)
self.assertEqual(set(c._evaluator._days['2026-10-27']['60']), {'bed'}) # living measured differently now
change = next(ch for ch in c._model_changes if ch.get('zone') == 'Living')
self.assertEqual(change['reasons'], ['zone sensors or sensor weights changed'])
# Saved at once with the new fingerprints: another restart changes nothing.
saved = c._store.async_save.call_args.args[0]
again = await self.restored(saved, zones=zones)
self.assertEqual(len(again._model_changes), len(c._model_changes))
again._store.async_save.assert_not_called()
async def test_turned_window_resets_only_the_direction_term(self):
payload = self.trained_payload()
c = await self.restored(payload, windows=[{**self.WINDOWS[0], 'azimuth': 300}])
vent = c.ventilation['bed']
self.assertEqual((vent.coefficient('vent_natural'), vent.coefficient('vent_wind_dir'), vent.count),
(0.01, 0.0, 80))
self.assertEqual((c.learners['bed'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
self.assertEqual(c._model_changes[-1]['reasons'], ['window orientation changed'])
async def test_moved_or_replaced_heat_source(self):
payload = self.trained_payload()
c = await self.restored(payload, sources=[{**self.SOURCES[0], 'zone': 'bed'}])
self.assertEqual(c.extended['living'].coefficient('internal'), 0.0)
self.assertEqual(c.extended['living'].coefficient('couple:bed'), 0.008) # unrelated term kept
self.assertEqual(c.extended['bed'].coefficient('internal'), 0.0) # new there, from the prior
self.assertEqual(len(c._heat_sources._baselines['ups'].samples), 24) # same sensor: same load
replaced = await self.restored(payload, sources=[{**self.SOURCES[0], 'sensor': 'sensor.ups2'}])
self.assertEqual(len(replaced._heat_sources._baselines['ups'].samples), 0)
self.assertEqual(replaced.extended['living'].coefficient('internal'), 0.0)
self.assertIn('baseline', replaced._model_changes[-1]['recalibrated'])
async def test_v040_data_without_fingerprints_is_kept_and_then_fingerprinted(self):
payload = self.trained_payload()
del payload['fingerprints']
c = await self.restored(payload)
self.assertEqual((c.learners['living'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
# Fingerprinted at once, so an edit right after the upgrade is detected.
self.assertEqual(c._store.async_save.call_args.args[0]['fingerprints'], c._fingerprints)
async def test_re_added_zone_with_other_sensors_is_recalibrated(self):
payload = self.trained_payload()
without = await self.restored(payload, zones=[self.ZONES[1]], sources=[])
self.assertIn('living', without._retired)
saved = without._payload('sensor.outside')
back = await self.restored(saved, zones=[{**self.ZONES[0], 'sensors': ['sensor.other']}, self.ZONES[1]])
self.assertEqual((back.learners['living'].theta, back.learners['living'].count), ([0.004, 0.12, 0.001], 0))