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
@@ -5,7 +5,7 @@ import logging
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from math import exp, isfinite
|
||||
from math import exp
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
@@ -26,14 +26,26 @@ from .physics import (
|
||||
lag_from_time_in_state, optimize, relay_segments,
|
||||
)
|
||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
||||
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
# Relay changes kept between polls; more than this in five minutes is chatter.
|
||||
MAX_RELAY_EVENTS = 256
|
||||
# Models of rooms removed in Options, kept in case a room is added back.
|
||||
# Models of zones removed in the UI, kept in case a zone is added back.
|
||||
MAX_RETIRED_ROOMS = 24
|
||||
|
||||
|
||||
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
|
||||
"""Zones from the entry's zone subentries; one zone per legacy room sensor otherwise."""
|
||||
subentries = getattr(entry, "subentries", None) or {}
|
||||
items = [(sub.data, sub.subentry_id) for sub in subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_ZONE]
|
||||
if not items: # not migrated yet (or offline tests): same zones migration creates
|
||||
items = [(data, None) for data in legacy_zone_data(
|
||||
config.get(CONF_ROOMS, []), config.get(CONF_WEIGHTS))]
|
||||
return build_zones(items)
|
||||
|
||||
|
||||
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""The existing generic thermostat stays the only actuator owner."""
|
||||
|
||||
@@ -42,7 +54,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
update_interval=timedelta(minutes=5))
|
||||
self.entry = entry
|
||||
self.config = {**DEFAULTS, **entry.data, **entry.options}
|
||||
self.rooms: list[str] = self.config[CONF_ROOMS]
|
||||
self.zones: dict[str, Zone] = {zone.key: zone for zone in entry_zone_list(entry, self.config)}
|
||||
# Zone keys; models, forecasts and statistics are keyed by these.
|
||||
self.rooms: list[str] = list(self.zones)
|
||||
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
|
||||
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||
self._retired: dict[str, dict[str, Any]] = {}
|
||||
self._report_ages = ReportAgeHistory()
|
||||
@@ -173,7 +188,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def _prediction_inputs(self) -> tuple:
|
||||
"""Values the prediction depends on, used to detect changes during calculation."""
|
||||
heater = self.config[CONF_HEATER]
|
||||
entities = sorted(set(self.rooms) | set(sources_for(self.config, "temperature")) |
|
||||
entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) |
|
||||
{self.config[CONF_CLIMATE], heater})
|
||||
values = []
|
||||
for entity_id in entities:
|
||||
@@ -198,27 +213,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
for entity_id in sources_for(self.config, metric)
|
||||
}
|
||||
snapshot = {entity: self.hass.states.get(entity)
|
||||
for entity in set(self.rooms) | weather_entity_ids}
|
||||
for entity in set(self.sensors) | weather_entity_ids}
|
||||
inputs_before = self._prediction_inputs()
|
||||
room_quality = {}
|
||||
temps = {}
|
||||
sensor_quality = {}
|
||||
readings: dict[str, float | None] = {}
|
||||
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
||||
for room in self.rooms:
|
||||
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
for sensor in self.sensors:
|
||||
quality = self._sensor_quality(sensor, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
value = None
|
||||
# Optionally trust sensors that report only on change, but never a
|
||||
# sensor silent for a day: it is most likely dead.
|
||||
if quality["status"] == "valid" or (
|
||||
keep_stale and quality["status"] == "stale"
|
||||
and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60):
|
||||
state = snapshot[room]
|
||||
state = snapshot[sensor]
|
||||
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||
if value is None or not 0 <= value <= 45:
|
||||
quality["status"] = "invalid_value_or_unit"
|
||||
value = None
|
||||
quality["used"] = value is not None
|
||||
room_quality[room] = quality
|
||||
temps[room] = value
|
||||
sensor_quality[sensor] = quality
|
||||
readings[sensor] = value
|
||||
# A zone is usable while at least one of its sensors is; sensor weights
|
||||
# combine them. Which sensors were used matters for learning (see below).
|
||||
temps: dict[str, float | None] = {}
|
||||
compositions: dict[str, frozenset[str]] = {}
|
||||
zone_quality = {}
|
||||
for key, zone in self.zones.items():
|
||||
temps[key], compositions[key] = zone_temperature(zone, readings)
|
||||
zone_quality[key] = {"name": zone.name, "used": temps[key] is not None,
|
||||
"sensors": {s: sensor_quality[s] for s in zone.sensors}}
|
||||
weather_states = {}
|
||||
weather_quality = {}
|
||||
for entity_id in weather_entity_ids:
|
||||
@@ -240,9 +264,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
metric_quality[metric][entity_id] = quality
|
||||
outside = weather["temperature"]
|
||||
status: dict[str, Any] = {
|
||||
"input_quality": {"rooms": room_quality, "weather": metric_quality},
|
||||
"input_quality": {"zones": zone_quality, "weather": metric_quality},
|
||||
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
|
||||
"temperatures": {r: t for r, t in temps.items() if t is not None},
|
||||
"temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None},
|
||||
"zone_names": {key: zone.name for key, zone in self.zones.items()},
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"mode": climate.state if climate else "unavailable",
|
||||
@@ -250,8 +275,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"weather_active_sources": {k: v for k, v in active_sources.items() if v},
|
||||
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
||||
"weather_model_inputs": ["temperature"],
|
||||
"retired_room_models": sorted(self._retired),
|
||||
"excluded_rooms": [room for room in self.rooms if temps[room] is None],
|
||||
"retired_zone_models": sorted(self._retired),
|
||||
"excluded_zones": [room for room in self.rooms if temps[room] is None],
|
||||
}
|
||||
heater_valid = heater is not None and heater.state in ("on", "off")
|
||||
heater_on = heater_valid and heater.state == "on"
|
||||
@@ -274,11 +299,16 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._count_result(status, "waiting_for_inputs")
|
||||
return status
|
||||
|
||||
# A room learns only across a poll pair where it was usable at both ends.
|
||||
pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
|
||||
# A zone learns only across a poll pair where it was usable at both ends with
|
||||
# the same sensors: a sensor dropping out shifts the mean by its offset.
|
||||
pairs = [room for room in vals if self._previous and room in self._previous["temps"]
|
||||
and self._previous["compositions"].get(room) == compositions[room]]
|
||||
learned = False
|
||||
if not pairs:
|
||||
status["sample_result"] = "first_sample"
|
||||
changed = self._previous and any(
|
||||
room in self._previous["temps"] and self._previous["compositions"].get(room)
|
||||
!= compositions[room] for room in vals)
|
||||
status["sample_result"] = "sensors_changed" if changed else "first_sample"
|
||||
else:
|
||||
dt = (now - self._previous["time"]).total_seconds()
|
||||
# Do not fit across a relay change, a provider/fallback switch or an
|
||||
@@ -305,7 +335,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._count_result(status, status["sample_result"])
|
||||
self._previous = {
|
||||
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
|
||||
"outside_source": active_sources["temperature"],
|
||||
"outside_source": active_sources["temperature"], "compositions": compositions,
|
||||
}
|
||||
# Parameters after this poll's learning: forecasts from here use only data so far.
|
||||
self._evaluator.observe(now, vals, outside, self._lag["lag"], self._lag["uncertainty"],
|
||||
@@ -323,15 +353,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
weights = self.config.get(CONF_WEIGHTS, {})
|
||||
try:
|
||||
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
|
||||
if any(not isfinite(w) or w <= 0 for w in weights.values()) or not isfinite(sum(weights.values())):
|
||||
raise ValueError
|
||||
except (AttributeError, TypeError, ValueError, OverflowError):
|
||||
status["status"] = "invalid_weights"
|
||||
return status
|
||||
# Left-out rooms take their weight with them; the rest are re-normalized.
|
||||
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
|
||||
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
|
||||
# Left-out zones take their weight with them; the rest are re-normalized.
|
||||
used_weights = {room: weights[room] for room in vals}
|
||||
weighted_temp = sum(used_weights[r] * vals[r] for r in vals) / sum(used_weights.values())
|
||||
status.update({
|
||||
@@ -343,7 +367,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"models": {
|
||||
room: {"loss": round(model.theta[0], 6),
|
||||
room: {"name": self.zones[room].name,
|
||||
"loss": round(model.theta[0], 6),
|
||||
"heat": round(model.theta[1], 6),
|
||||
"bias": round(model.theta[2], 6),
|
||||
"samples": model.count,
|
||||
@@ -388,6 +413,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
||||
heating_cost=float(self.config[CONF_HEAT_COST]),
|
||||
comfort_band=float(self.config[CONF_BAND]),
|
||||
target_offsets={room: self.zones[room].target_offset for room in vals},
|
||||
)
|
||||
# Discard a result if a value used by the prediction changed while
|
||||
# saving or while the worker calculated the hypothetical plan. Optional
|
||||
|
||||
Reference in new issue
Block a user