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
@@ -1,16 +1,19 @@
|
|||||||
"""Heating MPC custom integration, initial release shadow-only."""
|
"""Heating MPC custom integration, initial release shadow-only."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
|
|
||||||
from .const import CONF_HEATER, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
||||||
from .weather_sources import sources_for
|
from .weather_sources import sources_for
|
||||||
from .coordinator import HeatingMPCCoordinator
|
from .coordinator import HeatingMPCCoordinator
|
||||||
|
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||||
|
|
||||||
@@ -31,12 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||||
raise
|
raise
|
||||||
entry.async_on_unload(unsub_relay)
|
entry.async_on_unload(unsub_relay)
|
||||||
# Remove only obsolete room forecasts belonging to this entry/platform.
|
# Remove only obsolete zone forecasts belonging to this entry/platform. Zones
|
||||||
|
# deleted in the UI are also removed by HA through their subentry link.
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms}
|
expected = {f"{entry.entry_id}_{zone}_predicted_60min" for zone in coordinator.rooms}
|
||||||
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
||||||
if (entity.platform == DOMAIN and
|
if (entity.platform == DOMAIN and
|
||||||
entity.unique_id.startswith(f"{entry.entry_id}_sensor.") and
|
entity.unique_id.startswith(f"{entry.entry_id}_") and
|
||||||
entity.unique_id.endswith("_predicted_60min") and
|
entity.unique_id.endswith("_predicted_60min") and
|
||||||
entity.unique_id not in expected):
|
entity.unique_id not in expected):
|
||||||
registry.async_remove(entity.entity_id)
|
registry.async_remove(entity.entity_id)
|
||||||
@@ -62,8 +66,14 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Migrate legacy single weather source without changing user selections."""
|
"""Migrate older entries without changing the user's selections.
|
||||||
if entry.version > 2:
|
|
||||||
|
1 -> 2: single outdoor sensor becomes a prioritized source list.
|
||||||
|
2 -> 3: each room sensor becomes a one-sensor zone subentry with its comfort
|
||||||
|
weight. Its entity ID stays the zone key, so learned models, forecast entities
|
||||||
|
and accuracy statistics carry over. Older versions cannot load version 3.
|
||||||
|
"""
|
||||||
|
if entry.version > 3:
|
||||||
return False
|
return False
|
||||||
if entry.version == 1:
|
if entry.version == 1:
|
||||||
def migrate(config):
|
def migrate(config):
|
||||||
@@ -76,4 +86,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
entry, data=migrate(entry.data), options=migrate(entry.options),
|
entry, data=migrate(entry.data), options=migrate(entry.options),
|
||||||
version=2, minor_version=1,
|
version=2, minor_version=1,
|
||||||
)
|
)
|
||||||
|
if entry.version == 2:
|
||||||
|
config = {**entry.data, **entry.options}
|
||||||
|
rooms = [room for room in config.get(CONF_ROOMS, []) if isinstance(room, str)]
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
names = {}
|
||||||
|
for room in rooms:
|
||||||
|
state, registered = hass.states.get(room), registry.async_get(room)
|
||||||
|
names[room] = ((state.name if state else None)
|
||||||
|
or (registered.name or registered.original_name if registered else None)
|
||||||
|
or room)
|
||||||
|
existing = {sub.unique_id for sub in entry.subentries.values()}
|
||||||
|
for data in legacy_zone_data(rooms, config.get(CONF_WEIGHTS), names):
|
||||||
|
if data[ZONE_KEY] not in existing:
|
||||||
|
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
|
||||||
|
data=MappingProxyType(data), subentry_type=SUBENTRY_ZONE,
|
||||||
|
title=data[ZONE_NAME], unique_id=data[ZONE_KEY]))
|
||||||
|
|
||||||
|
def strip(config):
|
||||||
|
return {k: v for k, v in config.items() if k not in (CONF_ROOMS, CONF_WEIGHTS)}
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry, data=strip(entry.data), options=strip(entry.options), version=3, minor_version=1)
|
||||||
return True
|
return True
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""UI-only installation and reconfiguration: rooms, source priorities, weights."""
|
"""UI-only installation and reconfiguration: zones, source priorities, weights."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
@@ -11,11 +12,16 @@ from homeassistant.helpers import selector
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE,
|
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE,
|
||||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
||||||
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
||||||
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
||||||
)
|
)
|
||||||
from .weather_sources import sources_for
|
from .weather_sources import sources_for
|
||||||
|
from .zones import (
|
||||||
|
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE,
|
||||||
|
ZONE_COMFORT_WEIGHT, ZONE_KEY, ZONE_NAME, ZONE_NEIGHBORS, ZONE_SENSOR_WEIGHTS,
|
||||||
|
ZONE_SENSORS, ZONE_TARGET_OFFSET, Zone, build_zones, legacy_zone_data, validate_zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _entity_picker(multiple: bool = False):
|
def _entity_picker(multiple: bool = False):
|
||||||
@@ -30,14 +36,16 @@ def _required(key: str, data: dict):
|
|||||||
|
|
||||||
|
|
||||||
def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||||
|
"""Setup asks for room sensors (one zone each); Configure manages zones separately."""
|
||||||
data = config or {}
|
data = config or {}
|
||||||
schema: dict[Any, Any] = {
|
schema: dict[Any, Any] = {
|
||||||
_required(CONF_CLIMATE, data): selector.EntitySelector(
|
_required(CONF_CLIMATE, data): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain="climate")),
|
selector.EntitySelectorConfig(domain="climate")),
|
||||||
_required(CONF_HEATER, data): selector.EntitySelector(
|
_required(CONF_HEATER, data): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain="switch")),
|
selector.EntitySelectorConfig(domain="switch")),
|
||||||
_required(CONF_ROOMS, data): _entity_picker(multiple=True),
|
|
||||||
}
|
}
|
||||||
|
if not detailed:
|
||||||
|
schema[_required(CONF_ROOMS, data)] = _entity_picker(multiple=True)
|
||||||
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
|
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
|
||||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
|
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
|
||||||
vol.Coerce(int), vol.Range(min=5, max=1440))
|
vol.Coerce(int), vol.Range(min=5, max=1440))
|
||||||
@@ -98,10 +106,22 @@ def _valid_weather(rooms: list[str], data: dict) -> bool:
|
|||||||
return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms)
|
return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_zones(entry: config_entries.ConfigEntry) -> list[Zone]:
|
||||||
|
"""Zones of a config entry, from its zone subentries."""
|
||||||
|
return build_zones((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||||
|
if sub.subentry_type == SUBENTRY_ZONE)
|
||||||
|
|
||||||
|
|
||||||
|
def _number(lo: float, hi: float, step: float) -> selector.NumberSelector:
|
||||||
|
return selector.NumberSelector(selector.NumberSelectorConfig(
|
||||||
|
min=lo, max=hi, step=step, mode=selector.NumberSelectorMode.BOX))
|
||||||
|
|
||||||
|
|
||||||
class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Create a native observation-only MPC installation."""
|
"""Create a native observation-only MPC installation."""
|
||||||
|
|
||||||
VERSION = 2
|
# 3: rooms and comfort weights moved into zone subentries.
|
||||||
|
VERSION = 3
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._draft: dict[str, Any] = {}
|
self._draft: dict[str, Any] = {}
|
||||||
@@ -136,11 +156,17 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._draft[CONF_WEIGHTS] = {
|
rooms = self._draft.pop(CONF_ROOMS)
|
||||||
room: float(user_input[f"weight__{room}"])
|
weights = {room: float(user_input[f"weight__{room}"]) for room in rooms}
|
||||||
for room in self._draft[CONF_ROOMS]
|
names = {}
|
||||||
}
|
for room in rooms:
|
||||||
return self.async_create_entry(title="Heating MPC", data=self._draft)
|
state = self.hass.states.get(room)
|
||||||
|
names[room] = state.name if state else room
|
||||||
|
# Each chosen sensor becomes its own zone; zones can be edited later.
|
||||||
|
zones = [{"data": data, "subentry_type": SUBENTRY_ZONE, "title": data[ZONE_NAME],
|
||||||
|
"unique_id": data[ZONE_KEY]}
|
||||||
|
for data in legacy_zone_data(rooms, weights, names)]
|
||||||
|
return self.async_create_entry(title="Heating MPC", data=self._draft, subentries=zones)
|
||||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||||
self._draft[CONF_ROOMS]))
|
self._draft[CONF_ROOMS]))
|
||||||
|
|
||||||
@@ -149,6 +175,11 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
||||||
return HeatingMPCOptionsFlow()
|
return HeatingMPCOptionsFlow()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@callback
|
||||||
|
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry):
|
||||||
|
return {SUBENTRY_ZONE: ZoneSubentryFlow}
|
||||||
|
|
||||||
|
|
||||||
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||||
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
|
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
|
||||||
@@ -156,12 +187,12 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._draft: dict[str, Any] = {}
|
self._draft: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _zone_sensors(self) -> list[str]:
|
||||||
|
return [sensor for zone in entry_zones(self.config_entry) for sensor in zone.sensors]
|
||||||
|
|
||||||
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
||||||
config = {**self.config_entry.data, **self.config_entry.options}
|
config = {**self.config_entry.data, **self.config_entry.options}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if not _valid_rooms(user_input[CONF_ROOMS]):
|
|
||||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
|
||||||
config, detailed=True), errors={"base": "invalid_rooms"})
|
|
||||||
self._draft = user_input
|
self._draft = user_input
|
||||||
return await self.async_step_weather()
|
return await self.async_step_weather()
|
||||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||||
@@ -170,7 +201,7 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
|||||||
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
|
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
|
||||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if not _valid_weather(self._draft[CONF_ROOMS], user_input):
|
if not _valid_weather(self._zone_sensors(), user_input):
|
||||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||||
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"})
|
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"})
|
||||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_MAIN))
|
self._draft.update(_with_cleared_weather(user_input, WEATHER_MAIN))
|
||||||
@@ -182,19 +213,87 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
|||||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_EXTRA))
|
self._draft.update(_with_cleared_weather(user_input, WEATHER_EXTRA))
|
||||||
return await self.async_step_weights()
|
|
||||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
|
||||||
config, WEATHER_EXTRA))
|
|
||||||
|
|
||||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
|
||||||
if user_input is not None:
|
|
||||||
self._draft[CONF_WEIGHTS] = {
|
|
||||||
room: float(user_input[f"weight__{room}"])
|
|
||||||
for room in self._draft[CONF_ROOMS]
|
|
||||||
}
|
|
||||||
# Prevent an old single-temperature sensor from overriding new sources.
|
# Prevent an old single-temperature sensor from overriding new sources.
|
||||||
self._draft.pop(CONF_OUTSIDE, None)
|
self._draft.pop(CONF_OUTSIDE, None)
|
||||||
return self.async_create_entry(title="", data=self._draft)
|
return self.async_create_entry(title="", data=self._draft)
|
||||||
config = {**self.config_entry.data, **self.config_entry.options}
|
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
config, WEATHER_EXTRA))
|
||||||
self._draft[CONF_ROOMS], config.get(CONF_WEIGHTS, {})))
|
|
||||||
|
|
||||||
|
class ZoneSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||||
|
"""Add or edit a thermal zone: its sensors, comfort weight and neighbours."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._draft: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _other_zones(self, key: str | None) -> list[Zone]:
|
||||||
|
return [zone for zone in entry_zones(self._get_entry()) if zone.key != key]
|
||||||
|
|
||||||
|
def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema:
|
||||||
|
schema: dict[Any, Any] = {
|
||||||
|
vol.Required(ZONE_NAME, default=data.get(ZONE_NAME, "")): selector.TextSelector(),
|
||||||
|
vol.Required(ZONE_SENSORS, default=list(data.get(ZONE_SENSORS, []))):
|
||||||
|
_entity_picker(multiple=True),
|
||||||
|
vol.Required(ZONE_COMFORT_WEIGHT, default=data.get(ZONE_COMFORT_WEIGHT, 1.0)):
|
||||||
|
_number(*COMFORT_WEIGHT_RANGE, 0.1),
|
||||||
|
vol.Required(ZONE_TARGET_OFFSET, default=data.get(ZONE_TARGET_OFFSET, 0.0)):
|
||||||
|
_number(*TARGET_OFFSET_RANGE, 0.1),
|
||||||
|
}
|
||||||
|
others = self._other_zones(key)
|
||||||
|
if others:
|
||||||
|
schema[vol.Optional(ZONE_NEIGHBORS, description={
|
||||||
|
"suggested_value": [n for n in data.get(ZONE_NEIGHBORS, []) if n in {z.key for z in others}]})] = \
|
||||||
|
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||||
|
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in others],
|
||||||
|
multiple=True, mode=selector.SelectSelectorMode.LIST))
|
||||||
|
return vol.Schema(schema)
|
||||||
|
|
||||||
|
async def _zone_form(self, step_id: str, user_input: dict[str, Any] | None,
|
||||||
|
existing: dict[str, Any], key: str | None):
|
||||||
|
if user_input is not None:
|
||||||
|
entry = self._get_entry()
|
||||||
|
config = {**entry.data, **entry.options}
|
||||||
|
weather = {s for metric in ("temperature",) for s in sources_for(config, metric)}
|
||||||
|
error = validate_zone(user_input, self._other_zones(key), weather)
|
||||||
|
if error:
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(user_input, key),
|
||||||
|
errors={"base": error})
|
||||||
|
self._draft = {**existing, **user_input, ZONE_NAME: user_input[ZONE_NAME].strip()}
|
||||||
|
self._draft.setdefault(ZONE_NEIGHBORS, [])
|
||||||
|
if len(user_input[ZONE_SENSORS]) > 1:
|
||||||
|
return await self.async_step_sensor_weights()
|
||||||
|
self._draft[ZONE_SENSOR_WEIGHTS] = {user_input[ZONE_SENSORS][0]: 1.0}
|
||||||
|
return self._finish()
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing, key))
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||||
|
if not self._draft.get(ZONE_KEY):
|
||||||
|
self._draft = {ZONE_KEY: f"zone_{uuid4().hex[:10]}"}
|
||||||
|
return await self._zone_form("user", user_input, self._draft, None)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||||
|
subentry = self._get_reconfigure_subentry()
|
||||||
|
existing = dict(subentry.data)
|
||||||
|
existing.setdefault(ZONE_KEY, subentry.subentry_id)
|
||||||
|
return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY])
|
||||||
|
|
||||||
|
async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None):
|
||||||
|
sensors = self._draft[ZONE_SENSORS]
|
||||||
|
if user_input is not None:
|
||||||
|
self._draft[ZONE_SENSOR_WEIGHTS] = {s: float(user_input[f"weight__{s}"]) for s in sensors}
|
||||||
|
return self._finish()
|
||||||
|
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
|
||||||
|
return self.async_show_form(step_id="sensor_weights", data_schema=vol.Schema({
|
||||||
|
vol.Required(f"weight__{s}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
|
||||||
|
for s in sensors}))
|
||||||
|
|
||||||
|
def _finish(self):
|
||||||
|
data = {key: self._draft[key] for key in (
|
||||||
|
ZONE_KEY, ZONE_NAME, ZONE_SENSORS, ZONE_SENSOR_WEIGHTS, ZONE_COMFORT_WEIGHT,
|
||||||
|
ZONE_TARGET_OFFSET, ZONE_NEIGHBORS)}
|
||||||
|
data[ZONE_COMFORT_WEIGHT] = float(data[ZONE_COMFORT_WEIGHT])
|
||||||
|
data[ZONE_TARGET_OFFSET] = float(data[ZONE_TARGET_OFFSET])
|
||||||
|
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||||
|
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||||
|
title=data[ZONE_NAME], data=data)
|
||||||
|
return self.async_create_entry(title=data[ZONE_NAME], data=data, unique_id=data[ZONE_KEY])
|
||||||
@@ -52,7 +52,7 @@ READY_ON_SAMPLES = 30
|
|||||||
READY_OFF_SAMPLES = 30
|
READY_OFF_SAMPLES = 30
|
||||||
# Outcome of each poll for learning (Status sample_result, Last Sample sensor).
|
# Outcome of each poll for learning (Status sample_result, Last Sample sensor).
|
||||||
SAMPLE_RESULTS = (
|
SAMPLE_RESULTS = (
|
||||||
"waiting_for_inputs", "first_sample", "learned", "partly_rejected",
|
"waiting_for_inputs", "first_sample", "sensors_changed", "learned", "partly_rejected",
|
||||||
"interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain",
|
"interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain",
|
||||||
)
|
)
|
||||||
BLOCK_MINUTES = 15
|
BLOCK_MINUTES = 15
|
||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from math import exp, isfinite
|
from math import exp
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
@@ -26,14 +26,26 @@ from .physics import (
|
|||||||
lag_from_time_in_state, optimize, relay_segments,
|
lag_from_time_in_state, optimize, relay_segments,
|
||||||
)
|
)
|
||||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
# Relay changes kept between polls; more than this in five minutes is chatter.
|
# Relay changes kept between polls; more than this in five minutes is chatter.
|
||||||
MAX_RELAY_EVENTS = 256
|
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
|
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]]):
|
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
"""The existing generic thermostat stays the only actuator owner."""
|
"""The existing generic thermostat stays the only actuator owner."""
|
||||||
|
|
||||||
@@ -42,7 +54,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
update_interval=timedelta(minutes=5))
|
update_interval=timedelta(minutes=5))
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.config = {**DEFAULTS, **entry.data, **entry.options}
|
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.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||||
self._retired: dict[str, dict[str, Any]] = {}
|
self._retired: dict[str, dict[str, Any]] = {}
|
||||||
self._report_ages = ReportAgeHistory()
|
self._report_ages = ReportAgeHistory()
|
||||||
@@ -173,7 +188,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
def _prediction_inputs(self) -> tuple:
|
def _prediction_inputs(self) -> tuple:
|
||||||
"""Values the prediction depends on, used to detect changes during calculation."""
|
"""Values the prediction depends on, used to detect changes during calculation."""
|
||||||
heater = self.config[CONF_HEATER]
|
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})
|
{self.config[CONF_CLIMATE], heater})
|
||||||
values = []
|
values = []
|
||||||
for entity_id in entities:
|
for entity_id in entities:
|
||||||
@@ -198,27 +213,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
for entity_id in sources_for(self.config, metric)
|
for entity_id in sources_for(self.config, metric)
|
||||||
}
|
}
|
||||||
snapshot = {entity: self.hass.states.get(entity)
|
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()
|
inputs_before = self._prediction_inputs()
|
||||||
room_quality = {}
|
sensor_quality = {}
|
||||||
temps = {}
|
readings: dict[str, float | None] = {}
|
||||||
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
||||||
for room in self.rooms:
|
for sensor in self.sensors:
|
||||||
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
quality = self._sensor_quality(sensor, now, self.config[CONF_ROOM_MAX_AGE])
|
||||||
value = None
|
value = None
|
||||||
# Optionally trust sensors that report only on change, but never a
|
# Optionally trust sensors that report only on change, but never a
|
||||||
# sensor silent for a day: it is most likely dead.
|
# sensor silent for a day: it is most likely dead.
|
||||||
if quality["status"] == "valid" or (
|
if quality["status"] == "valid" or (
|
||||||
keep_stale and quality["status"] == "stale"
|
keep_stale and quality["status"] == "stale"
|
||||||
and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60):
|
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"))
|
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||||
if value is None or not 0 <= value <= 45:
|
if value is None or not 0 <= value <= 45:
|
||||||
quality["status"] = "invalid_value_or_unit"
|
quality["status"] = "invalid_value_or_unit"
|
||||||
value = None
|
value = None
|
||||||
quality["used"] = value is not None
|
quality["used"] = value is not None
|
||||||
room_quality[room] = quality
|
sensor_quality[sensor] = quality
|
||||||
temps[room] = value
|
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_states = {}
|
||||||
weather_quality = {}
|
weather_quality = {}
|
||||||
for entity_id in weather_entity_ids:
|
for entity_id in weather_entity_ids:
|
||||||
@@ -240,9 +264,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
metric_quality[metric][entity_id] = quality
|
metric_quality[metric][entity_id] = quality
|
||||||
outside = weather["temperature"]
|
outside = weather["temperature"]
|
||||||
status: dict[str, Any] = {
|
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": {},
|
"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,
|
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||||
"samples": min(learner.count for learner in self.learners.values()),
|
"samples": min(learner.count for learner in self.learners.values()),
|
||||||
"mode": climate.state if climate else "unavailable",
|
"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_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_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
||||||
"weather_model_inputs": ["temperature"],
|
"weather_model_inputs": ["temperature"],
|
||||||
"retired_room_models": sorted(self._retired),
|
"retired_zone_models": sorted(self._retired),
|
||||||
"excluded_rooms": [room for room in self.rooms if temps[room] is None],
|
"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_valid = heater is not None and heater.state in ("on", "off")
|
||||||
heater_on = heater_valid and heater.state == "on"
|
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")
|
self._count_result(status, "waiting_for_inputs")
|
||||||
return status
|
return status
|
||||||
|
|
||||||
# A room learns only across a poll pair where it was usable at both ends.
|
# A zone learns only across a poll pair where it was usable at both ends with
|
||||||
pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
|
# 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
|
learned = False
|
||||||
if not pairs:
|
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:
|
else:
|
||||||
dt = (now - self._previous["time"]).total_seconds()
|
dt = (now - self._previous["time"]).total_seconds()
|
||||||
# Do not fit across a relay change, a provider/fallback switch or an
|
# 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._count_result(status, status["sample_result"])
|
||||||
self._previous = {
|
self._previous = {
|
||||||
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
|
"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.
|
# 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"],
|
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],
|
"heater_entity": self.config[CONF_HEATER],
|
||||||
"outside_source": active_sources["temperature"],
|
"outside_source": active_sources["temperature"],
|
||||||
})
|
})
|
||||||
weights = self.config.get(CONF_WEIGHTS, {})
|
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
|
||||||
try:
|
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
|
||||||
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
|
# Left-out zones take their weight with them; the rest are re-normalized.
|
||||||
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.
|
|
||||||
used_weights = {room: weights[room] for room in vals}
|
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())
|
weighted_temp = sum(used_weights[r] * vals[r] for r in vals) / sum(used_weights.values())
|
||||||
status.update({
|
status.update({
|
||||||
@@ -343,7 +367,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"off_samples": self._off_samples,
|
"off_samples": self._off_samples,
|
||||||
"samples": min(learner.count for learner in self.learners.values()),
|
"samples": min(learner.count for learner in self.learners.values()),
|
||||||
"models": {
|
"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),
|
"heat": round(model.theta[1], 6),
|
||||||
"bias": round(model.theta[2], 6),
|
"bias": round(model.theta[2], 6),
|
||||||
"samples": model.count,
|
"samples": model.count,
|
||||||
@@ -388,6 +413,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
||||||
heating_cost=float(self.config[CONF_HEAT_COST]),
|
heating_cost=float(self.config[CONF_HEAT_COST]),
|
||||||
comfort_band=float(self.config[CONF_BAND]),
|
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
|
# Discard a result if a value used by the prediction changed while
|
||||||
# saving or while the worker calculated the hypothetical plan. Optional
|
# saving or while the worker calculated the hypothetical plan. Optional
|
||||||
|
|||||||
@@ -179,11 +179,12 @@ def optimize(
|
|||||||
is_on: bool, seconds_since_change: float, horizon_minutes: int = 120,
|
is_on: bool, seconds_since_change: float, horizon_minutes: int = 120,
|
||||||
minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
|
minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
|
||||||
switching_cost: float = .15, heating_cost: float = .025,
|
switching_cost: float = .15, heating_cost: float = .025,
|
||||||
comfort_band: float = .15,
|
comfort_band: float = .15, target_offsets: dict[str, float] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
|
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
|
||||||
|
|
||||||
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
|
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
|
||||||
|
`target_offsets` shifts individual zones' targets (°C) relative to `target`.
|
||||||
"""
|
"""
|
||||||
if not temps or set(temps) != set(learners):
|
if not temps or set(temps) != set(learners):
|
||||||
raise ValueError("Missing room temperatures or learner")
|
raise ValueError("Missing room temperatures or learner")
|
||||||
@@ -206,6 +207,11 @@ def optimize(
|
|||||||
if not isfinite(total_weight):
|
if not isfinite(total_weight):
|
||||||
raise ValueError("Invalid total weight")
|
raise ValueError("Invalid total weight")
|
||||||
norm = {room: float(weights[room]) / total_weight for room in temps}
|
norm = {room: float(weights[room]) / total_weight for room in temps}
|
||||||
|
offsets = target_offsets or {}
|
||||||
|
if any(not isfinite(float(offsets.get(room, 0.0))) for room in temps):
|
||||||
|
raise ValueError("Invalid target offset")
|
||||||
|
targets = {room: target + float(offsets.get(room, 0.0)) for room in temps}
|
||||||
|
mean_target = sum(norm[room] * targets[room] for room in temps)
|
||||||
blocks = horizon_minutes // 15
|
blocks = horizon_minutes // 15
|
||||||
nsteps_per_block = 3
|
nsteps_per_block = 3
|
||||||
best = float("inf")
|
best = float("inf")
|
||||||
@@ -234,12 +240,12 @@ def optimize(
|
|||||||
for tick in range(nsteps_per_block):
|
for tick in range(nsteps_per_block):
|
||||||
state, lag = simulate_step(state, outside, lag, bit, learners)
|
state, lag = simulate_step(state, outside, lag, bit, learners)
|
||||||
mismatch = sum(
|
mismatch = sum(
|
||||||
norm[room] * max(0.0, abs(value - target) - comfort_band) ** 2
|
norm[room] * max(0.0, abs(value - targets[room]) - comfort_band) ** 2
|
||||||
for room, value in state.items()
|
for room, value in state.items()
|
||||||
)
|
)
|
||||||
# A lower bound on weighted average comfort also matters.
|
# A lower bound on weighted average comfort also matters.
|
||||||
avg = sum(norm[r] * value for r, value in state.items())
|
avg = sum(norm[r] * value for r, value in state.items())
|
||||||
mismatch += max(0.0, abs(avg - target) - comfort_band) ** 2
|
mismatch += max(0.0, abs(avg - mean_target) - comfort_band) ** 2
|
||||||
cost += 8.0 * mismatch + (heating_cost if bit else 0.0)
|
cost += 8.0 * mismatch + (heating_cost if bit else 0.0)
|
||||||
if idx * 15 + (tick + 1) * STEP_MINUTES == 60:
|
if idx * 15 + (tick + 1) * STEP_MINUTES == 60:
|
||||||
at_60 = dict(state)
|
at_60 = dict(state)
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
|||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id),
|
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id),
|
||||||
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
|
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
|
||||||
entities += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms]
|
|
||||||
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
||||||
|
# Zone forecasts belong to their zone subentry, so deleting a zone removes them.
|
||||||
|
linked = {}
|
||||||
|
for zone in coordinator.zones.values():
|
||||||
|
entity = MPCRoomPrediction(coordinator, entry.entry_id, zone.key)
|
||||||
|
if zone.subentry_id:
|
||||||
|
linked.setdefault(zone.subentry_id, []).append(entity)
|
||||||
|
else:
|
||||||
|
entities.append(entity)
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
for subentry_id, zone_entities in linked.items():
|
||||||
|
async_add_entities(zone_entities, config_subentry_id=subentry_id)
|
||||||
|
|
||||||
|
|
||||||
class MPCStatus(CoordinatorEntity, SensorEntity):
|
class MPCStatus(CoordinatorEntity, SensorEntity):
|
||||||
@@ -69,7 +78,7 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self.room = room
|
self.room = room
|
||||||
self._attr_unique_id = f"{entry_id}_{room}_predicted_60min"
|
self._attr_unique_id = f"{entry_id}_{room}_predicted_60min"
|
||||||
self._attr_name = f"MPC {room} 60min Prediction"
|
self._attr_name = f"MPC {coordinator.zones[room].name} 60min Prediction"
|
||||||
|
|
||||||
# Change every poll; the 60-minute value itself is recorded.
|
# Change every poll; the 60-minute value itself is recorded.
|
||||||
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min"})
|
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min"})
|
||||||
@@ -120,7 +129,7 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
|||||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
_attr_suggested_display_precision = 2
|
_attr_suggested_display_precision = 2
|
||||||
_attr_icon = "mdi:target"
|
_attr_icon = "mdi:target"
|
||||||
_unrecorded_attributes = frozenset({"rooms"})
|
_unrecorded_attributes = frozenset({"zones"})
|
||||||
|
|
||||||
def __init__(self, coordinator, entry_id, horizon):
|
def __init__(self, coordinator, entry_id, horizon):
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
@@ -144,7 +153,9 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
|||||||
"bias": accuracy.get("bias"),
|
"bias": accuracy.get("bias"),
|
||||||
"persistence_mae": accuracy.get("persistence_mae"),
|
"persistence_mae": accuracy.get("persistence_mae"),
|
||||||
"skill": accuracy.get("skill"),
|
"skill": accuracy.get("skill"),
|
||||||
"rooms": accuracy.get("rooms", {}),
|
# Per zone, by name.
|
||||||
|
"zones": {self.coordinator.zones[key].name if key in self.coordinator.zones else key: stats
|
||||||
|
for key, stats in accuracy.get("rooms", {}).items()},
|
||||||
"window_days": WINDOW_DAYS,
|
"window_days": WINDOW_DAYS,
|
||||||
"unit_of_attributes": "°C",
|
"unit_of_attributes": "°C",
|
||||||
"basis": ("thermal model replayed with the actual relay history; outdoor "
|
"basis": ("thermal model replayed with the actual relay history; outdoor "
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"weights": {
|
"weights": {
|
||||||
"title": "Room priorities",
|
"title": "Room priorities",
|
||||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
"description": "Each selected sensor becomes its own zone with this comfort weight. Later, group the sensors of one room into one zone with Add zone or by editing a zone."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -59,7 +59,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"climate_entity": "Existing Generic Thermostat",
|
"climate_entity": "Existing Generic Thermostat",
|
||||||
"heater_entity": "Physical heating switch (read-only)",
|
"heater_entity": "Physical heating switch (read-only)",
|
||||||
"room_entities": "Individual room temperature sensors",
|
|
||||||
"horizon_minutes": "Prediction horizon (minutes)",
|
"horizon_minutes": "Prediction horizon (minutes)",
|
||||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||||
@@ -94,14 +93,9 @@
|
|||||||
"outside_rain_sources": "Rain sources (optional)",
|
"outside_rain_sources": "Rain sources (optional)",
|
||||||
"outside_snow_sources": "Snow sources (optional)"
|
"outside_snow_sources": "Snow sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"weights": {
|
|
||||||
"title": "Room priorities",
|
|
||||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
|
||||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,6 +105,7 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"waiting_for_inputs": "Waiting for inputs",
|
"waiting_for_inputs": "Waiting for inputs",
|
||||||
"first_sample": "First sample",
|
"first_sample": "First sample",
|
||||||
|
"sensors_changed": "Zone sensors changed",
|
||||||
"learned": "Learned",
|
"learned": "Learned",
|
||||||
"partly_rejected": "Partly rejected (outlier)",
|
"partly_rejected": "Partly rejected (outlier)",
|
||||||
"interval_out_of_range": "Irregular interval",
|
"interval_out_of_range": "Irregular interval",
|
||||||
@@ -120,5 +115,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"config_subentries": {
|
||||||
|
"zone": {
|
||||||
|
"entry_type": "Zone",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add zone",
|
||||||
|
"reconfigure": "Edit zone"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor_weights": {
|
||||||
|
"title": "Sensor weights",
|
||||||
|
"description": "How much each sensor counts in the zone temperature (0.1–10)."
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"zone_name_required": "Enter a name.",
|
||||||
|
"zone_sensors": "Choose 1–6 different sensors.",
|
||||||
|
"sensor_in_other_zone": "A chosen sensor already belongs to another zone.",
|
||||||
|
"sensor_is_weather_source": "A chosen sensor is used as an outdoor temperature source.",
|
||||||
|
"too_many_zones": "At most 12 zones are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The zone was updated."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"weights": {
|
"weights": {
|
||||||
"title": "Room priorities",
|
"title": "Room priorities",
|
||||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
"description": "Each selected sensor becomes its own zone with this comfort weight. Later, group the sensors of one room into one zone with Add zone or by editing a zone."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -59,7 +59,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"climate_entity": "Existing Generic Thermostat",
|
"climate_entity": "Existing Generic Thermostat",
|
||||||
"heater_entity": "Physical heating switch (read-only)",
|
"heater_entity": "Physical heating switch (read-only)",
|
||||||
"room_entities": "Individual room temperature sensors",
|
|
||||||
"horizon_minutes": "Prediction horizon (minutes)",
|
"horizon_minutes": "Prediction horizon (minutes)",
|
||||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||||
@@ -94,14 +93,9 @@
|
|||||||
"outside_rain_sources": "Rain sources (optional)",
|
"outside_rain_sources": "Rain sources (optional)",
|
||||||
"outside_snow_sources": "Snow sources (optional)"
|
"outside_snow_sources": "Snow sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"weights": {
|
|
||||||
"title": "Room priorities",
|
|
||||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
|
||||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,6 +105,7 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"waiting_for_inputs": "Waiting for inputs",
|
"waiting_for_inputs": "Waiting for inputs",
|
||||||
"first_sample": "First sample",
|
"first_sample": "First sample",
|
||||||
|
"sensors_changed": "Zone sensors changed",
|
||||||
"learned": "Learned",
|
"learned": "Learned",
|
||||||
"partly_rejected": "Partly rejected (outlier)",
|
"partly_rejected": "Partly rejected (outlier)",
|
||||||
"interval_out_of_range": "Irregular interval",
|
"interval_out_of_range": "Irregular interval",
|
||||||
@@ -120,5 +115,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"config_subentries": {
|
||||||
|
"zone": {
|
||||||
|
"entry_type": "Zone",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add zone",
|
||||||
|
"reconfigure": "Edit zone"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor_weights": {
|
||||||
|
"title": "Sensor weights",
|
||||||
|
"description": "How much each sensor counts in the zone temperature (0.1–10)."
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"zone_name_required": "Enter a name.",
|
||||||
|
"zone_sensors": "Choose 1–6 different sensors.",
|
||||||
|
"sensor_in_other_zone": "A chosen sensor already belongs to another zone.",
|
||||||
|
"sensor_is_weather_source": "A chosen sensor is used as an outdoor temperature source.",
|
||||||
|
"too_many_zones": "At most 12 zones are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The zone was updated."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"weights": {
|
"weights": {
|
||||||
"title": "Ponderile camerelor",
|
"title": "Ponderile camerelor",
|
||||||
"description": "Fiecare cameră are o pondere de confort între 0,1 și 10; senzorul este indicat în numele câmpului."
|
"description": "Fiecare senzor ales devine o zonă proprie, cu această pondere de confort. Ulterior poți grupa senzorii unei camere într-o singură zonă cu Adaugă zonă sau modificând o zonă."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -59,7 +59,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"climate_entity": "Termostatul Generic Thermostat existent",
|
"climate_entity": "Termostatul Generic Thermostat existent",
|
||||||
"heater_entity": "Releul fizic (numai citire)",
|
"heater_entity": "Releul fizic (numai citire)",
|
||||||
"room_entities": "Senzorii individuali din camere",
|
|
||||||
"horizon_minutes": "Orizontul predicției (minute)",
|
"horizon_minutes": "Orizontul predicției (minute)",
|
||||||
"minimum_on_minutes": "Durată minimă ON (minute)",
|
"minimum_on_minutes": "Durată minimă ON (minute)",
|
||||||
"minimum_off_minutes": "Durată minimă OFF (minute)",
|
"minimum_off_minutes": "Durată minimă OFF (minute)",
|
||||||
@@ -94,14 +93,9 @@
|
|||||||
"outside_rain_sources": "Senzori precipitații ploaie (opțional)",
|
"outside_rain_sources": "Senzori precipitații ploaie (opțional)",
|
||||||
"outside_snow_sources": "Senzori precipitații ninsoare (opțional)"
|
"outside_snow_sources": "Senzori precipitații ninsoare (opțional)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"weights": {
|
|
||||||
"title": "Ponderi pentru camere",
|
|
||||||
"description": "Ponderile afectează importanța confortului, nu coeficienții fizici."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_rooms": "Selectează 1–12 senzori diferiți.",
|
|
||||||
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
|
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,6 +105,7 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"waiting_for_inputs": "Așteaptă datele de intrare",
|
"waiting_for_inputs": "Așteaptă datele de intrare",
|
||||||
"first_sample": "Prima măsurătoare",
|
"first_sample": "Prima măsurătoare",
|
||||||
|
"sensors_changed": "Senzorii zonei s-au schimbat",
|
||||||
"learned": "Învățat",
|
"learned": "Învățat",
|
||||||
"partly_rejected": "Respins parțial (valoare aberantă)",
|
"partly_rejected": "Respins parțial (valoare aberantă)",
|
||||||
"interval_out_of_range": "Interval neregulat",
|
"interval_out_of_range": "Interval neregulat",
|
||||||
@@ -120,5 +115,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"config_subentries": {
|
||||||
|
"zone": {
|
||||||
|
"entry_type": "Zonă",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Adaugă zonă",
|
||||||
|
"reconfigure": "Modifică zona"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Zonă termică",
|
||||||
|
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensors": "Senzori de temperatură",
|
||||||
|
"comfort_weight": "Pondere de confort",
|
||||||
|
"target_offset": "Abatere de la țintă (°C)",
|
||||||
|
"neighbors": "Zone învecinate (schimb de căldură)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Importanța relativă pentru MPC (0,1–10).",
|
||||||
|
"target_offset": "Se adaugă la temperatura țintă a termostatului pentru această zonă.",
|
||||||
|
"neighbors": "Zonele cu care această zonă face schimb de căldură, de exemplu printr-un perete sau o ușă deschisă. Coeficientul este învățat doar dacă datele îl susțin."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor_weights": {
|
||||||
|
"title": "Ponderile senzorilor",
|
||||||
|
"description": "Cât contează fiecare senzor în temperatura zonei (0,1–10)."
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Zonă termică",
|
||||||
|
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensors": "Senzori de temperatură",
|
||||||
|
"comfort_weight": "Pondere de confort",
|
||||||
|
"target_offset": "Abatere de la țintă (°C)",
|
||||||
|
"neighbors": "Zone învecinate (schimb de căldură)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Importanța relativă pentru MPC (0,1–10).",
|
||||||
|
"target_offset": "Se adaugă la temperatura țintă a termostatului pentru această zonă.",
|
||||||
|
"neighbors": "Zonele cu care această zonă face schimb de căldură, de exemplu printr-un perete sau o ușă deschisă. Coeficientul este învățat doar dacă datele îl susțin."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"zone_name_required": "Introdu un nume.",
|
||||||
|
"zone_sensors": "Alege între 1 și 6 senzori diferiți.",
|
||||||
|
"sensor_in_other_zone": "Un senzor ales aparține deja altei zone.",
|
||||||
|
"sensor_is_weather_source": "Un senzor ales este folosit ca sursă de temperatură exterioară.",
|
||||||
|
"too_many_zones": "Sunt permise cel mult 12 zone."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "Zona a fost actualizată."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
+115
-34
@@ -1,18 +1,31 @@
|
|||||||
"""Validate actual HA form schemas, options and entry migration."""
|
"""Validate actual HA form schemas, options, zone subentries and entry migration."""
|
||||||
from homeassistant.config_entries import SOURCE_USER
|
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.heating_mpc.const import DOMAIN
|
from custom_components.heating_mpc.const import DOMAIN
|
||||||
from custom_components.heating_mpc.weather_sources import sources_for
|
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})
|
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
|
||||||
assert result["type"] is FlowResultType.FORM
|
assert result["type"] is FlowResultType.FORM
|
||||||
main = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
validated = result["data_schema"]({**MAIN, "room_entities": ["sensor.room", "sensor.bedroom"]})
|
||||||
"room_entities": ["sensor.room"]}
|
|
||||||
validated = result["data_schema"](main)
|
|
||||||
assert validated["room_max_age_minutes"] == 30
|
assert validated["room_max_age_minutes"] == 30
|
||||||
assert validated["weather_max_age_minutes"] == 120
|
assert validated["weather_max_age_minutes"] == 120
|
||||||
assert validated["room_stale_use_last_value"] is False
|
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"
|
assert result["step_id"] == "weather_extra"
|
||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||||
assert result["step_id"] == "weights"
|
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["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result["data"]["weights"] == {"sensor.room": 2}
|
|
||||||
await hass.async_block_till_done()
|
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):
|
async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
|
||||||
entry.add_to_hass(hass)
|
await setup_entry(hass, entry)
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
main = result["data_schema"]({"climate_entity": "climate.reference",
|
assert "room_entities" not in {str(key) for key in result["data_schema"].schema}
|
||||||
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"],
|
main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True})
|
||||||
"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"], main)
|
||||||
result = await hass.config_entries.options.async_configure(
|
result = await hass.config_entries.options.async_configure(
|
||||||
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
|
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"], 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
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert entry.options["room_max_age_minutes"] == 45
|
assert entry.options["room_max_age_minutes"] == 45
|
||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
assert coordinator.config["room_max_age_minutes"] == 45
|
assert coordinator.config["room_max_age_minutes"] == 45
|
||||||
assert coordinator.config["room_stale_use_last_value"] is True
|
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)
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
assert result["data_schema"]({"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
|
||||||
"room_entities": ["sensor.room"]})["room_stale_use_last_value"] is True
|
|
||||||
hass.config_entries.options.async_abort(result["flow_id"])
|
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={
|
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
|
||||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
**MAIN, "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
|
||||||
"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)
|
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)
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert entry.version == 2
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
|
zone = zones_of(entry)["sensor.room"]
|
||||||
assert "outside_entity" not in entry.data
|
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):
|
async def test_options_flow_clears_optional_weather_sources(hass, inputs):
|
||||||
"""Swapping providers in the UI must allow removing an optional source list."""
|
"""Swapping providers in the UI must allow removing an optional source list."""
|
||||||
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
|
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
|
||||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
**MAIN, "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
|
||||||
"room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
|
|
||||||
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
|
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
|
||||||
"weights": {"sensor.room": 1.0}})
|
"weights": {"sensor.room": 1.0}})
|
||||||
entry.add_to_hass(hass)
|
await setup_entry(hass, entry)
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
flow = hass.config_entries.options
|
flow = hass.config_entries.options
|
||||||
result = await flow.async_init(entry.entry_id)
|
result = await flow.async_init(entry.entry_id)
|
||||||
result = await flow.async_configure(result["flow_id"], result["data_schema"]({
|
result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
|
||||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
|
||||||
"room_entities": ["sensor.room"]}))
|
|
||||||
markers = {str(key): key for key in result["data_schema"].schema}
|
markers = {str(key): key for key in result["data_schema"].schema}
|
||||||
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
|
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
|
||||||
# The user removes the wind provider; a cleared field may be omitted on submit.
|
# 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"]}))
|
{"outside_temperature_sources": ["sensor.outside"]}))
|
||||||
result = await flow.async_configure(result["flow_id"], result["data_schema"](
|
result = await flow.async_configure(result["flow_id"], result["data_schema"](
|
||||||
{"outside_pressure_sources": ["sensor.pressure"]}))
|
{"outside_pressure_sources": ["sensor.pressure"]}))
|
||||||
result = await flow.async_configure(result["flow_id"], {"weight__sensor.room": 1})
|
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert entry.options["outside_wind_sources"] == []
|
assert entry.options["outside_wind_sources"] == []
|
||||||
|
|||||||
+30
-10
@@ -10,6 +10,21 @@ from homeassistant.helpers import entity_registry as er
|
|||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from custom_components.heating_mpc.const import DOMAIN
|
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):
|
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}
|
ids = {e.unique_id: e.entity_id for e in entities}
|
||||||
assert coordinator.data["status"] == "learning"
|
assert coordinator.data["status"] == "learning"
|
||||||
status = hass.states.get(ids[f"{entry.entry_id}_status"])
|
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.
|
# Volatile diagnostics stay visible but are not written to the Recorder.
|
||||||
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
|
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
|
||||||
assert "models" not in 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
|
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)
|
await setup(hass, entry)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
|
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.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()
|
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(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
|
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()}}}
|
"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"})
|
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
|
||||||
await setup(hass, entry)
|
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()
|
await hass.async_block_till_done()
|
||||||
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
|
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
|
||||||
freezer.move_to(future)
|
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"]
|
saved = hass_storage[key]["data"]
|
||||||
assert set(saved["learners"]) == {"sensor.new"}
|
assert set(saved["learners"]) == {"sensor.new"}
|
||||||
assert saved["retired_learners"]["sensor.room"]["count"] == 250
|
assert saved["retired_learners"]["sensor.room"]["count"] == 250
|
||||||
hass.config_entries.async_update_entry(
|
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
||||||
entry, options={"room_entities": ["sensor.room", "sensor.new"]})
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
|
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 coordinator.data["forecast_accuracy"][15]["samples"] == 1
|
||||||
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
|
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
|
||||||
assert state.attributes["samples"] == 1
|
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 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"
|
assert hass.states.get(ids[30]).state == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+57
-19
@@ -44,6 +44,7 @@ def load_integration():
|
|||||||
modules['homeassistant.helpers.event'].async_track_state_change_event = (
|
modules['homeassistant.helpers.event'].async_track_state_change_event = (
|
||||||
lambda hass, entity_ids, action: hass.track_state(entity_ids, action))
|
lambda hass, entity_ids, action: hass.track_state(entity_ids, action))
|
||||||
modules['homeassistant.config_entries'].ConfigEntry = object
|
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.storage'].Store = Store
|
||||||
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
|
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
|
||||||
registry = modules['homeassistant.helpers.entity_registry']
|
registry = modules['homeassistant.helpers.entity_registry']
|
||||||
@@ -249,7 +250,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside'])
|
self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside'])
|
||||||
self.assertNotIn('outside_entity', kwargs['data'])
|
self.assertNotIn('outside_entity', kwargs['data'])
|
||||||
self.assertEqual(kwargs['version'], 2)
|
self.assertEqual(kwargs['version'], 2)
|
||||||
self.entry.version = 3
|
self.entry.version = 4
|
||||||
self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry))
|
self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry))
|
||||||
|
|
||||||
async def test_changed_inputs_during_optimization_discard_result(self):
|
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.assertEqual(data['status'], 'inputs_changed')
|
||||||
self.assertIsNone(data['proposal'])
|
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')):
|
for weight in (0, -1, 'bad', float('nan'), float('inf')):
|
||||||
self.c.config['weights'] = {'sensor.room': weight}
|
self.entry.data['weights'] = {'sensor.room': weight}
|
||||||
data = await self.sample()
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
self.assertEqual(data['status'], 'invalid_weights')
|
self.assertEqual(self.c.zones['sensor.room'].comfort_weight, 1.0)
|
||||||
self.assertIsNone(data['proposal'])
|
|
||||||
|
|
||||||
async def test_added_room_needs_its_own_excitation_evidence(self):
|
async def test_added_room_needs_its_own_excitation_evidence(self):
|
||||||
model = coordinator.Learner(count=500, on_count=250, off_count=250)
|
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)
|
self.states['sensor.room'].last_reported = self.now - timedelta(minutes=31)
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['status'], 'waiting_for_sensors')
|
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.assertIsNone(data['proposal'])
|
||||||
self.set_state('sensor.room', '20', unit_of_measurement='°C')
|
self.set_state('sensor.room', '20', unit_of_measurement='°C')
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
@@ -511,7 +511,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await self.c.async_restore_model()
|
await self.c.async_restore_model()
|
||||||
self.assertEqual(self.c.learners['sensor.room'].count, 7)
|
self.assertEqual(self.c.learners['sensor.room'].count, 7)
|
||||||
await self.sample()
|
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]
|
payload = self.c._store.async_save.call_args.args[0]
|
||||||
self.assertEqual(set(payload['learners']), {'sensor.room'})
|
self.assertEqual(set(payload['learners']), {'sensor.room'})
|
||||||
self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500)
|
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
|
await self.sample(2400) # the room then stays silent for 40 minutes
|
||||||
self.states['sensor.outside'].last_reported = self.now
|
self.states['sensor.outside'].last_reported = self.now
|
||||||
data = await self.sample()
|
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.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400))
|
||||||
self.states['sensor.room'].last_reported = self.now
|
self.states['sensor.room'].last_reported = self.now
|
||||||
self.states['sensor.outside'].last_reported = self.now
|
self.states['sensor.outside'].last_reported = self.now
|
||||||
data = await self.sample()
|
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']),
|
self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']),
|
||||||
('valid', 0, 2400))
|
('valid', 0, 2400))
|
||||||
weather = data['input_quality']['weather']['temperature']['sensor.outside']
|
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)
|
self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31)
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['status'], 'learning')
|
self.assertEqual(data['status'], 'learning')
|
||||||
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
|
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
|
||||||
rooms = data['input_quality']['rooms']
|
zones = data['input_quality']['zones']
|
||||||
self.assertEqual((rooms['sensor.bedroom']['status'], rooms['sensor.bedroom']['used']), ('stale', False))
|
bedroom = zones['sensor.bedroom']['sensors']['sensor.bedroom']
|
||||||
self.assertTrue(rooms['sensor.room']['used'])
|
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['weighted_temperature'], data['weight_coverage']), (20, 0.75))
|
||||||
self.assertEqual(data['sample_result'], 'learned')
|
self.assertEqual(data['sample_result'], 'learned')
|
||||||
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
|
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
|
||||||
self.assertEqual(counts(), (1, 0))
|
self.assertEqual(counts(), (1, 0))
|
||||||
self.fresh() # bedroom reports again: no learning across its gap
|
self.fresh() # bedroom reports again: no learning across its gap
|
||||||
data = await self.sample()
|
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(data['weighted_temperature'], 19.5)
|
||||||
self.assertEqual(counts(), (2, 0))
|
self.assertEqual(counts(), (2, 0))
|
||||||
self.fresh()
|
self.fresh()
|
||||||
@@ -599,7 +601,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.states[room].last_reported = self.now - timedelta(minutes=31)
|
self.states[room].last_reported = self.now - timedelta(minutes=31)
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['status'], 'waiting_for_sensors')
|
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'])
|
self.assertIsNone(data['proposal'])
|
||||||
|
|
||||||
async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self):
|
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()
|
await self.sample()
|
||||||
self.fresh('sensor.room', 'sensor.outside')
|
self.fresh('sensor.room', 'sensor.outside')
|
||||||
data = await self.sample()
|
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((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)
|
self.assertEqual(self.c.learners['sensor.bedroom'].count, 1)
|
||||||
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
|
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
|
||||||
with self.subTest(state=state):
|
with self.subTest(state=state):
|
||||||
@@ -618,7 +620,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.states['sensor.bedroom'].state = state
|
self.states['sensor.bedroom'].state = state
|
||||||
self.states['sensor.bedroom'].last_reported = self.now - age
|
self.states['sensor.bedroom'].last_reported = self.now - age
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
|
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
|
||||||
self.assertEqual(data['status'], 'learning')
|
self.assertEqual(data['status'], 'learning')
|
||||||
|
|
||||||
async def test_poll_outcomes_and_rejections_are_counted(self):
|
async def test_poll_outcomes_and_rejections_are_counted(self):
|
||||||
@@ -697,3 +699,39 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['sample_result'], 'waiting_for_inputs')
|
self.assertEqual(data['sample_result'], 'waiting_for_inputs')
|
||||||
self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1)
|
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})
|
||||||
@@ -45,6 +45,14 @@ class ModelTests(unittest.TestCase):
|
|||||||
learners, False, 3600, horizon_minutes=60)
|
learners, False, 3600, horizon_minutes=60)
|
||||||
self.assertTrue(out['proposal'])
|
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):
|
def test_minimum_duration_on_and_off(self):
|
||||||
learners = {'r': m.Learner()}
|
learners = {'r': m.Learner()}
|
||||||
# Could not switch OFF during first 15 minutes if just turned ON.
|
# Could not switch OFF during first 15 minutes if just turned ON.
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user