diff --git a/custom_components/heating_mpc/__init__.py b/custom_components/heating_mpc/__init__.py index a9065d6..0b4cc92 100644 --- a/custom_components/heating_mpc/__init__.py +++ b/custom_components/heating_mpc/__init__.py @@ -1,16 +1,19 @@ """Heating MPC custom integration, initial release shadow-only.""" from __future__ import annotations +from types import MappingProxyType + 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.helpers.storage import Store from homeassistant.helpers import entity_registry as er 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 .coordinator import HeatingMPCCoordinator +from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data 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) raise 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) - 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): 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 not in expected): 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: - """Migrate legacy single weather source without changing user selections.""" - if entry.version > 2: + """Migrate older entries without changing the user's selections. + + 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 if entry.version == 1: 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), 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 diff --git a/custom_components/heating_mpc/config_flow.py b/custom_components/heating_mpc/config_flow.py index 33f89e0..8e4b8a2 100644 --- a/custom_components/heating_mpc/config_flow.py +++ b/custom_components/heating_mpc/config_flow.py @@ -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 typing import Any +from uuid import uuid4 import voluptuous as vol from homeassistant import config_entries @@ -11,11 +12,16 @@ from homeassistant.helpers import selector from .const import ( CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, 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, WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA, ) 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): @@ -30,14 +36,16 @@ def _required(key: str, data: dict): 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 {} schema: dict[Any, Any] = { _required(CONF_CLIMATE, data): selector.EntitySelector( selector.EntitySelectorConfig(domain="climate")), _required(CONF_HEATER, data): selector.EntitySelector( 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): schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All( 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) +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): """Create a native observation-only MPC installation.""" - VERSION = 2 + # 3: rooms and comfort weights moved into zone subentries. + VERSION = 3 def __init__(self): 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): if user_input is not None: - self._draft[CONF_WEIGHTS] = { - room: float(user_input[f"weight__{room}"]) - for room in self._draft[CONF_ROOMS] - } - return self.async_create_entry(title="Heating MPC", data=self._draft) + rooms = self._draft.pop(CONF_ROOMS) + weights = {room: float(user_input[f"weight__{room}"]) for room in rooms} + names = {} + for room in rooms: + 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( 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): 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): """Edit sensors, ranked alternative weather providers and MPC parameters.""" @@ -156,12 +187,12 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow): def __init__(self): 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): config = {**self.config_entry.data, **self.config_entry.options} 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 return await self.async_step_weather() 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): config = {**self.config_entry.data, **self.config_entry.options, **self._draft} 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( user_input, WEATHER_MAIN), errors={"base": "invalid_weather"}) 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} if user_input is not None: 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. self._draft.pop(CONF_OUTSIDE, None) 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="weights", data_schema=_weight_schema( - self._draft[CONF_ROOMS], config.get(CONF_WEIGHTS, {}))) + return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema( + config, WEATHER_EXTRA)) + + +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]) diff --git a/custom_components/heating_mpc/const.py b/custom_components/heating_mpc/const.py index 06e06f7..646e20b 100644 --- a/custom_components/heating_mpc/const.py +++ b/custom_components/heating_mpc/const.py @@ -52,7 +52,7 @@ READY_ON_SAMPLES = 30 READY_OFF_SAMPLES = 30 # Outcome of each poll for learning (Status sample_result, Last Sample sensor). 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", ) BLOCK_MINUTES = 15 diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index a73c9ba..87f8fd3 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -5,7 +5,7 @@ import logging from collections import deque from datetime import datetime, timedelta, timezone from functools import partial -from math import exp, isfinite +from math import exp from typing import Any from homeassistant.core import Event, HomeAssistant, callback @@ -26,14 +26,26 @@ from .physics import ( lag_from_time_in_state, optimize, relay_segments, ) from .weather_sources import choose_weather_sources, sources_for, normalize +from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature _LOGGER = logging.getLogger(__name__) # Relay changes kept between polls; more than this in five minutes is chatter. MAX_RELAY_EVENTS = 256 -# Models of rooms removed in Options, kept in case a room is added back. +# Models of zones removed in the UI, kept in case a zone is added back. MAX_RETIRED_ROOMS = 24 +def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]: + """Zones from the entry's zone subentries; one zone per legacy room sensor otherwise.""" + subentries = getattr(entry, "subentries", None) or {} + items = [(sub.data, sub.subentry_id) for sub in subentries.values() + if sub.subentry_type == SUBENTRY_ZONE] + if not items: # not migrated yet (or offline tests): same zones migration creates + items = [(data, None) for data in legacy_zone_data( + config.get(CONF_ROOMS, []), config.get(CONF_WEIGHTS))] + return build_zones(items) + + class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The existing generic thermostat stays the only actuator owner.""" @@ -42,7 +54,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(minutes=5)) self.entry = entry self.config = {**DEFAULTS, **entry.data, **entry.options} - self.rooms: list[str] = self.config[CONF_ROOMS] + self.zones: dict[str, Zone] = {zone.key: zone for zone in entry_zone_list(entry, self.config)} + # Zone keys; models, forecasts and statistics are keyed by these. + self.rooms: list[str] = list(self.zones) + self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors] self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self._retired: dict[str, dict[str, Any]] = {} self._report_ages = ReportAgeHistory() @@ -173,7 +188,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): def _prediction_inputs(self) -> tuple: """Values the prediction depends on, used to detect changes during calculation.""" heater = self.config[CONF_HEATER] - entities = sorted(set(self.rooms) | set(sources_for(self.config, "temperature")) | + entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) | {self.config[CONF_CLIMATE], heater}) values = [] for entity_id in entities: @@ -198,27 +213,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): for entity_id in sources_for(self.config, metric) } snapshot = {entity: self.hass.states.get(entity) - for entity in set(self.rooms) | weather_entity_ids} + for entity in set(self.sensors) | weather_entity_ids} inputs_before = self._prediction_inputs() - room_quality = {} - temps = {} + sensor_quality = {} + readings: dict[str, float | None] = {} keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True - for room in self.rooms: - quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE]) + for sensor in self.sensors: + quality = self._sensor_quality(sensor, now, self.config[CONF_ROOM_MAX_AGE]) value = None # Optionally trust sensors that report only on change, but never a # sensor silent for a day: it is most likely dead. if quality["status"] == "valid" or ( keep_stale and quality["status"] == "stale" and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60): - state = snapshot[room] + state = snapshot[sensor] value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement")) if value is None or not 0 <= value <= 45: quality["status"] = "invalid_value_or_unit" value = None quality["used"] = value is not None - room_quality[room] = quality - temps[room] = value + sensor_quality[sensor] = quality + readings[sensor] = value + # A zone is usable while at least one of its sensors is; sensor weights + # combine them. Which sensors were used matters for learning (see below). + temps: dict[str, float | None] = {} + compositions: dict[str, frozenset[str]] = {} + zone_quality = {} + for key, zone in self.zones.items(): + temps[key], compositions[key] = zone_temperature(zone, readings) + zone_quality[key] = {"name": zone.name, "used": temps[key] is not None, + "sensors": {s: sensor_quality[s] for s in zone.sensors}} weather_states = {} weather_quality = {} for entity_id in weather_entity_ids: @@ -240,9 +264,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): metric_quality[metric][entity_id] = quality outside = weather["temperature"] status: dict[str, Any] = { - "input_quality": {"rooms": room_quality, "weather": metric_quality}, + "input_quality": {"zones": zone_quality, "weather": metric_quality}, "status": "waiting_for_sensors", "proposal": None, "prediction_60m": {}, - "temperatures": {r: t for r, t in temps.items() if t is not None}, + "temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None}, + "zone_names": {key: zone.name for key, zone in self.zones.items()}, "on_samples": self._on_samples, "off_samples": self._off_samples, "samples": min(learner.count for learner in self.learners.values()), "mode": climate.state if climate else "unavailable", @@ -250,8 +275,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "weather_active_sources": {k: v for k, v in active_sources.items() if v}, "weather_comparison": {k: v for k, v in weather_comparisons.items() if v}, "weather_model_inputs": ["temperature"], - "retired_room_models": sorted(self._retired), - "excluded_rooms": [room for room in self.rooms if temps[room] is None], + "retired_zone_models": sorted(self._retired), + "excluded_zones": [room for room in self.rooms if temps[room] is None], } heater_valid = heater is not None and heater.state in ("on", "off") heater_on = heater_valid and heater.state == "on" @@ -274,11 +299,16 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._count_result(status, "waiting_for_inputs") return status - # A room learns only across a poll pair where it was usable at both ends. - pairs = [room for room in vals if self._previous and room in self._previous["temps"]] + # A zone learns only across a poll pair where it was usable at both ends with + # the same sensors: a sensor dropping out shifts the mean by its offset. + pairs = [room for room in vals if self._previous and room in self._previous["temps"] + and self._previous["compositions"].get(room) == compositions[room]] learned = False if not pairs: - status["sample_result"] = "first_sample" + changed = self._previous and any( + room in self._previous["temps"] and self._previous["compositions"].get(room) + != compositions[room] for room in vals) + status["sample_result"] = "sensors_changed" if changed else "first_sample" else: dt = (now - self._previous["time"]).total_seconds() # Do not fit across a relay change, a provider/fallback switch or an @@ -305,7 +335,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._count_result(status, status["sample_result"]) self._previous = { "time": now, "temps": vals, "outside": outside, "heater_on": heater_on, - "outside_source": active_sources["temperature"], + "outside_source": active_sources["temperature"], "compositions": compositions, } # Parameters after this poll's learning: forecasts from here use only data so far. self._evaluator.observe(now, vals, outside, self._lag["lag"], self._lag["uncertainty"], @@ -323,15 +353,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "heater_entity": self.config[CONF_HEATER], "outside_source": active_sources["temperature"], }) - weights = self.config.get(CONF_WEIGHTS, {}) - try: - weights = {r: float(weights.get(r, 1.0)) for r in self.rooms} - if any(not isfinite(w) or w <= 0 for w in weights.values()) or not isfinite(sum(weights.values())): - raise ValueError - except (AttributeError, TypeError, ValueError, OverflowError): - status["status"] = "invalid_weights" - return status - # Left-out rooms take their weight with them; the rest are re-normalized. + # Comfort weights are validated when zones are parsed (invalid -> 1.0). + weights = {r: self.zones[r].comfort_weight for r in self.rooms} + # Left-out zones take their weight with them; the rest are re-normalized. used_weights = {room: weights[room] for room in vals} weighted_temp = sum(used_weights[r] * vals[r] for r in vals) / sum(used_weights.values()) status.update({ @@ -343,7 +367,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "off_samples": self._off_samples, "samples": min(learner.count for learner in self.learners.values()), "models": { - room: {"loss": round(model.theta[0], 6), + room: {"name": self.zones[room].name, + "loss": round(model.theta[0], 6), "heat": round(model.theta[1], 6), "bias": round(model.theta[2], 6), "samples": model.count, @@ -388,6 +413,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): switching_cost=float(self.config[CONF_SWITCH_COST]), heating_cost=float(self.config[CONF_HEAT_COST]), comfort_band=float(self.config[CONF_BAND]), + target_offsets={room: self.zones[room].target_offset for room in vals}, ) # Discard a result if a value used by the prediction changed while # saving or while the worker calculated the hypothetical plan. Optional diff --git a/custom_components/heating_mpc/physics.py b/custom_components/heating_mpc/physics.py index 9eb6350..299b023 100644 --- a/custom_components/heating_mpc/physics.py +++ b/custom_components/heating_mpc/physics.py @@ -179,11 +179,12 @@ def optimize( is_on: bool, seconds_since_change: float, horizon_minutes: int = 120, minimum_on_minutes: int = 15, minimum_off_minutes: int = 10, 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]: """Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations. 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): raise ValueError("Missing room temperatures or learner") @@ -206,6 +207,11 @@ def optimize( if not isfinite(total_weight): raise ValueError("Invalid total weight") 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 nsteps_per_block = 3 best = float("inf") @@ -234,12 +240,12 @@ def optimize( for tick in range(nsteps_per_block): state, lag = simulate_step(state, outside, lag, bit, learners) 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() ) # A lower bound on weighted average comfort also matters. 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) if idx * 15 + (tick + 1) * STEP_MINUTES == 60: at_60 = dict(state) diff --git a/custom_components/heating_mpc/sensor.py b/custom_components/heating_mpc/sensor.py index b2fd8c4..02e55d5 100644 --- a/custom_components/heating_mpc/sensor.py +++ b/custom_components/heating_mpc/sensor.py @@ -15,9 +15,18 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: coordinator = hass.data[DOMAIN][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)] - entities += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms] 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) + for subentry_id, zone_entities in linked.items(): + async_add_entities(zone_entities, config_subentry_id=subentry_id) class MPCStatus(CoordinatorEntity, SensorEntity): @@ -69,7 +78,7 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity): super().__init__(coordinator) self.room = room 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. _unrecorded_attributes = frozenset({"forecast_15min", "plan_15min"}) @@ -120,7 +129,7 @@ class MPCForecastError(CoordinatorEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_suggested_display_precision = 2 _attr_icon = "mdi:target" - _unrecorded_attributes = frozenset({"rooms"}) + _unrecorded_attributes = frozenset({"zones"}) def __init__(self, coordinator, entry_id, horizon): super().__init__(coordinator) @@ -144,7 +153,9 @@ class MPCForecastError(CoordinatorEntity, SensorEntity): "bias": accuracy.get("bias"), "persistence_mae": accuracy.get("persistence_mae"), "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, "unit_of_attributes": "°C", "basis": ("thermal model replayed with the actual relay history; outdoor " diff --git a/custom_components/heating_mpc/strings.json b/custom_components/heating_mpc/strings.json index 85de6e8..585dd34 100644 --- a/custom_components/heating_mpc/strings.json +++ b/custom_components/heating_mpc/strings.json @@ -40,7 +40,7 @@ }, "weights": { "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": { @@ -59,7 +59,6 @@ "data": { "climate_entity": "Existing Generic Thermostat", "heater_entity": "Physical heating switch (read-only)", - "room_entities": "Individual room temperature sensors", "horizon_minutes": "Prediction horizon (minutes)", "minimum_on_minutes": "Minimum ON time (minutes)", "minimum_off_minutes": "Minimum OFF time (minutes)", @@ -94,14 +93,9 @@ "outside_rain_sources": "Rain sources (optional)", "outside_snow_sources": "Snow sources (optional)" } - }, - "weights": { - "title": "Room priorities", - "description": "Weights determine relative comfort importance, not physics coefficients." } }, "error": { - "invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." } }, @@ -111,6 +105,7 @@ "state": { "waiting_for_inputs": "Waiting for inputs", "first_sample": "First sample", + "sensors_changed": "Zone sensors changed", "learned": "Learned", "partly_rejected": "Partly rejected (outlier)", "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." + } + } } } diff --git a/custom_components/heating_mpc/translations/en.json b/custom_components/heating_mpc/translations/en.json index 85de6e8..585dd34 100644 --- a/custom_components/heating_mpc/translations/en.json +++ b/custom_components/heating_mpc/translations/en.json @@ -40,7 +40,7 @@ }, "weights": { "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": { @@ -59,7 +59,6 @@ "data": { "climate_entity": "Existing Generic Thermostat", "heater_entity": "Physical heating switch (read-only)", - "room_entities": "Individual room temperature sensors", "horizon_minutes": "Prediction horizon (minutes)", "minimum_on_minutes": "Minimum ON time (minutes)", "minimum_off_minutes": "Minimum OFF time (minutes)", @@ -94,14 +93,9 @@ "outside_rain_sources": "Rain sources (optional)", "outside_snow_sources": "Snow sources (optional)" } - }, - "weights": { - "title": "Room priorities", - "description": "Weights determine relative comfort importance, not physics coefficients." } }, "error": { - "invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." } }, @@ -111,6 +105,7 @@ "state": { "waiting_for_inputs": "Waiting for inputs", "first_sample": "First sample", + "sensors_changed": "Zone sensors changed", "learned": "Learned", "partly_rejected": "Partly rejected (outlier)", "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." + } + } } } diff --git a/custom_components/heating_mpc/translations/ro.json b/custom_components/heating_mpc/translations/ro.json index 518635e..aca60aa 100644 --- a/custom_components/heating_mpc/translations/ro.json +++ b/custom_components/heating_mpc/translations/ro.json @@ -40,7 +40,7 @@ }, "weights": { "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": { @@ -59,7 +59,6 @@ "data": { "climate_entity": "Termostatul Generic Thermostat existent", "heater_entity": "Releul fizic (numai citire)", - "room_entities": "Senzorii individuali din camere", "horizon_minutes": "Orizontul predicției (minute)", "minimum_on_minutes": "Durată minimă ON (minute)", "minimum_off_minutes": "Durată minimă OFF (minute)", @@ -94,14 +93,9 @@ "outside_rain_sources": "Senzori precipitații ploaie (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": { - "invalid_rooms": "Selectează 1–12 senzori diferiți.", "invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor." } }, @@ -111,6 +105,7 @@ "state": { "waiting_for_inputs": "Așteaptă datele de intrare", "first_sample": "Prima măsurătoare", + "sensors_changed": "Senzorii zonei s-au schimbat", "learned": "Învățat", "partly_rejected": "Respins parțial (valoare aberantă)", "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ă." + } + } } } diff --git a/custom_components/heating_mpc/zones.py b/custom_components/heating_mpc/zones.py new file mode 100644 index 0000000..8be5c36 --- /dev/null +++ b/custom_components/heating_mpc/zones.py @@ -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 diff --git a/tests/ha/test_config_flow.py b/tests/ha/test_config_flow.py index 9888e7c..7c74b40 100644 --- a/tests/ha/test_config_flow.py +++ b/tests/ha/test_config_flow.py @@ -1,18 +1,31 @@ -"""Validate actual HA form schemas, options and entry migration.""" -from homeassistant.config_entries import SOURCE_USER +"""Validate actual HA form schemas, options, zone subentries and entry migration.""" +from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.heating_mpc.const import DOMAIN from custom_components.heating_mpc.weather_sources import sources_for +MAIN = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler"} -async def test_config_flow(hass, inputs): + +def zones_of(entry): + return {sub.unique_id: sub for sub in entry.subentries.values() if sub.subentry_type == "zone"} + + +async def setup_entry(hass, entry): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return hass.data[DOMAIN][entry.entry_id] + + +async def test_config_flow_creates_one_zone_per_sensor(hass, inputs): + hass.states.async_set("sensor.bedroom", "19", {"unit_of_measurement": "°C", "friendly_name": "Bedroom"}) result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) assert result["type"] is FlowResultType.FORM - main = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler", - "room_entities": ["sensor.room"]} - validated = result["data_schema"](main) + validated = result["data_schema"]({**MAIN, "room_entities": ["sensor.room", "sensor.bedroom"]}) assert validated["room_max_age_minutes"] == 30 assert validated["weather_max_age_minutes"] == 120 assert validated["room_stale_use_last_value"] is False @@ -23,65 +36,134 @@ async def test_config_flow(hass, inputs): assert result["step_id"] == "weather_extra" result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({})) assert result["step_id"] == "weights" - result = await hass.config_entries.flow.async_configure(result["flow_id"], {"weight__sensor.room": 2}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"weight__sensor.room": 2, "weight__sensor.bedroom": 1}) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["weights"] == {"sensor.room": 2} await hass.async_block_till_done() + entry = result["result"] + assert entry.version == 3 + assert "room_entities" not in entry.data and "weights" not in entry.data + zones = zones_of(entry) + assert {key: (sub.title, sub.data["comfort_weight"]) for key, sub in zones.items()} == { + "sensor.room": ("room", 2.0), "sensor.bedroom": ("Bedroom", 1.0)} # HA's name fallback + assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.room", "sensor.bedroom"] -async def test_options_flow_keeps_settings(hass, entry, inputs): - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() +async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs): + await setup_entry(hass, entry) result = await hass.config_entries.options.async_init(entry.entry_id) - main = result["data_schema"]({"climate_entity": "climate.reference", - "heater_entity": "switch.boiler", "room_entities": ["sensor.room"], - "room_max_age_minutes": 45, "room_stale_use_last_value": True}) + assert "room_entities" not in {str(key) for key in result["data_schema"].schema} + main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True}) result = await hass.config_entries.options.async_configure(result["flow_id"], main) result = await hass.config_entries.options.async_configure( result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]})) result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({})) - result = await hass.config_entries.options.async_configure(result["flow_id"], {"weight__sensor.room": 3}) assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() assert entry.options["room_max_age_minutes"] == 45 coordinator = hass.data[DOMAIN][entry.entry_id] assert coordinator.config["room_max_age_minutes"] == 45 assert coordinator.config["room_stale_use_last_value"] is True - # The next options form shows the saved choice. + assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["data_schema"]({"climate_entity": "climate.reference", "heater_entity": "switch.boiler", - "room_entities": ["sensor.room"]})["room_stale_use_last_value"] is True + assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True hass.config_entries.options.async_abort(result["flow_id"]) -async def test_v1_entry_migrates_on_setup(hass, inputs): +async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs): + await setup_entry(hass, entry) + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"](MAIN)) + result = await hass.config_entries.options.async_configure( + result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]})) + assert result["errors"] == {"base": "invalid_weather"} + + +async def test_v1_entry_migrates_to_zones_on_setup(hass, inputs): entry = MockConfigEntry(domain=DOMAIN, version=1, data={ - "climate_entity": "climate.reference", "heater_entity": "switch.boiler", - "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"}) + **MAIN, "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"}) + await setup_entry(hass, entry) + assert entry.version == 3 + assert entry.data["outside_temperature_sources"] == ["sensor.outside"] + assert "outside_entity" not in entry.data and "room_entities" not in entry.data + assert set(zones_of(entry)) == {"sensor.room"} + + +async def test_v2_migration_keeps_weights_models_and_entities(hass, entry, inputs, hass_storage): + from custom_components.heating_mpc.physics import Learner + hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C", "friendly_name": "Living room"}) + hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": { + "schema_version": 2, "heater_entity": "switch.boiler", + "learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}} entry.add_to_hass(hass) + # A forecast entity created by 0.3 for this room. + registry = er.async_get(hass) + old = registry.async_get_or_create("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min", + config_entry=entry, suggested_object_id="mpc_living_forecast") + hass.config_entries.async_update_entry(entry, options={"weights": {"sensor.room": 4.0}}) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.version == 2 - assert entry.data["outside_temperature_sources"] == ["sensor.outside"] - assert "outside_entity" not in entry.data + coordinator = hass.data[DOMAIN][entry.entry_id] + zone = zones_of(entry)["sensor.room"] + assert (zone.title, zone.data["comfort_weight"], zone.data["sensors"]) == ("Living room", 4.0, ["sensor.room"]) + assert "weights" not in entry.options + assert coordinator.learners["sensor.room"].count == 250 + # Same entity, now linked to its zone, so deleting the zone removes it. + migrated = registry.async_get(old.entity_id) + assert migrated.config_subentry_id == zone.subentry_id + assert hass.config_entries.async_remove_subentry(entry, zone.subentry_id) + await hass.async_block_till_done() + assert registry.async_get(old.entity_id) is None + + +async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs): + await setup_entry(hass, entry) + for entity, value in (("sensor.living_a", "21"), ("sensor.living_b", "22")): + hass.states.async_set(entity, value, {"unit_of_measurement": "°C"}) + flows = hass.config_entries.subentries + result = await flows.async_init((entry.entry_id, "zone"), context={"source": SOURCE_USER}) + assert result["type"] is FlowResultType.FORM + # A sensor of another zone, or an outdoor source, is refused. + for sensors, error in ((["sensor.room"], "sensor_in_other_zone"), (["sensor.outside"], "sensor_is_weather_source")): + result = await flows.async_configure(result["flow_id"], { + "name": "Living", "sensors": sensors, "comfort_weight": 2, "target_offset": 0}) + assert result["errors"] == {"base": error} + result = await flows.async_configure(result["flow_id"], { + "name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2, + "target_offset": 0.5, "neighbors": ["sensor.room"]}) + assert result["step_id"] == "sensor_weights" + result = await flows.async_configure(result["flow_id"], { + "weight__sensor.living_a": 3, "weight__sensor.living_b": 1}) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + living = next(sub for sub in zones_of(entry).values() if sub.title == "Living") + assert living.data["sensor_weights"] == {"sensor.living_a": 3.0, "sensor.living_b": 1.0} + coordinator = hass.data[DOMAIN][entry.entry_id] + key = living.data["key"] + assert coordinator.zones[key].neighbors == ("sensor.room",) + assert coordinator.zones["sensor.room"].neighbors == (key,) # symmetric + assert coordinator.data["temperatures"][key] == 21.25 + # Edit: drop a sensor; the key (and so the learned model) is kept. + result = await flows.async_init((entry.entry_id, "zone"), context={ + "source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id}) + result = await flows.async_configure(result["flow_id"], { + "name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0}) + assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful" + await hass.async_block_till_done() + assert entry.subentries[living.subentry_id].data["key"] == key + assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",) async def test_options_flow_clears_optional_weather_sources(hass, inputs): """Swapping providers in the UI must allow removing an optional source list.""" entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={ - "climate_entity": "climate.reference", "heater_entity": "switch.boiler", - "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"], + **MAIN, "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"], "outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"], "weights": {"sensor.room": 1.0}}) - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_entry(hass, entry) flow = hass.config_entries.options result = await flow.async_init(entry.entry_id) - result = await flow.async_configure(result["flow_id"], result["data_schema"]({ - "climate_entity": "climate.reference", "heater_entity": "switch.boiler", - "room_entities": ["sensor.room"]})) + result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN)) markers = {str(key): key for key in result["data_schema"].schema} assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]} # The user removes the wind provider; a cleared field may be omitted on submit. @@ -89,7 +171,6 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs): {"outside_temperature_sources": ["sensor.outside"]})) result = await flow.async_configure(result["flow_id"], result["data_schema"]( {"outside_pressure_sources": ["sensor.pressure"]})) - result = await flow.async_configure(result["flow_id"], {"weight__sensor.room": 1}) assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() assert entry.options["outside_wind_sources"] == [] diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index cc9cc25..4bee9cc 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -10,6 +10,21 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from custom_components.heating_mpc.const import DOMAIN +from homeassistant.config_entries import ConfigSubentry +from types import MappingProxyType + + +def zone_subentry(entry, key): + return next(sub for sub in entry.subentries.values() if sub.unique_id == key) + + +def add_zone(hass, entry, key, sensors, name=None): + """What the Add zone flow stores, added directly.""" + hass.config_entries.async_add_subentry(entry, ConfigSubentry( + data=MappingProxyType({"key": key, "name": name or key, "sensors": sensors, + "sensor_weights": {s: 1.0 for s in sensors}, "comfort_weight": 1.0, + "target_offset": 0.0, "neighbors": []}), + subentry_type="zone", title=name or key, unique_id=key)) async def setup(hass, entry): @@ -28,7 +43,8 @@ async def test_setup_reload_unload(hass, entry, inputs): ids = {e.unique_id: e.entity_id for e in entities} assert coordinator.data["status"] == "learning" status = hass.states.get(ids[f"{entry.entry_id}_status"]) - assert status.attributes["input_quality"]["rooms"]["sensor.room"]["max_age_seconds_24h"] == 0 + zone_quality = status.attributes["input_quality"]["zones"]["sensor.room"] + assert zone_quality["sensors"]["sensor.room"]["max_age_seconds_24h"] == 0 # Volatile diagnostics stay visible but are not written to the Recorder. assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"] assert "models" not in status.state_info["unrecorded_attributes"] @@ -45,16 +61,19 @@ async def test_setup_reload_unload(hass, entry, inputs): assert coordinator._unsub_refresh is None -async def test_options_reload_removes_only_old_room(hass, entry, inputs): +async def test_replacing_a_zone_removes_only_its_forecast(hass, entry, inputs): await setup(hass, entry) registry = er.async_get(hass) old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min") hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"}) - hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]}) + add_zone(hass, entry, "zone_new", ["sensor.new"], "New") await hass.async_block_till_done() - assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"] + assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN][entry.entry_id].rooms == ["zone_new"] assert registry.async_get(old) is None - assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min") + new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min") + assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction" assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11 @@ -203,7 +222,9 @@ async def test_removed_room_model_survives_options_round_trip(hass, entry, input "learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}} hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"}) await setup(hass, entry) - hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]}) + add_zone(hass, entry, "sensor.new", ["sensor.new"]) + await hass.async_block_till_done() + assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id) await hass.async_block_till_done() future = dt_util.utcnow() + timedelta(minutes=5, seconds=1) freezer.move_to(future) @@ -212,8 +233,7 @@ async def test_removed_room_model_survives_options_round_trip(hass, entry, input saved = hass_storage[key]["data"] assert set(saved["learners"]) == {"sensor.new"} assert saved["retired_learners"]["sensor.room"]["count"] == 250 - hass.config_entries.async_update_entry( - entry, options={"room_entities": ["sensor.room", "sensor.new"]}) + add_zone(hass, entry, "sensor.room", ["sensor.room"]) await hass.async_block_till_done() assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250 @@ -234,9 +254,9 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs, assert coordinator.data["forecast_accuracy"][15]["samples"] == 1 assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0 assert state.attributes["samples"] == 1 - assert state.attributes["rooms"]["sensor.room"]["samples"] == 1 + assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name assert state.attributes["unit_of_measurement"] == "°C" - assert "rooms" in state.state_info["unrecorded_attributes"] + assert "zones" in state.state_info["unrecorded_attributes"] assert hass.states.get(ids[30]).state == "unknown" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index d119152..e847e68 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -44,6 +44,7 @@ def load_integration(): modules['homeassistant.helpers.event'].async_track_state_change_event = ( lambda hass, entity_ids, action: hass.track_state(entity_ids, action)) modules['homeassistant.config_entries'].ConfigEntry = object + modules['homeassistant.config_entries'].ConfigSubentry = lambda **kwargs: types.SimpleNamespace(**kwargs) modules['homeassistant.helpers.storage'].Store = Store modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase registry = modules['homeassistant.helpers.entity_registry'] @@ -249,7 +250,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside']) self.assertNotIn('outside_entity', kwargs['data']) self.assertEqual(kwargs['version'], 2) - self.entry.version = 3 + self.entry.version = 4 self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry)) async def test_changed_inputs_during_optimization_discard_result(self): @@ -263,12 +264,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(data['status'], 'inputs_changed') self.assertIsNone(data['proposal']) - async def test_invalid_weights_do_not_publish_proposal(self): + async def test_invalid_stored_comfort_weights_fall_back_to_one(self): for weight in (0, -1, 'bad', float('nan'), float('inf')): - self.c.config['weights'] = {'sensor.room': weight} - data = await self.sample() - self.assertEqual(data['status'], 'invalid_weights') - self.assertIsNone(data['proposal']) + self.entry.data['weights'] = {'sensor.room': weight} + self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry) + self.assertEqual(self.c.zones['sensor.room'].comfort_weight, 1.0) async def test_added_room_needs_its_own_excitation_evidence(self): model = coordinator.Learner(count=500, on_count=250, off_count=250) @@ -295,7 +295,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.states['sensor.room'].last_reported = self.now - timedelta(minutes=31) data = await self.sample() self.assertEqual(data['status'], 'waiting_for_sensors') - self.assertEqual(data['input_quality']['rooms']['sensor.room']['status'], 'stale') + self.assertEqual(data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']['status'], 'stale') self.assertIsNone(data['proposal']) self.set_state('sensor.room', '20', unit_of_measurement='°C') data = await self.sample() @@ -511,7 +511,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): await self.c.async_restore_model() self.assertEqual(self.c.learners['sensor.room'].count, 7) await self.sample() - self.assertEqual((await self.sample())['retired_room_models'], ['sensor.gone']) + self.assertEqual((await self.sample())['retired_zone_models'], ['sensor.gone']) payload = self.c._store.async_save.call_args.args[0] self.assertEqual(set(payload['learners']), {'sensor.room'}) self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500) @@ -534,12 +534,12 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): await self.sample(2400) # the room then stays silent for 40 minutes self.states['sensor.outside'].last_reported = self.now data = await self.sample() - room = data['input_quality']['rooms']['sensor.room'] + room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room'] self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400)) self.states['sensor.room'].last_reported = self.now self.states['sensor.outside'].last_reported = self.now data = await self.sample() - room = data['input_quality']['rooms']['sensor.room'] + room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room'] self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']), ('valid', 0, 2400)) weather = data['input_quality']['weather']['temperature']['sensor.outside'] @@ -563,17 +563,19 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31) data = await self.sample() self.assertEqual(data['status'], 'learning') - self.assertEqual(data['excluded_rooms'], ['sensor.bedroom']) - rooms = data['input_quality']['rooms'] - self.assertEqual((rooms['sensor.bedroom']['status'], rooms['sensor.bedroom']['used']), ('stale', False)) - self.assertTrue(rooms['sensor.room']['used']) + self.assertEqual(data['excluded_zones'], ['sensor.bedroom']) + zones = data['input_quality']['zones'] + bedroom = zones['sensor.bedroom']['sensors']['sensor.bedroom'] + self.assertEqual((bedroom['status'], bedroom['used'], zones['sensor.bedroom']['used']), + ('stale', False, False)) + self.assertTrue(zones['sensor.room']['used']) self.assertEqual((data['weighted_temperature'], data['weight_coverage']), (20, 0.75)) self.assertEqual(data['sample_result'], 'learned') counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count) self.assertEqual(counts(), (1, 0)) self.fresh() # bedroom reports again: no learning across its gap data = await self.sample() - self.assertEqual((data['excluded_rooms'], data['weight_coverage']), ([], 1.0)) + self.assertEqual((data['excluded_zones'], data['weight_coverage']), ([], 1.0)) self.assertEqual(data['weighted_temperature'], 19.5) self.assertEqual(counts(), (2, 0)) self.fresh() @@ -599,7 +601,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.states[room].last_reported = self.now - timedelta(minutes=31) data = await self.sample() self.assertEqual(data['status'], 'waiting_for_sensors') - self.assertEqual(data['excluded_rooms'], ['sensor.room', 'sensor.bedroom']) + self.assertEqual(data['excluded_zones'], ['sensor.room', 'sensor.bedroom']) self.assertIsNone(data['proposal']) async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self): @@ -608,9 +610,9 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): await self.sample() self.fresh('sensor.room', 'sensor.outside') data = await self.sample() - bedroom = data['input_quality']['rooms']['sensor.bedroom'] + bedroom = data['input_quality']['zones']['sensor.bedroom']['sensors']['sensor.bedroom'] self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True)) - self.assertEqual((data['excluded_rooms'], data['temperatures']['sensor.bedroom']), ([], 18)) + self.assertEqual((data['excluded_zones'], data['temperatures']['sensor.bedroom']), ([], 18)) self.assertEqual(self.c.learners['sensor.bedroom'].count, 1) for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))): with self.subTest(state=state): @@ -618,7 +620,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.states['sensor.bedroom'].state = state self.states['sensor.bedroom'].last_reported = self.now - age data = await self.sample() - self.assertEqual(data['excluded_rooms'], ['sensor.bedroom']) + self.assertEqual(data['excluded_zones'], ['sensor.bedroom']) self.assertEqual(data['status'], 'learning') async def test_poll_outcomes_and_rejections_are_counted(self): @@ -697,3 +699,39 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): data = await self.sample() self.assertEqual(data['sample_result'], 'waiting_for_inputs') self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1) + + def multi_sensor_zone(self): + self.entry.subentries = {'s1': types.SimpleNamespace(subentry_id='s1', subentry_type='zone', data={ + 'key': 'zone_living', 'name': 'Living', 'sensors': ['sensor.room', 'sensor.second'], + 'sensor_weights': {'sensor.room': 3, 'sensor.second': 1}, 'comfort_weight': 2, + 'target_offset': 0.5})} + self.set_state('sensor.second', '22', unit_of_measurement='°C') + self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry) + + async def test_multi_sensor_zone_weights_sensors_and_skips_composition_changes(self): + self.multi_sensor_zone() + data = await self.sample() + self.assertEqual((data['temperatures'], data['zone_names']), ({'zone_living': 20.5}, {'zone_living': 'Living'})) + self.fresh('sensor.room', 'sensor.second', 'sensor.outside') + self.assertEqual((await self.sample())['sample_result'], 'learned') + self.states['sensor.second'].state = 'unavailable' + self.fresh('sensor.room', 'sensor.outside') + data = await self.sample() + self.assertEqual(data['temperatures'], {'zone_living': 20}) + # The mean jumped by the sensors' offset, not by a real change: not learned. + self.assertEqual(data['sample_result'], 'sensors_changed') + self.assertEqual(self.c.learners['zone_living'].count, 1) + quality = data['input_quality']['zones']['zone_living'] + self.assertEqual((quality['used'], quality['sensors']['sensor.second']['status']), (True, 'unavailable')) + self.fresh('sensor.room', 'sensor.outside') + self.assertEqual((await self.sample())['sample_result'], 'learned') + + async def test_zone_comfort_weight_and_target_offset_reach_optimizer(self): + self.multi_sensor_zone() + model = self.c.learners['zone_living'] + model.count, model.on_count, model.off_count = 200, 100, 100 + with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy: + data = await self.sample() + self.assertEqual(data['status'], 'ready') + self.assertEqual(spy.call_args.args[4], {'zone_living': 2.0}) + self.assertEqual(spy.call_args.kwargs['target_offsets'], {'zone_living': 0.5}) diff --git a/tests/test_physics.py b/tests/test_physics.py index 9e376db..54b0570 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -45,6 +45,14 @@ class ModelTests(unittest.TestCase): learners, False, 3600, horizon_minutes=60) self.assertTrue(out['proposal']) + def test_zone_target_offsets_shift_comfort(self): + learners = {'r': m.Learner()} + args = ({'r': 20.8}, 8, 0, 22, {'r': 1}, learners, False, 3600) + self.assertTrue(m.optimize(*args, horizon_minutes=60)['proposal']) + self.assertFalse(m.optimize(*args, horizon_minutes=60, target_offsets={'r': -1.5})['proposal']) + with self.assertRaises(ValueError): + m.optimize(*args, horizon_minutes=60, target_offsets={'r': float('nan')}) + def test_minimum_duration_on_and_off(self): learners = {'r': m.Learner()} # Could not switch OFF during first 15 minutes if just turned ON. diff --git a/tests/test_zones.py b/tests/test_zones.py new file mode 100644 index 0000000..b205f2a --- /dev/null +++ b/tests/test_zones.py @@ -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()