Introduce thermal zones as config subentries

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>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:32:11 +02:00
1 parent 220ac570d2
commit 46587af8a3
15 files changed
+867 -155

No files matched your search

+57 -19
View File
@@ -44,6 +44,7 @@ def load_integration():
modules['homeassistant.helpers.event'].async_track_state_change_event = (
lambda hass, entity_ids, action: hass.track_state(entity_ids, action))
modules['homeassistant.config_entries'].ConfigEntry = object
modules['homeassistant.config_entries'].ConfigSubentry = lambda **kwargs: types.SimpleNamespace(**kwargs)
modules['homeassistant.helpers.storage'].Store = Store
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
registry = modules['homeassistant.helpers.entity_registry']
@@ -249,7 +250,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside'])
self.assertNotIn('outside_entity', kwargs['data'])
self.assertEqual(kwargs['version'], 2)
self.entry.version = 3
self.entry.version = 4
self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry))
async def test_changed_inputs_during_optimization_discard_result(self):
@@ -263,12 +264,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data['status'], 'inputs_changed')
self.assertIsNone(data['proposal'])
async def test_invalid_weights_do_not_publish_proposal(self):
async def test_invalid_stored_comfort_weights_fall_back_to_one(self):
for weight in (0, -1, 'bad', float('nan'), float('inf')):
self.c.config['weights'] = {'sensor.room': weight}
data = await self.sample()
self.assertEqual(data['status'], 'invalid_weights')
self.assertIsNone(data['proposal'])
self.entry.data['weights'] = {'sensor.room': weight}
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
self.assertEqual(self.c.zones['sensor.room'].comfort_weight, 1.0)
async def test_added_room_needs_its_own_excitation_evidence(self):
model = coordinator.Learner(count=500, on_count=250, off_count=250)
@@ -295,7 +295,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.states['sensor.room'].last_reported = self.now - timedelta(minutes=31)
data = await self.sample()
self.assertEqual(data['status'], 'waiting_for_sensors')
self.assertEqual(data['input_quality']['rooms']['sensor.room']['status'], 'stale')
self.assertEqual(data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']['status'], 'stale')
self.assertIsNone(data['proposal'])
self.set_state('sensor.room', '20', unit_of_measurement='°C')
data = await self.sample()
@@ -511,7 +511,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
await self.c.async_restore_model()
self.assertEqual(self.c.learners['sensor.room'].count, 7)
await self.sample()
self.assertEqual((await self.sample())['retired_room_models'], ['sensor.gone'])
self.assertEqual((await self.sample())['retired_zone_models'], ['sensor.gone'])
payload = self.c._store.async_save.call_args.args[0]
self.assertEqual(set(payload['learners']), {'sensor.room'})
self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500)
@@ -534,12 +534,12 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
await self.sample(2400) # the room then stays silent for 40 minutes
self.states['sensor.outside'].last_reported = self.now
data = await self.sample()
room = data['input_quality']['rooms']['sensor.room']
room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']
self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400))
self.states['sensor.room'].last_reported = self.now
self.states['sensor.outside'].last_reported = self.now
data = await self.sample()
room = data['input_quality']['rooms']['sensor.room']
room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']
self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']),
('valid', 0, 2400))
weather = data['input_quality']['weather']['temperature']['sensor.outside']
@@ -563,17 +563,19 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31)
data = await self.sample()
self.assertEqual(data['status'], 'learning')
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
rooms = data['input_quality']['rooms']
self.assertEqual((rooms['sensor.bedroom']['status'], rooms['sensor.bedroom']['used']), ('stale', False))
self.assertTrue(rooms['sensor.room']['used'])
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
zones = data['input_quality']['zones']
bedroom = zones['sensor.bedroom']['sensors']['sensor.bedroom']
self.assertEqual((bedroom['status'], bedroom['used'], zones['sensor.bedroom']['used']),
('stale', False, False))
self.assertTrue(zones['sensor.room']['used'])
self.assertEqual((data['weighted_temperature'], data['weight_coverage']), (20, 0.75))
self.assertEqual(data['sample_result'], 'learned')
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
self.assertEqual(counts(), (1, 0))
self.fresh() # bedroom reports again: no learning across its gap
data = await self.sample()
self.assertEqual((data['excluded_rooms'], data['weight_coverage']), ([], 1.0))
self.assertEqual((data['excluded_zones'], data['weight_coverage']), ([], 1.0))
self.assertEqual(data['weighted_temperature'], 19.5)
self.assertEqual(counts(), (2, 0))
self.fresh()
@@ -599,7 +601,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.states[room].last_reported = self.now - timedelta(minutes=31)
data = await self.sample()
self.assertEqual(data['status'], 'waiting_for_sensors')
self.assertEqual(data['excluded_rooms'], ['sensor.room', 'sensor.bedroom'])
self.assertEqual(data['excluded_zones'], ['sensor.room', 'sensor.bedroom'])
self.assertIsNone(data['proposal'])
async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self):
@@ -608,9 +610,9 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
await self.sample()
self.fresh('sensor.room', 'sensor.outside')
data = await self.sample()
bedroom = data['input_quality']['rooms']['sensor.bedroom']
bedroom = data['input_quality']['zones']['sensor.bedroom']['sensors']['sensor.bedroom']
self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True))
self.assertEqual((data['excluded_rooms'], data['temperatures']['sensor.bedroom']), ([], 18))
self.assertEqual((data['excluded_zones'], data['temperatures']['sensor.bedroom']), ([], 18))
self.assertEqual(self.c.learners['sensor.bedroom'].count, 1)
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
with self.subTest(state=state):
@@ -618,7 +620,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.states['sensor.bedroom'].state = state
self.states['sensor.bedroom'].last_reported = self.now - age
data = await self.sample()
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
self.assertEqual(data['status'], 'learning')
async def test_poll_outcomes_and_rejections_are_counted(self):
@@ -697,3 +699,39 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
data = await self.sample()
self.assertEqual(data['sample_result'], 'waiting_for_inputs')
self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1)
def multi_sensor_zone(self):
self.entry.subentries = {'s1': types.SimpleNamespace(subentry_id='s1', subentry_type='zone', data={
'key': 'zone_living', 'name': 'Living', 'sensors': ['sensor.room', 'sensor.second'],
'sensor_weights': {'sensor.room': 3, 'sensor.second': 1}, 'comfort_weight': 2,
'target_offset': 0.5})}
self.set_state('sensor.second', '22', unit_of_measurement='°C')
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
async def test_multi_sensor_zone_weights_sensors_and_skips_composition_changes(self):
self.multi_sensor_zone()
data = await self.sample()
self.assertEqual((data['temperatures'], data['zone_names']), ({'zone_living': 20.5}, {'zone_living': 'Living'}))
self.fresh('sensor.room', 'sensor.second', 'sensor.outside')
self.assertEqual((await self.sample())['sample_result'], 'learned')
self.states['sensor.second'].state = 'unavailable'
self.fresh('sensor.room', 'sensor.outside')
data = await self.sample()
self.assertEqual(data['temperatures'], {'zone_living': 20})
# The mean jumped by the sensors' offset, not by a real change: not learned.
self.assertEqual(data['sample_result'], 'sensors_changed')
self.assertEqual(self.c.learners['zone_living'].count, 1)
quality = data['input_quality']['zones']['zone_living']
self.assertEqual((quality['used'], quality['sensors']['sensor.second']['status']), (True, 'unavailable'))
self.fresh('sensor.room', 'sensor.outside')
self.assertEqual((await self.sample())['sample_result'], 'learned')
async def test_zone_comfort_weight_and_target_offset_reach_optimizer(self):
self.multi_sensor_zone()
model = self.c.learners['zone_living']
model.count, model.on_count, model.off_count = 200, 100, 100
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
data = await self.sample()
self.assertEqual(data['status'], 'ready')
self.assertEqual(spy.call_args.args[4], {'zone_living': 2.0})
self.assertEqual(spy.call_args.kwargs['target_offsets'], {'zone_living': 0.5})