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:
1 parent
220ac570d2
commit
46587af8a3
15 files changed
+867
-155
No files matched your search
+30
-10
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user