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

+115 -34
View File
@@ -1,18 +1,31 @@
"""Validate actual HA form schemas, options and entry migration."""
from homeassistant.config_entries import SOURCE_USER
"""Validate actual HA form schemas, options, zone subentries and entry migration."""
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.heating_mpc.const import DOMAIN
from custom_components.heating_mpc.weather_sources import sources_for
MAIN = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler"}
async def test_config_flow(hass, inputs):
def zones_of(entry):
return {sub.unique_id: sub for sub in entry.subentries.values() if sub.subentry_type == "zone"}
async def setup_entry(hass, entry):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return hass.data[DOMAIN][entry.entry_id]
async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
hass.states.async_set("sensor.bedroom", "19", {"unit_of_measurement": "°C", "friendly_name": "Bedroom"})
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
assert result["type"] is FlowResultType.FORM
main = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"]}
validated = result["data_schema"](main)
validated = result["data_schema"]({**MAIN, "room_entities": ["sensor.room", "sensor.bedroom"]})
assert validated["room_max_age_minutes"] == 30
assert validated["weather_max_age_minutes"] == 120
assert validated["room_stale_use_last_value"] is False
@@ -23,65 +36,134 @@ async def test_config_flow(hass, inputs):
assert result["step_id"] == "weather_extra"
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
assert result["step_id"] == "weights"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {"weight__sensor.room": 2})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"weight__sensor.room": 2, "weight__sensor.bedroom": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"]["weights"] == {"sensor.room": 2}
await hass.async_block_till_done()
entry = result["result"]
assert entry.version == 3
assert "room_entities" not in entry.data and "weights" not in entry.data
zones = zones_of(entry)
assert {key: (sub.title, sub.data["comfort_weight"]) for key, sub in zones.items()} == {
"sensor.room": ("room", 2.0), "sensor.bedroom": ("Bedroom", 1.0)} # HA's name fallback
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.room", "sensor.bedroom"]
async def test_options_flow_keeps_settings(hass, entry, inputs):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
await setup_entry(hass, entry)
result = await hass.config_entries.options.async_init(entry.entry_id)
main = result["data_schema"]({"climate_entity": "climate.reference",
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"],
"room_max_age_minutes": 45, "room_stale_use_last_value": True})
assert "room_entities" not in {str(key) for key in result["data_schema"].schema}
main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True})
result = await hass.config_entries.options.async_configure(result["flow_id"], main)
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({}))
result = await hass.config_entries.options.async_configure(result["flow_id"], {"weight__sensor.room": 3})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["room_max_age_minutes"] == 45
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.config["room_max_age_minutes"] == 45
assert coordinator.config["room_stale_use_last_value"] is True
# The next options form shows the saved choice.
assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["data_schema"]({"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"]})["room_stale_use_last_value"] is True
assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
hass.config_entries.options.async_abort(result["flow_id"])
async def test_v1_entry_migrates_on_setup(hass, inputs):
async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs):
await setup_entry(hass, entry)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"](MAIN))
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]}))
assert result["errors"] == {"base": "invalid_weather"}
async def test_v1_entry_migrates_to_zones_on_setup(hass, inputs):
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
**MAIN, "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
await setup_entry(hass, entry)
assert entry.version == 3
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
assert "outside_entity" not in entry.data and "room_entities" not in entry.data
assert set(zones_of(entry)) == {"sensor.room"}
async def test_v2_migration_keeps_weights_models_and_entities(hass, entry, inputs, hass_storage):
from custom_components.heating_mpc.physics import Learner
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C", "friendly_name": "Living room"})
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()}}}
entry.add_to_hass(hass)
# A forecast entity created by 0.3 for this room.
registry = er.async_get(hass)
old = registry.async_get_or_create("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min",
config_entry=entry, suggested_object_id="mpc_living_forecast")
hass.config_entries.async_update_entry(entry, options={"weights": {"sensor.room": 4.0}})
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 2
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
assert "outside_entity" not in entry.data
coordinator = hass.data[DOMAIN][entry.entry_id]
zone = zones_of(entry)["sensor.room"]
assert (zone.title, zone.data["comfort_weight"], zone.data["sensors"]) == ("Living room", 4.0, ["sensor.room"])
assert "weights" not in entry.options
assert coordinator.learners["sensor.room"].count == 250
# Same entity, now linked to its zone, so deleting the zone removes it.
migrated = registry.async_get(old.entity_id)
assert migrated.config_subentry_id == zone.subentry_id
assert hass.config_entries.async_remove_subentry(entry, zone.subentry_id)
await hass.async_block_till_done()
assert registry.async_get(old.entity_id) is None
async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
await setup_entry(hass, entry)
for entity, value in (("sensor.living_a", "21"), ("sensor.living_b", "22")):
hass.states.async_set(entity, value, {"unit_of_measurement": "°C"})
flows = hass.config_entries.subentries
result = await flows.async_init((entry.entry_id, "zone"), context={"source": SOURCE_USER})
assert result["type"] is FlowResultType.FORM
# A sensor of another zone, or an outdoor source, is refused.
for sensors, error in ((["sensor.room"], "sensor_in_other_zone"), (["sensor.outside"], "sensor_is_weather_source")):
result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": sensors, "comfort_weight": 2, "target_offset": 0})
assert result["errors"] == {"base": error}
result = await flows.async_configure(result["flow_id"], {
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
"target_offset": 0.5, "neighbors": ["sensor.room"]})
assert result["step_id"] == "sensor_weights"
result = await flows.async_configure(result["flow_id"], {
"weight__sensor.living_a": 3, "weight__sensor.living_b": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
assert living.data["sensor_weights"] == {"sensor.living_a": 3.0, "sensor.living_b": 1.0}
coordinator = hass.data[DOMAIN][entry.entry_id]
key = living.data["key"]
assert coordinator.zones[key].neighbors == ("sensor.room",)
assert coordinator.zones["sensor.room"].neighbors == (key,) # symmetric
assert coordinator.data["temperatures"][key] == 21.25
# Edit: drop a sensor; the key (and so the learned model) is kept.
result = await flows.async_init((entry.entry_id, "zone"), context={
"source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id})
result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
await hass.async_block_till_done()
assert entry.subentries[living.subentry_id].data["key"] == key
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
async def test_options_flow_clears_optional_weather_sources(hass, inputs):
"""Swapping providers in the UI must allow removing an optional source list."""
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
**MAIN, "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
"weights": {"sensor.room": 1.0}})
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await setup_entry(hass, entry)
flow = hass.config_entries.options
result = await flow.async_init(entry.entry_id)
result = await flow.async_configure(result["flow_id"], result["data_schema"]({
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"]}))
result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
markers = {str(key): key for key in result["data_schema"].schema}
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
# The user removes the wind provider; a cleared field may be omitted on submit.
@@ -89,7 +171,6 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs):
{"outside_temperature_sources": ["sensor.outside"]}))
result = await flow.async_configure(result["flow_id"], result["data_schema"](
{"outside_pressure_sources": ["sensor.pressure"]}))
result = await flow.async_configure(result["flow_id"], {"weight__sensor.room": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["outside_wind_sources"] == []
+30 -10
View File
@@ -10,6 +10,21 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from custom_components.heating_mpc.const import DOMAIN
from homeassistant.config_entries import ConfigSubentry
from types import MappingProxyType
def zone_subentry(entry, key):
return next(sub for sub in entry.subentries.values() if sub.unique_id == key)
def add_zone(hass, entry, key, sensors, name=None):
"""What the Add zone flow stores, added directly."""
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
data=MappingProxyType({"key": key, "name": name or key, "sensors": sensors,
"sensor_weights": {s: 1.0 for s in sensors}, "comfort_weight": 1.0,
"target_offset": 0.0, "neighbors": []}),
subentry_type="zone", title=name or key, unique_id=key))
async def setup(hass, entry):
@@ -28,7 +43,8 @@ async def test_setup_reload_unload(hass, entry, inputs):
ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning"
status = hass.states.get(ids[f"{entry.entry_id}_status"])
assert status.attributes["input_quality"]["rooms"]["sensor.room"]["max_age_seconds_24h"] == 0
zone_quality = status.attributes["input_quality"]["zones"]["sensor.room"]
assert zone_quality["sensors"]["sensor.room"]["max_age_seconds_24h"] == 0
# Volatile diagnostics stay visible but are not written to the Recorder.
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
assert "models" not in status.state_info["unrecorded_attributes"]
@@ -45,16 +61,19 @@ async def test_setup_reload_unload(hass, entry, inputs):
assert coordinator._unsub_refresh is None
async def test_options_reload_removes_only_old_room(hass, entry, inputs):
async def test_replacing_a_zone_removes_only_its_forecast(hass, entry, inputs):
await setup(hass, entry)
registry = er.async_get(hass)
old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
add_zone(hass, entry, "zone_new", ["sensor.new"], "New")
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].rooms == ["zone_new"]
assert registry.async_get(old) is None
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min")
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11
@@ -203,7 +222,9 @@ async def test_removed_room_model_survives_options_round_trip(hass, entry, input
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
await setup(hass, entry)
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
add_zone(hass, entry, "sensor.new", ["sensor.new"])
await hass.async_block_till_done()
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
await hass.async_block_till_done()
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
@@ -212,8 +233,7 @@ async def test_removed_room_model_survives_options_round_trip(hass, entry, input
saved = hass_storage[key]["data"]
assert set(saved["learners"]) == {"sensor.new"}
assert saved["retired_learners"]["sensor.room"]["count"] == 250
hass.config_entries.async_update_entry(
entry, options={"room_entities": ["sensor.room", "sensor.new"]})
add_zone(hass, entry, "sensor.room", ["sensor.room"])
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
@@ -234,9 +254,9 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs,
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
assert state.attributes["samples"] == 1
assert state.attributes["rooms"]["sensor.room"]["samples"] == 1
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
assert state.attributes["unit_of_measurement"] == "°C"
assert "rooms" in state.state_info["unrecorded_attributes"]
assert "zones" in state.state_info["unrecorded_attributes"]
assert hass.states.get(ids[30]).state == "unknown"
+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})
+8
View File
@@ -45,6 +45,14 @@ class ModelTests(unittest.TestCase):
learners, False, 3600, horizon_minutes=60)
self.assertTrue(out['proposal'])
def test_zone_target_offsets_shift_comfort(self):
learners = {'r': m.Learner()}
args = ({'r': 20.8}, 8, 0, 22, {'r': 1}, learners, False, 3600)
self.assertTrue(m.optimize(*args, horizon_minutes=60)['proposal'])
self.assertFalse(m.optimize(*args, horizon_minutes=60, target_offsets={'r': -1.5})['proposal'])
with self.assertRaises(ValueError):
m.optimize(*args, horizon_minutes=60, target_offsets={'r': float('nan')})
def test_minimum_duration_on_and_off(self):
learners = {'r': m.Learner()}
# Could not switch OFF during first 15 minutes if just turned ON.
+74
View File
@@ -0,0 +1,74 @@
"""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()