Files
heating-mpc-ha/tests/test_zones.py
T
buzeraandClaude Opus 5.5 46587af8a3 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>
2026-10-27 02:32:11 +02:00

75 lines
4.0 KiB
Python

"""Thermal zones: parsing, sensor weighting and validation, without Home Assistant."""
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 zones as z # noqa: E402
def zone(key, sensors, **extra):
return {'key': key, 'name': key.title(), 'sensors': sensors, **extra}
class ZoneTests(unittest.TestCase):
def test_legacy_rooms_become_one_sensor_zones_keyed_by_entity(self):
data = z.legacy_zone_data(['sensor.a', 'sensor.b', 'sensor.a'], {'sensor.a': 3, 'sensor.b': 'x'},
{'sensor.a': 'Living'})
zones = z.build_zones((d, None) for d in data)
self.assertEqual([(q.key, q.name, q.sensors, q.comfort_weight) for q in zones],
[('sensor.a', 'Living', ('sensor.a',), 3.0), ('sensor.b', 'sensor.b', ('sensor.b',), 1.0)])
def test_invalid_values_fall_back_and_bad_zones_are_skipped(self):
parsed = z.zone_from_data(zone('bed', ['sensor.b', 'light.x', 'sensor.b'], comfort_weight=99,
target_offset='bad', sensor_weights={'sensor.b': -1}))
self.assertEqual((parsed.sensors, parsed.comfort_weight, parsed.target_offset,
parsed.sensor_weight('sensor.b')), (('sensor.b',), 1.0, 0.0, 1.0))
for bad in ({'key': 'x', 'sensors': []}, {'sensors': ['sensor.a']}, {'key': 'x', 'sensors': 'sensor.a'}):
self.assertIsNone(z.zone_from_data(bad))
# The subentry ID stands in for a missing key.
self.assertEqual(z.zone_from_data({'sensors': ['sensor.a']}, 'sub1').key, 'sub1')
def test_duplicates_shared_sensors_and_neighbours(self):
zones = z.build_zones([
(zone('living', ['sensor.l1', 'sensor.l2'], neighbors=['bed', 'ghost', 'living']), 's1'),
(zone('bed', ['sensor.b']), 's2'),
(zone('living', ['sensor.x']), 's3'), # duplicate key
(zone('hall', ['sensor.l2']), 's4'), # sensor already in another zone
])
self.assertEqual([q.key for q in zones], ['living', 'bed'])
# Neighbours exist and are symmetric.
self.assertEqual([q.neighbors for q in zones], [('bed',), ('living',)])
def test_zone_temperature_weights_and_reports_sensors_used(self):
living = z.zone_from_data(zone('living', ['sensor.a', 'sensor.b'],
sensor_weights={'sensor.a': 3, 'sensor.b': 1}))
self.assertEqual(z.zone_temperature(living, {'sensor.a': 20.0, 'sensor.b': 22.0}),
(20.5, frozenset({'sensor.a', 'sensor.b'})))
self.assertEqual(z.zone_temperature(living, {'sensor.a': None, 'sensor.b': 22.0}),
(22.0, frozenset({'sensor.b'})))
self.assertEqual(z.zone_temperature(living, {}), (None, frozenset()))
def test_form_validation(self):
others = [z.zone_from_data(zone('bed', ['sensor.b']))]
cases = {
'zone_name_required': {'name': ' ', 'sensors': ['sensor.a']},
'zone_sensors': {'name': 'Living', 'sensors': []},
'sensor_in_other_zone': {'name': 'Living', 'sensors': ['sensor.b']},
'sensor_is_weather_source': {'name': 'Living', 'sensors': ['sensor.out']},
None: {'name': 'Living', 'sensors': ['sensor.a', 'sensor.c']},
}
for expected, data in cases.items():
with self.subTest(expected=expected):
self.assertEqual(z.validate_zone(data, others, ['sensor.out']), expected)
self.assertEqual(z.validate_zone({'name': 'x', 'sensors': [f'sensor.s{i}' for i in range(7)]}, [], []),
'zone_sensors')
twelve = [z.zone_from_data(zone(f'z{i}', [f'sensor.z{i}'])) for i in range(12)]
self.assertEqual(z.validate_zone({'name': 'x', 'sensors': ['sensor.new']}, twelve, []), 'too_many_zones')
if __name__ == '__main__':
unittest.main()