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

+162
View File
@@ -0,0 +1,162 @@
"""Thermal zones: rooms or areas, each with one or more temperature sensors.
Pure Python; no Home Assistant imports.
Two kinds of weight are kept apart on purpose:
* sensor weights combine several sensors in one zone into one temperature;
* the comfort weight says how much the zone matters to the MPC.
Neither is a physical coefficient. A cold bedroom can get a higher comfort
weight without its learned heat loss changing.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from math import isfinite
from typing import Any, Iterable, Mapping
SUBENTRY_ZONE = "zone"
MAX_ZONES = 12
MAX_SENSORS_PER_ZONE = 6
COMFORT_WEIGHT_RANGE = (0.1, 10.0)
SENSOR_WEIGHT_RANGE = (0.1, 10.0)
TARGET_OFFSET_RANGE = (-3.0, 3.0)
# Subentry data keys.
ZONE_KEY = "key"
ZONE_NAME = "name"
ZONE_SENSORS = "sensors"
ZONE_SENSOR_WEIGHTS = "sensor_weights"
ZONE_COMFORT_WEIGHT = "comfort_weight"
ZONE_TARGET_OFFSET = "target_offset"
ZONE_NEIGHBORS = "neighbors"
@dataclass(frozen=True)
class Zone:
# Stable identity for learned models and entity unique IDs. Zones migrated from
# 0.3 keep their sensor's entity ID; new zones get a generated key.
key: str
name: str
sensors: tuple[str, ...]
sensor_weights: Mapping[str, float] = field(default_factory=dict)
comfort_weight: float = 1.0
target_offset: float = 0.0
neighbors: tuple[str, ...] = ()
subentry_id: str | None = None
def sensor_weight(self, sensor: str) -> float:
return float(self.sensor_weights.get(sensor, 1.0))
def _number(value: Any, default: float, lo: float, hi: float) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return default
return number if isfinite(number) and lo <= number <= hi else default
def zone_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> Zone | None:
"""Parse one zone's stored data; None if it cannot be used."""
key = data.get(ZONE_KEY) or subentry_id
sensors = data.get(ZONE_SENSORS)
if not isinstance(key, str) or not isinstance(sensors, (list, tuple)):
return None
sensors = tuple(dict.fromkeys(s for s in sensors if isinstance(s, str) and s.startswith("sensor.")))
if not sensors:
return None
raw_weights = data.get(ZONE_SENSOR_WEIGHTS)
raw_weights = raw_weights if isinstance(raw_weights, Mapping) else {}
weights = {s: _number(raw_weights.get(s), 1.0, *SENSOR_WEIGHT_RANGE) for s in sensors}
neighbors = data.get(ZONE_NEIGHBORS)
neighbors = tuple(n for n in neighbors if isinstance(n, str) and n != key) \
if isinstance(neighbors, (list, tuple)) else ()
name = data.get(ZONE_NAME)
return Zone(
key=key,
name=name if isinstance(name, str) and name.strip() else key,
sensors=sensors,
sensor_weights=weights,
comfort_weight=_number(data.get(ZONE_COMFORT_WEIGHT), 1.0, *COMFORT_WEIGHT_RANGE),
target_offset=_number(data.get(ZONE_TARGET_OFFSET), 0.0, *TARGET_OFFSET_RANGE),
neighbors=neighbors,
subentry_id=subentry_id,
)
def legacy_zone_data(rooms: Iterable[str], weights: Mapping[str, Any] | None,
names: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
"""Zone data for 0.3 configurations: one zone per room sensor.
The sensor's entity ID becomes the zone key, so learned models, forecast
entities and accuracy statistics carry over unchanged.
"""
weights = weights if isinstance(weights, Mapping) else {}
names = names or {}
result = []
for room in dict.fromkeys(r for r in rooms if isinstance(r, str)):
result.append({
ZONE_KEY: room,
ZONE_NAME: names.get(room) or room,
ZONE_SENSORS: [room],
ZONE_SENSOR_WEIGHTS: {room: 1.0},
ZONE_COMFORT_WEIGHT: _number(weights.get(room), 1.0, *COMFORT_WEIGHT_RANGE),
ZONE_TARGET_OFFSET: 0.0,
ZONE_NEIGHBORS: [],
})
return result
def build_zones(items: Iterable[tuple[Mapping[str, Any], str | None]]) -> list[Zone]:
"""Valid zones from (data, subentry_id) pairs: unique keys, each sensor in one zone."""
zones: list[Zone] = []
keys: set[str] = set()
taken: set[str] = set()
for data, subentry_id in items:
zone = zone_from_data(data, subentry_id)
if zone is None or zone.key in keys or taken.intersection(zone.sensors):
continue
zones.append(zone)
keys.add(zone.key)
taken.update(zone.sensors)
if len(zones) == MAX_ZONES:
break
# Neighbours must exist; exchange is symmetric, so record both directions.
linked = {zone.key: set(n for n in zone.neighbors if n in keys) for zone in zones}
for key, others in list(linked.items()):
for other in others:
linked[other].add(key)
return [Zone(**{**zone.__dict__, "neighbors": tuple(sorted(linked[zone.key]))}) for zone in zones]
def zone_temperature(zone: Zone, readings: Mapping[str, float | None]) -> tuple[float | None, frozenset[str]]:
"""Sensor-weighted mean of the zone's usable readings, and which sensors it used.
The set of sensors matters for learning: when a sensor drops out or rejoins,
the mean jumps by the offset between sensors, which is not a real change.
"""
used = [s for s in zone.sensors if readings.get(s) is not None]
if not used:
return None, frozenset()
total = sum(zone.sensor_weight(s) for s in used)
value = sum(zone.sensor_weight(s) * float(readings[s]) for s in used) / total
return value, frozenset(used)
def validate_zone(data: Mapping[str, Any], others: Iterable[Zone], weather_sensors: Iterable[str]) -> str | None:
"""Translation key of the first problem with a zone form, or None."""
sensors = data.get(ZONE_SENSORS) or []
if not str(data.get(ZONE_NAME, "")).strip():
return "zone_name_required"
if not sensors or len(sensors) > MAX_SENSORS_PER_ZONE or len(set(sensors)) != len(sensors):
return "zone_sensors"
others = list(others)
if any(set(sensors).intersection(zone.sensors) for zone in others):
return "sensor_in_other_zone"
if set(sensors).intersection(weather_sensors):
return "sensor_is_weather_source"
if len(others) >= MAX_ZONES:
return "too_many_zones"
return None