diff --git a/custom_components/heating_mpc/config_flow.py b/custom_components/heating_mpc/config_flow.py index 8e4b8a2..0323cca 100644 --- a/custom_components/heating_mpc/config_flow.py +++ b/custom_components/heating_mpc/config_flow.py @@ -13,9 +13,15 @@ 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_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, - CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS, + CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS, WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA, ) +from .heat_sources import ( + BASELINE_AUTO, BASELINE_MANUAL, DEFAULT_RESPONSE_MINUTES, MAX_POWER_W, RESPONSE_MINUTES_RANGE, + SOURCE_BASELINE_MODE, SOURCE_BASELINE_W, SOURCE_ENABLED, SOURCE_FRACTION, SOURCE_INCLUDED_IN, + SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE, + HeatSource, build_sources, validate_source, +) from .weather_sources import sources_for from .zones import ( COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE, @@ -59,6 +65,7 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False): CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)), CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)), CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)), + CONF_INTERNAL_HALF_LIFE: vol.All(vol.Coerce(int), vol.Range(min=0, max=720)), }.items(): schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed return vol.Schema(schema) @@ -112,6 +119,12 @@ def entry_zones(entry: config_entries.ConfigEntry) -> list[Zone]: if sub.subentry_type == SUBENTRY_ZONE) +def entry_sources(entry: config_entries.ConfigEntry) -> list[HeatSource]: + zones = {zone.key for zone in entry_zones(entry)} + return build_sources(((sub.data, sub.subentry_id) for sub in entry.subentries.values() + if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones) + + 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)) @@ -178,7 +191,7 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @classmethod @callback def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry): - return {SUBENTRY_ZONE: ZoneSubentryFlow} + return {SUBENTRY_ZONE: ZoneSubentryFlow, SUBENTRY_HEAT_SOURCE: HeatSourceSubentryFlow} class HeatingMPCOptionsFlow(config_entries.OptionsFlow): @@ -297,3 +310,70 @@ class ZoneSubentryFlow(config_entries.ConfigSubentryFlow): 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]) + + +class HeatSourceSubentryFlow(config_entries.ConfigSubentryFlow): + """Add or edit an electrical heat source (UPS, PC, server) in a zone.""" + + def _others(self, key: str | None) -> list[HeatSource]: + return [s for s in entry_sources(self._get_entry()) if s.key != key] + + def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema: + zones = entry_zones(self._get_entry()) + parents = [s for s in self._others(key) if s.included_in is None] + schema: dict[Any, Any] = { + vol.Required(SOURCE_NAME, default=data.get(SOURCE_NAME, "")): selector.TextSelector(), + vol.Required(SOURCE_SENSOR, **({"default": data[SOURCE_SENSOR]} if data.get(SOURCE_SENSOR) else {})): + selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor", device_class="power")), + vol.Required(SOURCE_ZONE, **({"default": data[SOURCE_ZONE]} if data.get(SOURCE_ZONE) else {})): + selector.SelectSelector(selector.SelectSelectorConfig( + options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])), + vol.Required(SOURCE_FRACTION, default=data.get(SOURCE_FRACTION, 1.0)): _number(0.0, 1.0, 0.05), + vol.Required(SOURCE_BASELINE_MODE, default=data.get(SOURCE_BASELINE_MODE, BASELINE_AUTO)): + selector.SelectSelector(selector.SelectSelectorConfig( + options=[BASELINE_AUTO, BASELINE_MANUAL], translation_key=SOURCE_BASELINE_MODE)), + vol.Optional(SOURCE_BASELINE_W, description={"suggested_value": data.get(SOURCE_BASELINE_W)}): + _number(0.0, MAX_POWER_W, 1.0), + vol.Required(SOURCE_RESPONSE, default=data.get(SOURCE_RESPONSE, DEFAULT_RESPONSE_MINUTES)): + _number(*RESPONSE_MINUTES_RANGE, 1.0), + vol.Required(SOURCE_ENABLED, default=data.get(SOURCE_ENABLED, True)): selector.BooleanSelector(), + } + if parents: + schema[vol.Optional(SOURCE_INCLUDED_IN, description={ + "suggested_value": data.get(SOURCE_INCLUDED_IN)})] = selector.SelectSelector( + selector.SelectSelectorConfig(options=[ + selector.SelectOptionDict(value=s.key, label=s.name) for s in parents])) + return vol.Schema(schema) + + async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]): + key = existing[SOURCE_KEY] + if not entry_zones(self._get_entry()): + return self.async_abort(reason="no_zones") + if user_input is not None: + data = {**existing, **user_input, SOURCE_KEY: key} + data.setdefault(SOURCE_INCLUDED_IN, None) + error = validate_source(data, self._others(key)) + if error: + return self.async_show_form(step_id=step_id, data_schema=self._schema(data, key), + errors={"base": error}) + data[SOURCE_NAME] = data[SOURCE_NAME].strip() + for number in (SOURCE_FRACTION, SOURCE_RESPONSE): + data[number] = float(data[number]) + if data.get(SOURCE_BASELINE_W) is not None: + data[SOURCE_BASELINE_W] = float(data[SOURCE_BASELINE_W]) + if self.source == config_entries.SOURCE_RECONFIGURE: + return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(), + title=data[SOURCE_NAME], data=data) + return self.async_create_entry(title=data[SOURCE_NAME], data=data, unique_id=key) + 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 hasattr(self, "_key"): + self._key = f"source_{uuid4().hex[:10]}" + return await self._form("user", user_input, {SOURCE_KEY: self._key}) + + async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): + subentry = self._get_reconfigure_subentry() + existing = dict(subentry.data) + existing.setdefault(SOURCE_KEY, subentry.subentry_id) + return await self._form("reconfigure", user_input, existing) diff --git a/custom_components/heating_mpc/const.py b/custom_components/heating_mpc/const.py index 646e20b..460e097 100644 --- a/custom_components/heating_mpc/const.py +++ b/custom_components/heating_mpc/const.py @@ -16,6 +16,8 @@ CONF_WEATHER_MAX_AGE = "weather_max_age_minutes" # True keeps using their last value for sensors that report only on change. CONF_ROOM_STALE_KEEP = "room_stale_use_last_value" CONF_BAND = "comfort_band" +# Forecast assumption: extra electrical heat returns to baseline with this half-life. +CONF_INTERNAL_HALF_LIFE = "internal_heat_half_life_minutes" # Each source list is ordered: first valid sensor wins; remaining are fallbacks. WEATHER_SOURCES = { @@ -42,6 +44,7 @@ DEFAULTS = { CONF_SWITCH_COST: 0.15, CONF_HEAT_COST: 0.025, CONF_BAND: 0.15, + CONF_INTERNAL_HALF_LIFE: 30, } # A room sensor silent this long is treated as dead even when keeping last values. ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60 diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 87f8fd3..6b4d769 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -17,15 +17,18 @@ from .const import ( CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES, DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST, - CONF_BAND, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES, + CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES, ) from .data_quality import ReportAgeHistory, freshness from .evaluation import ForecastEvaluator +from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power +from .learning import FeatureLearner from .physics import ( LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag, lag_from_time_in_state, optimize, relay_segments, ) from .weather_sources import choose_weather_sources, sources_for, normalize +from .zone_model import ZoneModel, extended_features, feature_vector from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature _LOGGER = logging.getLogger(__name__) @@ -46,6 +49,12 @@ def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]: return build_zones(items) +def entry_source_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[HeatSource]: + subentries = getattr(entry, "subentries", None) or {} + return build_sources(((sub.data, sub.subentry_id) for sub in subentries.values() + if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones) + + class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The existing generic thermostat stays the only actuator owner.""" @@ -59,6 +68,14 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): 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.sources = entry_source_list(entry, self.zones) + self._heat_sources = HeatSourceTracker(self.sources) + # Extended model per zone: base terms plus electrical heat and adjacent zones. + self._extended_features = { + key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors) + for key, zone in self.zones.items()} + self.extended: dict[str, FeatureLearner] = { + key: FeatureLearner(features) for key, features in self._extended_features.items() if features} self._retired: dict[str, dict[str, Any]] = {} self._report_ages = ReportAgeHistory() self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY) @@ -86,6 +103,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") return self._evaluator.load(stored.get("evaluation")) + self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources")) + saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {} + for key, features in self._extended_features.items(): + if features and key in saved_models: + # Matched by feature name: a new source or neighbour retrains only its term. + self.extended[key] = FeatureLearner.from_dict(saved_models[key], features) # Models carry room identity, so changing the room list is safe. Models of # rooms no longer configured are retained, most recently removed last. for key in ("retired_learners", "learners"): # current learners take precedence @@ -185,6 +208,51 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): + min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3 for m in self.learners.values()), 1) + def _extended_diagnostics(self, room: str) -> dict[str, Any]: + model = self.extended.get(room) + if model is None: + return {"model_in_use": "base"} + return {"model_in_use": "extended" if ZoneModel(self.learners[room], model).uses_extended else "base", + "extended": {"samples": model.count, "on_samples": model.on_count, + "off_samples": model.off_count, + "mae_5m": round(model.mae, 4) if model.mae is not None else None, + "coefficients": model.coefficients()}} + + def _source_diagnostics(self, quality: dict[str, dict]) -> dict[str, Any]: + names = {s.key: s.name for s in self.sources} + result = {} + for source in self.sources: + info = self._heat_sources.state.get(source.key, {}) + + def watts(kw): + return None if kw is None else round(kw * 1000, 1) + result[source.name] = { + "zone": self.zones[source.zone].name, "sensor": source.sensor, + "status": quality[source.key]["status"], "enabled": source.enabled, + "power_w": info.get("power_w"), "effective_w": info.get("effective_w"), + "included_in": names.get(source.included_in), + "baseline_w": info.get("baseline_w"), "baseline_mode": source.baseline_mode, + "variable_w": watts(info.get("variable_kw")), "thermal_response_w": watts(info.get("response_kw")), + "settled": info.get("settled", False), "heat_fraction": source.fraction, + "response_minutes": source.response_minutes, + } + return result + + def _step_model(self, temps: dict[str, float], outside: float, horizon: int): + """Multi-zone step for the optimizer, or None when only base models are in use.""" + models = {key: ZoneModel(self.learners[key], self.extended.get(key)) for key in temps} + if not any(model.uses_extended for model in models.values()): + return None + steps = horizon // 5 + half_life = float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30)) + forecast = {key: self._heat_sources.zone_forecast(key, steps, half_life) for key in temps} + + def step(state: dict[str, float], lag: float, k: int) -> dict[str, float]: + return {key: value + models[key].delta(value, { + "outside": outside, "lag": lag, "internal": forecast[key][min(k, steps - 1)], "temps": state}) + for key, value in state.items()} + return step + def _prediction_inputs(self) -> tuple: """Values the prediction depends on, used to detect changes during calculation.""" heater = self.config[CONF_HEATER] @@ -278,6 +346,23 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "retired_zone_models": sorted(self._retired), "excluded_zones": [room for room in self.rooms if temps[room] is None], } + # Electrical heat sources: processed every poll so their responses stay continuous. + source_readings: dict[str, float | None] = {} + source_quality = {} + for source in self.sources: + quality = self._sensor_quality(source.sensor, now, self.config[CONF_ROOM_MAX_AGE]) + watts = None + if quality["status"] == "valid": + state = self.hass.states.get(source.sensor) + watts = normalize_power(state.state, state.attributes.get("unit_of_measurement")) + if watts is None: + quality["status"] = "invalid_value_or_unit" + source_readings[source.key] = watts + source_quality[source.key] = quality + self._heat_sources.update(now.timestamp(), source_readings) + internal = {key: self._heat_sources.zone_input(key) for key in self.zones} + if self.sources: + status["heat_sources"] = self._source_diagnostics(source_quality) heater_valid = heater is not None and heater.state in ("on", "off") heater_on = heater_valid and heater.state == "on" steady = False @@ -331,6 +416,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): status["sample_result"] = "learned" if all(accepted) else "partly_rejected" self._update_counts() learned = True + # Extended models learn the same interval when all their inputs are known. + before = {"outside": self._previous["outside"], "lag": self._lag["lag"], + "temps": self._previous["temps"]} + for room in pairs: + model = self.extended.get(room) + x = model and feature_vector(model.features, self._previous["temps"][room], + {**before, "internal": internal[room]}) + if x: + model.learn(x, (vals[room] - self._previous["temps"][room]) * 300 / dt, heater_on) self._count_result(status, status["sample_result"]) self._previous = { @@ -348,6 +442,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "learners": {room: learner.as_dict() for room, learner in self.learners.items()}, # Optional keys; older 0.2.x readers ignore them and keep working. "retired_learners": self._retired, + "zone_models": {key: model.as_dict() for key, model in self.extended.items()}, + "heat_sources": self._heat_sources.as_dict(), "evaluation": self._evaluator.as_dict(), "on_samples": self._on_samples, "off_samples": self._off_samples, "heater_entity": self.config[CONF_HEATER], @@ -374,7 +470,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "samples": model.count, "on_samples": model.on_count, "off_samples": model.off_count, "rejected_samples": model.rejected_count, - "mae_5m": round(model.mae, 4) if model.mae is not None else None} + "mae_5m": round(model.mae, 4) if model.mae is not None else None, + **self._extended_diagnostics(room)} for room, model in self.learners.items() }, }) @@ -414,6 +511,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): 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}, + step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON])), ) # 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/heat_sources.py b/custom_components/heating_mpc/heat_sources.py new file mode 100644 index 0000000..926d631 --- /dev/null +++ b/custom_components/heating_mpc/heat_sources.py @@ -0,0 +1,304 @@ +"""Electrical internal heat gains (UPS, PCs, servers) per zone. + +Pure Python; no Home Assistant imports. + +Most electrical energy ends up as heat in the room. What matters to the thermal +model is the *variation* around a persistent baseline: the constant part (for +example a UPS drawing 200 W all day) is already absorbed by the model's bias +term, so feeding it again would count it twice. Each source therefore supplies + + variable heat = fraction x (effective power - baseline) + +where the baseline is a robust low percentile of the last 24 hours (or a value +set by the user), and "effective power" removes separately measured devices +that are already included in a parent measurement (a PC behind a UPS). The +variable heat reaches the room through a first-order response, because the air, +furniture and equipment take time to warm. +""" +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from math import exp, isfinite +from typing import Any, Iterable, Mapping + +SUBENTRY_HEAT_SOURCE = "heat_source" +MAX_SOURCES = 16 +MAX_POWER_W = 20000.0 +BASELINE_WINDOW = 288 # 24 h of 5-minute samples +BASELINE_PERCENTILE = 0.2 +BASELINE_MIN_SAMPLES = 12 # 1 h before the automatic baseline is trusted +RESPONSE_MINUTES_RANGE = (1.0, 180.0) +DEFAULT_RESPONSE_MINUTES = 15.0 + +SOURCE_KEY = "key" +SOURCE_NAME = "name" +SOURCE_SENSOR = "sensor" +SOURCE_ZONE = "zone" +SOURCE_FRACTION = "heat_fraction" +SOURCE_BASELINE_MODE = "baseline_mode" +SOURCE_BASELINE_W = "baseline_w" +SOURCE_INCLUDED_IN = "included_in" +SOURCE_RESPONSE = "response_minutes" +SOURCE_ENABLED = "enabled" +BASELINE_AUTO = "auto" +BASELINE_MANUAL = "manual" + +# Active power only: VA (apparent power) and % (UPS load) are not heat. +POWER_UNITS = {"w": 1.0, "kw": 1000.0, "mw": 0.001} + + +def normalize_power(raw: Any, unit: Any) -> float | None: + """Active power in W, or None when the value or unit is unusable.""" + if not isinstance(unit, str) or unit.strip().lower() not in POWER_UNITS: + return None + try: + value = float(raw) * POWER_UNITS[unit.strip().lower()] + except (TypeError, ValueError): + return None + return value if isfinite(value) and 0 <= value <= MAX_POWER_W else None + + +@dataclass(frozen=True) +class HeatSource: + key: str + name: str + sensor: str + zone: str + fraction: float = 1.0 + baseline_mode: str = BASELINE_AUTO + baseline_w: float = 0.0 + included_in: str | None = None + response_minutes: float = DEFAULT_RESPONSE_MINUTES + enabled: bool = True + subentry_id: str | None = None + + +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 source_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> HeatSource | None: + key = data.get(SOURCE_KEY) or subentry_id + sensor, zone = data.get(SOURCE_SENSOR), data.get(SOURCE_ZONE) + if not (isinstance(key, str) and isinstance(sensor, str) and sensor.startswith("sensor.") + and isinstance(zone, str)): + return None + name = data.get(SOURCE_NAME) + parent = data.get(SOURCE_INCLUDED_IN) + mode = data.get(SOURCE_BASELINE_MODE) + return HeatSource( + key=key, name=name if isinstance(name, str) and name.strip() else key, sensor=sensor, zone=zone, + fraction=_number(data.get(SOURCE_FRACTION), 1.0, 0.0, 1.0), + baseline_mode=mode if mode in (BASELINE_AUTO, BASELINE_MANUAL) else BASELINE_AUTO, + baseline_w=_number(data.get(SOURCE_BASELINE_W), 0.0, 0.0, MAX_POWER_W), + included_in=parent if isinstance(parent, str) and parent and parent != key else None, + response_minutes=_number(data.get(SOURCE_RESPONSE), DEFAULT_RESPONSE_MINUTES, *RESPONSE_MINUTES_RANGE), + enabled=data.get(SOURCE_ENABLED, True) is not False, + subentry_id=subentry_id, + ) + + +def build_sources(items: Iterable[tuple[Mapping[str, Any], str | None]], zone_keys: Iterable[str]) -> list[HeatSource]: + """Valid sources: unique keys and sensors, existing zones, one level of inclusion.""" + zone_keys = set(zone_keys) + sources: list[HeatSource] = [] + for data, subentry_id in items: + source = source_from_data(data, subentry_id) + if (source is None or source.zone not in zone_keys or len(sources) == MAX_SOURCES + or any(s.key == source.key or s.sensor == source.sensor for s in sources)): + continue + sources.append(source) + keys = {s.key: s for s in sources} + # A parent must exist and must not itself be included in another source. + return [s if s.included_in in keys and keys[s.included_in].included_in is None + else HeatSource(**{**s.__dict__, "included_in": None}) if s.included_in else s + for s in sources] + + +def effective_power(sources: list[HeatSource], readings: Mapping[str, float | None]) -> dict[str, float | None]: + """Power of each enabled source after removing included child sources (W). + + `readings` maps source key -> measured W (None if unavailable). A parent is + unknown when any enabled child it contains is unknown, since the split is + then impossible. Values below 0 (timing differences) are clamped. + """ + result: dict[str, float | None] = {} + for source in sources: + if not source.enabled: + continue + power = readings.get(source.key) + children = [s for s in sources if s.included_in == source.key and s.enabled] + if power is None or any(readings.get(c.key) is None for c in children): + result[source.key] = None + continue + result[source.key] = max(0.0, power - sum(readings[c.key] for c in children)) + return result + + +class BaselineEstimator: + """Robust persistent load: low percentile of the last 24 hours of 5-minute samples.""" + + def __init__(self, samples: Iterable[float] = ()) -> None: + self.samples: deque[float] = deque( + (float(v) for v in samples if isinstance(v, (int, float)) and isfinite(v) and 0 <= v <= MAX_POWER_W), + maxlen=BASELINE_WINDOW) + + def add(self, watts: float) -> None: + self.samples.append(watts) + + def value(self) -> float | None: + if len(self.samples) < BASELINE_MIN_SAMPLES: + return None + ordered = sorted(self.samples) + return ordered[int(BASELINE_PERCENTILE * (len(ordered) - 1))] + + +class ResponseFilter: + """First-order thermal response of a heat input (kW), like the radiator lag.""" + + def __init__(self, minutes: float) -> None: + self.tau = minutes * 60.0 + self.value: float | None = None + + def advance(self, held_kw: float, seconds: float) -> float: + """Advance over an interval during which `held_kw` was dissipated.""" + if self.value is None: + self.value = held_kw # assume the current load has been steady + else: + self.value += (1.0 - exp(-max(0.0, seconds) / self.tau)) * (held_kw - self.value) + return self.value + + def reset(self) -> None: + self.value = None + + +def forecast_internal(current_kw: float, steps: int, half_life_minutes: float | None, + step_minutes: float = 5.0) -> list[float]: + """Variable heat assumed over a forecast: decays to baseline (0) with a half-life. + + A gaming PC drawing 600 W now is not assumed to keep doing so for two hours. + `half_life_minutes=None` keeps the current value (persistence). + """ + if half_life_minutes is None: + return [current_kw] * steps + if half_life_minutes <= 0: + return [0.0] * steps + factor = 0.5 ** (step_minutes / half_life_minutes) + return [current_kw * factor ** (k + 1) for k in range(steps)] + + +def validate_source(data: Mapping[str, Any], others: Iterable[HeatSource]) -> str | None: + """Translation key of the first problem with a heat-source form, or None.""" + others = list(others) + if not str(data.get(SOURCE_NAME, "")).strip(): + return "source_name_required" + if any(s.sensor == data.get(SOURCE_SENSOR) for s in others): + return "sensor_in_other_source" + parent = data.get(SOURCE_INCLUDED_IN) + if parent: + parents = {s.key: s for s in others} + if parent not in parents or parents[parent].included_in: + return "invalid_parent_source" + key = data.get(SOURCE_KEY) + if key and any(s.included_in == key for s in others): + return "source_has_children" + if data.get(SOURCE_BASELINE_MODE) == BASELINE_MANUAL and data.get(SOURCE_BASELINE_W) in (None, ""): + return "baseline_required" + if len(others) >= MAX_SOURCES: + return "too_many_sources" + return None + + +def forecast_response(response_kw: float, variable_kw: float, steps: int, tau_minutes: float, + half_life_minutes: float | None, step_minutes: float = 5.0) -> list[float]: + """Thermal response (kW) at the end of each forecast step. + + The variable power returns towards baseline (see forecast_internal) and the + response keeps following it with its own time constant. + """ + alpha = 1.0 - exp(-step_minutes / tau_minutes) + held = [variable_kw] + forecast_internal(variable_kw, steps - 1, half_life_minutes, step_minutes) + result, value = [], response_kw + for power in held: + value += alpha * (power - value) + result.append(value) + return result + + +class HeatSourceTracker: + """Per-poll processing of all heat sources: baselines, responses, zone inputs.""" + + def __init__(self, sources: list[HeatSource], saved: Any = None) -> None: + self.sources = sources + saved = saved if isinstance(saved, Mapping) else {} + stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {} + self._baselines = {s.key: BaselineEstimator(stored.get(s.key) if isinstance(stored.get(s.key), list) else ()) + for s in sources} + self._filters = {s.key: ResponseFilter(s.response_minutes) for s in sources} + self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW) + self._settled_since: dict[str, float] = {} + self.state: dict[str, dict[str, Any]] = {} + + def update(self, now: float, readings: Mapping[str, float | None]) -> None: + """Process one poll. `now` is a timestamp; `readings` maps key -> measured W or None.""" + effective = effective_power(self.sources, readings) + self.state = {} + for source in self.sources: + info: dict[str, Any] = {"power_w": readings.get(source.key), "enabled": source.enabled} + self.state[source.key] = info + if not source.enabled: + continue + watts = effective.get(source.key) + estimator = self._baselines[source.key] + if watts is not None: + estimator.add(watts) + baseline = source.baseline_w if source.baseline_mode == BASELINE_MANUAL else estimator.value() + variable = None if watts is None or baseline is None else source.fraction * (watts - baseline) / 1000 + # The heat during the interval just ended is what was drawn at its start. + held = self._held.get(source.key) + filt = self._filters[source.key] + if held and held[1] is not None and 0 < now - held[0] <= 900: + response = filt.advance(held[1], now - held[0]) + else: + filt.reset() + self._settled_since.pop(source.key, None) + response = None if variable is None else filt.advance(variable, 0) + if response is not None: + self._settled_since.setdefault(source.key, now) + self._held[source.key] = (now, variable) + info.update(effective_w=watts, baseline_w=baseline, variable_kw=variable, response_kw=response, + settled=response is not None + and now - self._settled_since[source.key] >= 2 * filt.tau) + + def zone_input(self, zone: str) -> float | None: + """Summed thermal response of a zone's sources (kW); None if unknown or unsettled.""" + total = 0.0 + for source in self.sources: + if source.zone != zone or not source.enabled: + continue + info = self.state.get(source.key, {}) + if not info.get("settled"): + return None + total += info["response_kw"] + return total + + def zone_forecast(self, zone: str, steps: int, half_life_minutes: float | None) -> list[float]: + """Assumed thermal response of a zone's sources over a forecast (kW per step).""" + total = [0.0] * steps + for source in self.sources: + info = self.state.get(source.key, {}) + if source.zone != zone or not source.enabled or info.get("response_kw") is None: + continue + variable = info["variable_kw"] if info.get("variable_kw") is not None else 0.0 + for k, value in enumerate(forecast_response(info["response_kw"], variable, steps, + source.response_minutes, half_life_minutes)): + total[k] += value + return total + + def as_dict(self) -> dict[str, Any]: + return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()}} diff --git a/custom_components/heating_mpc/physics.py b/custom_components/heating_mpc/physics.py index 299b023..fb863ad 100644 --- a/custom_components/heating_mpc/physics.py +++ b/custom_components/heating_mpc/physics.py @@ -8,7 +8,10 @@ from __future__ import annotations from dataclasses import dataclass, field from itertools import product from math import exp, isfinite -from typing import Any +from typing import Any, Callable + +# (zone temperatures, radiator lag at the end of the step, step index) -> new temperatures +StepModel = Callable[[dict[str, float], float, int], dict[str, float]] from .learning import bound_covariance, positive_definite, rls_update @@ -180,11 +183,14 @@ def optimize( minimum_on_minutes: int = 15, minimum_off_minutes: int = 10, switching_cost: float = .15, heating_cost: float = .025, comfort_band: float = .15, target_offsets: dict[str, float] | None = None, + step_model: StepModel | 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`. + `step_model` advances all zones together (electrical heat, ventilation, + neighbours); by default each zone uses its learner's base model. """ if not temps or set(temps) != set(learners): raise ValueError("Missing room temperatures or learner") @@ -214,6 +220,12 @@ def optimize( mean_target = sum(norm[room] * targets[room] for room in temps) blocks = horizon_minutes // 15 nsteps_per_block = 3 + + def advance(state: dict[str, float], lag: float, bit: bool, k: int) -> tuple[dict[str, float], float]: + if step_model is None: + return simulate_step(state, outside, lag, bit, learners) + next_lag = lag + HEAT_LAG * (float(bit) - lag) + return step_model(state, next_lag, k), next_lag best = float("inf") result = None min_on_s = minimum_on_minutes * 60 @@ -238,7 +250,9 @@ def optimize( elapsed_s = 0.0 current = bit for tick in range(nsteps_per_block): - state, lag = simulate_step(state, outside, lag, bit, learners) + state, lag = advance(state, lag, bit, idx * nsteps_per_block + tick) + if not all(isfinite(v) for v in state.values()): + raise ValueError("Non-finite simulated temperature") mismatch = sum( norm[room] * max(0.0, abs(value - targets[room]) - comfort_band) ** 2 for room, value in state.items() @@ -259,9 +273,9 @@ def optimize( # Re-simulate the chosen plan for its trajectory, keeping the search loop lean. trajectory: dict[str, list[float]] = {room: [] for room in temps} state, lag = dict(temps), effective_heat - for bit in plan: - for _ in range(nsteps_per_block): - state, lag = simulate_step(state, outside, lag, bit, learners) + for idx, bit in enumerate(plan): + for tick in range(nsteps_per_block): + state, lag = advance(state, lag, bit, idx * nsteps_per_block + tick) for room, value in state.items(): trajectory[room].append(round(value, 3)) return { diff --git a/custom_components/heating_mpc/strings.json b/custom_components/heating_mpc/strings.json index 585dd34..0d48f55 100644 --- a/custom_components/heating_mpc/strings.json +++ b/custom_components/heating_mpc/strings.json @@ -67,10 +67,12 @@ "comfort_band": "Comfort tolerance (°C)", "room_max_age_minutes": "Maximum room sensor report age (minutes)", "weather_max_age_minutes": "Maximum weather sensor report age (minutes)", - "room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting" + "room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting", + "internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)" }, "data_description": { - "room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable." + "room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.", + "internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now." } }, "weather": { @@ -171,6 +173,77 @@ "abort": { "reconfigure_successful": "The zone was updated." } + }, + "heat_source": { + "entry_type": "Heat source", + "initiate_flow": { + "user": "Add heat source", + "reconfigure": "Edit heat source" + }, + "step": { + "user": { + "title": "Electrical heat source", + "description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.", + "data": { + "name": "Name", + "sensor": "Active power sensor", + "zone": "Zone", + "heat_fraction": "Share of power that becomes heat in the zone", + "baseline_mode": "Baseline", + "baseline_w": "Manual baseline (W)", + "included_in": "Already included in the measurement of", + "response_minutes": "Thermal response time (minutes)", + "enabled": "Enabled" + }, + "data_description": { + "heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.", + "baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).", + "included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.", + "response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm." + } + }, + "reconfigure": { + "title": "Electrical heat source", + "description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.", + "data": { + "name": "Name", + "sensor": "Active power sensor", + "zone": "Zone", + "heat_fraction": "Share of power that becomes heat in the zone", + "baseline_mode": "Baseline", + "baseline_w": "Manual baseline (W)", + "included_in": "Already included in the measurement of", + "response_minutes": "Thermal response time (minutes)", + "enabled": "Enabled" + }, + "data_description": { + "heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.", + "baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).", + "included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.", + "response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm." + } + } + }, + "error": { + "source_name_required": "Enter a name.", + "sensor_in_other_source": "This power sensor is already used by another heat source.", + "invalid_parent_source": "The parent must be an existing source that is not itself included in another.", + "source_has_children": "Other sources are included in this one, so it cannot be included in another.", + "baseline_required": "Enter the manual baseline in W.", + "too_many_sources": "At most 16 heat sources are supported." + }, + "abort": { + "reconfigure_successful": "The heat source was updated.", + "no_zones": "Add a zone first." + } + } + }, + "selector": { + "baseline_mode": { + "options": { + "auto": "Automatic (24-hour low)", + "manual": "Manual value" + } } } } diff --git a/custom_components/heating_mpc/translations/en.json b/custom_components/heating_mpc/translations/en.json index 585dd34..0d48f55 100644 --- a/custom_components/heating_mpc/translations/en.json +++ b/custom_components/heating_mpc/translations/en.json @@ -67,10 +67,12 @@ "comfort_band": "Comfort tolerance (°C)", "room_max_age_minutes": "Maximum room sensor report age (minutes)", "weather_max_age_minutes": "Maximum weather sensor report age (minutes)", - "room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting" + "room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting", + "internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)" }, "data_description": { - "room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable." + "room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.", + "internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now." } }, "weather": { @@ -171,6 +173,77 @@ "abort": { "reconfigure_successful": "The zone was updated." } + }, + "heat_source": { + "entry_type": "Heat source", + "initiate_flow": { + "user": "Add heat source", + "reconfigure": "Edit heat source" + }, + "step": { + "user": { + "title": "Electrical heat source", + "description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.", + "data": { + "name": "Name", + "sensor": "Active power sensor", + "zone": "Zone", + "heat_fraction": "Share of power that becomes heat in the zone", + "baseline_mode": "Baseline", + "baseline_w": "Manual baseline (W)", + "included_in": "Already included in the measurement of", + "response_minutes": "Thermal response time (minutes)", + "enabled": "Enabled" + }, + "data_description": { + "heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.", + "baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).", + "included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.", + "response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm." + } + }, + "reconfigure": { + "title": "Electrical heat source", + "description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.", + "data": { + "name": "Name", + "sensor": "Active power sensor", + "zone": "Zone", + "heat_fraction": "Share of power that becomes heat in the zone", + "baseline_mode": "Baseline", + "baseline_w": "Manual baseline (W)", + "included_in": "Already included in the measurement of", + "response_minutes": "Thermal response time (minutes)", + "enabled": "Enabled" + }, + "data_description": { + "heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.", + "baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).", + "included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.", + "response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm." + } + } + }, + "error": { + "source_name_required": "Enter a name.", + "sensor_in_other_source": "This power sensor is already used by another heat source.", + "invalid_parent_source": "The parent must be an existing source that is not itself included in another.", + "source_has_children": "Other sources are included in this one, so it cannot be included in another.", + "baseline_required": "Enter the manual baseline in W.", + "too_many_sources": "At most 16 heat sources are supported." + }, + "abort": { + "reconfigure_successful": "The heat source was updated.", + "no_zones": "Add a zone first." + } + } + }, + "selector": { + "baseline_mode": { + "options": { + "auto": "Automatic (24-hour low)", + "manual": "Manual value" + } } } } diff --git a/custom_components/heating_mpc/translations/ro.json b/custom_components/heating_mpc/translations/ro.json index aca60aa..707605c 100644 --- a/custom_components/heating_mpc/translations/ro.json +++ b/custom_components/heating_mpc/translations/ro.json @@ -67,10 +67,12 @@ "comfort_band": "Toleranță confort (°C)", "room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)", "weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)", - "room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează" + "room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează", + "internal_heat_half_life_minutes": "Căldură electrică suplimentară: timp de înjumătățire în prognoză (minute)" }, "data_description": { - "room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită." + "room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită.", + "internal_heat_half_life_minutes": "Prognozele presupun că puterea peste consumul de bază scade cu acest timp de înjumătățire, de exemplu la finalul unei sesiuni de jocuri. 0 presupune că se oprește acum." } }, "weather": { @@ -171,6 +173,77 @@ "abort": { "reconfigure_successful": "Zona a fost actualizată." } + }, + "heat_source": { + "entry_type": "Sursă de căldură", + "initiate_flow": { + "user": "Adaugă sursă de căldură", + "reconfigure": "Modifică sursa de căldură" + }, + "step": { + "user": { + "title": "Sursă electrică de căldură", + "description": "Echipamente a căror putere electrică ajunge căldură într-o zonă, de exemplu un UPS cu calculatoare. Se modelează doar variația peste un consum de bază persistent: partea constantă face deja parte din comportamentul învățat al zonei. Folosește putere activă (W sau kW), nu VA sau procent de încărcare.", + "data": { + "name": "Nume", + "sensor": "Senzor de putere activă", + "zone": "Zonă", + "heat_fraction": "Partea din putere care devine căldură în zonă", + "baseline_mode": "Consum de bază", + "baseline_w": "Consum de bază manual (W)", + "included_in": "Inclusă deja în măsurătoarea pentru", + "response_minutes": "Timp de răspuns termic (minute)", + "enabled": "Activă" + }, + "data_description": { + "heat_fraction": "1,0 când echipamentul este în această zonă. Micșorează valoarea dacă o parte din căldură ajunge în altă parte, de exemplu pe o ventilație.", + "baseline_mode": "Automat folosește partea de jos a ultimelor 24 de ore (consumul persistent).", + "included_in": "Alege UPS-ul sau contorul a cărui citire conține deja acest dispozitiv, ca puterea să nu fie numărată de două ori.", + "response_minutes": "Cât de repede simte zona o schimbare de putere: aerul, mobila și echipamentele au nevoie de timp să se încălzească." + } + }, + "reconfigure": { + "title": "Sursă electrică de căldură", + "description": "Echipamente a căror putere electrică ajunge căldură într-o zonă, de exemplu un UPS cu calculatoare. Se modelează doar variația peste un consum de bază persistent: partea constantă face deja parte din comportamentul învățat al zonei. Folosește putere activă (W sau kW), nu VA sau procent de încărcare.", + "data": { + "name": "Nume", + "sensor": "Senzor de putere activă", + "zone": "Zonă", + "heat_fraction": "Partea din putere care devine căldură în zonă", + "baseline_mode": "Consum de bază", + "baseline_w": "Consum de bază manual (W)", + "included_in": "Inclusă deja în măsurătoarea pentru", + "response_minutes": "Timp de răspuns termic (minute)", + "enabled": "Activă" + }, + "data_description": { + "heat_fraction": "1,0 când echipamentul este în această zonă. Micșorează valoarea dacă o parte din căldură ajunge în altă parte, de exemplu pe o ventilație.", + "baseline_mode": "Automat folosește partea de jos a ultimelor 24 de ore (consumul persistent).", + "included_in": "Alege UPS-ul sau contorul a cărui citire conține deja acest dispozitiv, ca puterea să nu fie numărată de două ori.", + "response_minutes": "Cât de repede simte zona o schimbare de putere: aerul, mobila și echipamentele au nevoie de timp să se încălzească." + } + } + }, + "error": { + "source_name_required": "Introdu un nume.", + "sensor_in_other_source": "Acest senzor de putere este folosit deja de altă sursă.", + "invalid_parent_source": "Sursa părinte trebuie să existe și să nu fie ea însăși inclusă în alta.", + "source_has_children": "Alte surse sunt incluse în aceasta, deci nu poate fi inclusă în alta.", + "baseline_required": "Introdu consumul de bază manual în W.", + "too_many_sources": "Sunt permise cel mult 16 surse de căldură." + }, + "abort": { + "reconfigure_successful": "Sursa de căldură a fost actualizată.", + "no_zones": "Adaugă mai întâi o zonă." + } + } + }, + "selector": { + "baseline_mode": { + "options": { + "auto": "Automat (minimul ultimelor 24 de ore)", + "manual": "Valoare manuală" + } } } } diff --git a/custom_components/heating_mpc/zone_model.py b/custom_components/heating_mpc/zone_model.py new file mode 100644 index 0000000..493a3d3 --- /dev/null +++ b/custom_components/heating_mpc/zone_model.py @@ -0,0 +1,93 @@ +"""Per-zone thermal model: base model plus optional extended features. + +Pure Python; no Home Assistant imports. Per 5-minute step, for zone i: + + dT_i = loss x (T_out - T_i) + heat x lag + bias (base, always learned) + + internal x q_i (electrical heat above baseline, kW) + + sum_j couple:j x (T_j - T_i) (adjacent zones) + +The base model is the 3-parameter model of earlier versions, kept as fallback +and as the comparison that shows whether the extra features earn their keep. +The extended model learns the same terms plus the configured extras, only from +samples where all of its inputs are known. +""" +from __future__ import annotations + +from typing import Any, Mapping + +from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES +from .learning import FeatureLearner +from .physics import Learner + +BASE_FEATURES = ["loss", "heat", "bias"] + + +def ready(model: Learner | FeatureLearner) -> bool: + return (model.count >= READY_SAMPLES and model.on_count >= READY_ON_SAMPLES + and model.off_count >= READY_OFF_SAMPLES) + + +def extended_features(has_internal: bool, neighbors: tuple[str, ...]) -> list[str]: + """Features of a zone's extended model; empty when it would equal the base.""" + extra = (["internal"] if has_internal else []) + [f"couple:{n}" for n in neighbors] + return BASE_FEATURES + extra if extra else [] + + +def feature_vector(features: list[str], temp: float, inputs: Mapping[str, Any]) -> list[float] | None: + """Values of `features` at the start of a step; None if an input is unknown. + + `inputs`: outside, lag, internal (kW, may be None) and temps (all zones). + """ + values = [] + for name in features: + if name == "loss": + values.append(inputs["outside"] - temp) + elif name == "heat": + values.append(inputs["lag"]) + elif name == "bias": + values.append(1.0) + elif name == "internal": + if inputs.get("internal") is None: + return None + values.append(inputs["internal"]) + elif name.startswith("couple:"): + neighbor = inputs.get("temps", {}).get(name.split(":", 1)[1]) + if neighbor is None: + return None + values.append(neighbor - temp) + else: + raise KeyError(name) + return values + + +class ZoneModel: + def __init__(self, base: Learner | None = None, extended: FeatureLearner | None = None) -> None: + self.base = base or Learner() + self.extended = extended + + @property + def uses_extended(self) -> bool: + """Forecast with the extended model once it is as trained as the base needs to be.""" + return self.extended is not None and ready(self.extended) + + def delta(self, temp: float, inputs: Mapping[str, Any]) -> float: + """Predicted 5-minute change. Unknown extended inputs fall back to the base model.""" + if self.uses_extended: + x = feature_vector(self.extended.features, temp, inputs) + if x is not None: + return self.extended.predict(x) + return self.base.predict_delta(temp, inputs["outside"], inputs["lag"]) + + def contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]: + """Each term's share of the predicted change, in °C per hour.""" + if self.uses_extended: + x = feature_vector(self.extended.features, temp, inputs) + if x is not None: + terms: dict[str, float] = {} + for name, theta, value in zip(self.extended.features, self.extended.theta, x): + group = "neighbors" if name.startswith("couple:") else name + terms[group] = terms.get(group, 0.0) + 12 * theta * value + return {name: round(value, 3) for name, value in terms.items()} + a, b, c = self.base.theta + return {"loss": round(12 * a * (inputs["outside"] - temp), 3), + "heat": round(12 * b * inputs["lag"], 3), "bias": round(12 * c, 3)} diff --git a/tests/ha/test_config_flow.py b/tests/ha/test_config_flow.py index 7c74b40..39d8559 100644 --- a/tests/ha/test_config_flow.py +++ b/tests/ha/test_config_flow.py @@ -178,3 +178,34 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs): config = hass.data[DOMAIN][entry.entry_id].config assert sources_for(config, "wind") == [] assert sources_for(config, "pressure") == ["sensor.pressure"] + + +async def test_heat_source_subentry_flow(hass, entry, inputs): + await setup_entry(hass, entry) + for sensor, value in (("sensor.ups_power", "600"), ("sensor.pc_power", "400")): + hass.states.async_set(sensor, value, {"unit_of_measurement": "W", "device_class": "power"}) + flows = hass.config_entries.subentries + base = {"zone": "sensor.room", "heat_fraction": 1, "response_minutes": 15, "enabled": True} + result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER}) + result = await flows.async_configure(result["flow_id"], { + **base, "name": "UPS", "sensor": "sensor.ups_power", "baseline_mode": "auto"}) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + ups = next(sub for sub in entry.subentries.values() if sub.subentry_type == "heat_source") + # The PC is behind the UPS: its power is already in the UPS reading. + result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER}) + pc = {**base, "name": "Gaming PC", "sensor": "sensor.pc_power", "baseline_mode": "manual", + "included_in": ups.unique_id, "response_minutes": 10} + result = await flows.async_configure(result["flow_id"], pc) + assert result["errors"] == {"base": "baseline_required"} + result = await flows.async_configure(result["flow_id"], {**pc, "baseline_w": 0}) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][entry.entry_id] + assert [(s.name, s.included_in) for s in coordinator.sources] == [("UPS", None), ("Gaming PC", ups.unique_id)] + assert coordinator.extended["sensor.room"].features == ["loss", "heat", "bias", "internal"] + registry = er.async_get(hass) + status = hass.states.get(registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")) + sources = status.attributes["heat_sources"] + assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice + assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index e847e68..fcf5ff0 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -735,3 +735,91 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): 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}) + + def office_with_ups(self): + self.entry.subentries = { + 'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={ + 'key': 'office', 'name': 'Office', 'sensors': ['sensor.room']}), + 'h': types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={ + 'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office', + 'response_minutes': 15})} + self.set_state('sensor.ups_power', '200', unit_of_measurement='W') + self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry) + + async def test_gaming_heat_identified_separately_from_boiler(self): + """Synthetic office: UPS 200 W idle, +400 W gaming sessions, thermostat-cycled boiler.""" + self.office_with_ups() + a, b, c, d = 0.004, 0.12, 0.0005, 0.15 + advance = coordinator.advance_lag + alpha = 1 - exp(-300 / 900) + self.states['climate.reference'].state = 'off' # learn only + temp, lag, on, response = 21.0, 0.0, False, 0.0 + for k in range(2000): + outside = 5 + 5 * sin(2 * pi * k / 288) + gaming = (k % 120) >= 96 # two hours of every ten + power = 600.0 if gaming else 200.0 + for entity, value in (('sensor.room', temp), ('sensor.outside', outside), ('sensor.ups_power', power)): + self.states[entity].state = f'{value:.5f}' + self.states[entity].last_reported = self.now + data = await self.sample() + command = temp < 20.7 or (on and temp < 21.3) + if command != on: + self.switch('on' if command else 'off', 180) + lag = advance(advance(lag, on, 120), command, 180) + else: + lag = advance(lag, on, 300) + on = command + response += alpha * ((power - 200) / 1000 - response) # this interval's held power + temp += a * (outside - temp) + b * lag + c + d * response + model = self.c.extended['office'] + for name, value in (('internal', d), ('heat', b), ('loss', a)): + with self.subTest(coefficient=name): + self.assertAlmostEqual(model.coefficient(name), value, delta=0.1 * value) + info = data['models']['office']['extended']['coefficients']['internal'] + self.assertTrue(info['identified']) + source = data['heat_sources']['UPS'] + self.assertEqual((source['baseline_w'], source['zone'], source['settled']), (200.0, 'Office', True)) + + async def test_unknown_power_pauses_extended_learning_only(self): + self.office_with_ups() + learned = [] + for k in range(30): + self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power') + self.states['sensor.ups_power'].state = 'unavailable' if k == 20 else '200' + before = self.c.extended['office'].count + data = await self.sample() + learned.append(self.c.extended['office'].count > before) + if k == 20: + self.assertEqual(data['heat_sources']['UPS']['status'], 'unavailable') + # Baseline after 12 polls (k = 11), then 2 time constants (6 polls) to settle. + # The interval ending at the outage is known (power held from k = 19); the + # next one is not, and the response must settle again. + self.assertEqual([k for k, ok in enumerate(learned) if ok], [17, 18, 19, 20, 27, 28, 29]) + self.assertEqual(self.c.learners['office'].count, 29) # the base model never pauses + + async def test_forecast_uses_extended_model_with_decaying_gaming_heat(self): + self.office_with_ups() + self.states['climate.reference'].state = 'off' + for k in range(20): # an hour idle (baseline), then gaming until the response settles + self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power') + self.states['sensor.ups_power'].state = '600' if k >= 12 else '200' + await self.sample() + base, extended = self.c.learners['office'], self.c.extended['office'] + for model in (base, extended): + model.count, model.on_count, model.off_count = 200, 100, 100 + extended.theta[extended.features.index('internal')] = 0.15 + self.states['climate.reference'].state = 'heat' + self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power') + with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy: + data = await self.sample() + self.assertEqual((data['status'], data['models']['office']['model_in_use']), ('ready', 'extended')) + step = spy.call_args.kwargs['step_model'] + now_heat = step({'office': 20.0}, 0.0, 0)['office'] + later_heat = step({'office': 20.0}, 0.0, 23)['office'] + self.assertGreater(now_heat - later_heat, 0.03) # the session is assumed to wind down + # With only base models trained, the optimizer keeps its default step. + extended.count = 10 + self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power') + with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy: + await self.sample() + self.assertIsNone(spy.call_args.kwargs['step_model']) diff --git a/tests/test_heat_sources.py b/tests/test_heat_sources.py new file mode 100644 index 0000000..f88380a --- /dev/null +++ b/tests/test_heat_sources.py @@ -0,0 +1,138 @@ +"""Electrical heat sources: units, overlap, baseline, thermal response, forecasts.""" +import sys +import types +import unittest +from math import exp +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 heat_sources as hs # noqa: E402 + +UPS = {'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office'} +PC = {'key': 'pc', 'name': 'Gaming PC', 'sensor': 'sensor.pc_power', 'zone': 'office', 'included_in': 'ups'} + + +def sources(*items, zones=('office', 'living')): + return hs.build_sources(((item, None) for item in items), zones) + + +def run(tracker, powers, start=0.0, step=300.0): + """Feed one reading per poll; return the tracker states after each poll.""" + states = [] + for k, readings in enumerate(powers): + tracker.update(start + k * step, readings) + states.append({key: dict(info) for key, info in tracker.state.items()}) + return states + + +class PowerTests(unittest.TestCase): + def test_units_are_normalized_and_non_heat_units_rejected(self): + self.assertEqual(hs.normalize_power('600', 'W'), 600) + self.assertEqual(hs.normalize_power(0.6, ' kW '), 600) + self.assertEqual(hs.normalize_power(600000, 'mW'), 600) + for raw, unit in (('600', 'VA'), ('35', '%'), ('600', None), ('600', ''), ('-5', 'W'), + ('nan', 'W'), ('abc', 'W'), ('30000', 'W'), ('600', 7)): + with self.subTest(raw=raw, unit=unit): + self.assertIsNone(hs.normalize_power(raw, unit)) + + def test_build_sources_validates_zones_duplicates_and_inclusion(self): + built = sources(UPS, PC, {**UPS, 'key': 'ups2'}, {**PC, 'key': 'pc2', 'sensor': 'sensor.x', 'zone': 'gone'}, + {'key': 'nas', 'sensor': 'sensor.nas', 'zone': 'office', 'included_in': 'pc'}, + {'key': 'tv', 'sensor': 'sensor.tv', 'zone': 'living', 'included_in': 'missing'}) + self.assertEqual([(s.key, s.included_in) for s in built], [('ups', None), ('pc', 'ups'), ('nas', None), + ('tv', None)]) + + def test_included_child_is_not_double_counted(self): + built = sources(UPS, PC) + self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': 400}), {'ups': 200, 'pc': 400}) + # Without the child reading the split is unknown; timing noise is clamped. + self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': None}), {'ups': None, 'pc': None}) + self.assertEqual(hs.effective_power(built, {'ups': 380, 'pc': 400})['ups'], 0) + # A disabled child stays part of its parent's measurement. + built = sources(UPS, {**PC, 'enabled': False}) + self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': None}), {'ups': 600}) + + def test_baseline_is_the_persistent_load_not_the_mean(self): + estimator = hs.BaselineEstimator() + for _ in range(11): + estimator.add(200) + self.assertIsNone(estimator.value()) # less than an hour of data + for k in range(288): + estimator.add(600 if k % 10 < 3 else 200) # gaming 30 % of the time + self.assertEqual(estimator.value(), 200) + self.assertEqual(len(estimator.samples), 288) + self.assertEqual(hs.BaselineEstimator([200, 'x', -5, float('nan'), 300]).samples, hs.deque([200.0, 300.0])) + + def test_response_and_forecasts(self): + filt = hs.ResponseFilter(15) + self.assertEqual(filt.advance(0.0, 0), 0.0) + self.assertAlmostEqual(filt.advance(0.4, 900), 0.4 * (1 - exp(-1))) + self.assertEqual(hs.forecast_internal(0.4, 3, None), [0.4] * 3) + self.assertEqual(hs.forecast_internal(0.4, 2, 0), [0.0, 0.0]) + decay = hs.forecast_internal(0.4, 12, 30) + self.assertAlmostEqual(decay[5], 0.2) # one half-life after 30 minutes + response = hs.forecast_response(0.0, 0.4, 24, 15, 30) + self.assertLess(max(response), 0.4) # the PC is not assumed to run on at full power + self.assertLess(response[-1], max(response)) + + +class TrackerTests(unittest.TestCase): + def test_gaming_session_drives_zone_input_with_lag_and_settling(self): + tracker = hs.HeatSourceTracker(sources(UPS)) + idle = [{'ups': 200.0}] * 12 + states = run(tracker, idle + [{'ups': 600.0}] * 6 + idle[:6]) + self.assertIsNone(states[10]['ups']['variable_kw']) # baseline needs an hour of data + self.assertEqual(states[11]['ups']['baseline_w'], 200) + # The poll that sees 600 W has not yet had any extra heat: power is held over the interval. + self.assertEqual((states[12]['ups']['variable_kw'], states[12]['ups']['response_kw']), (0.4, 0.0)) + self.assertAlmostEqual(states[13]['ups']['response_kw'], 0.4 * (1 - exp(-1 / 3))) + self.assertGreater(states[18]['ups']['response_kw'], states[19]['ups']['response_kw']) # PC off: decays + self.assertGreater(states[19]['ups']['response_kw'], 0.1) # but the room still holds the heat + self.assertEqual(tracker.zone_input('office'), states[-1]['ups']['response_kw']) + self.assertEqual(tracker.zone_input('living'), 0.0) + + def test_unknown_power_resets_until_settled_again(self): + tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}}) + run(tracker, [{'ups': 200.0}] * 7) + self.assertEqual(tracker.zone_input('office'), 0.0) # saved baseline: usable immediately + tracker.update(7 * 300, {'ups': 200.0}) + tracker.update(8 * 300, {'ups': None}) + self.assertFalse(tracker.state['ups']['settled'] and tracker.state['ups']['response_kw'] is None) + tracker.update(9 * 300, {'ups': 200.0}) + self.assertIsNone(tracker.zone_input('office')) # restarted: 2 time constants to settle + run(tracker, [{'ups': 200.0}] * 6, start=10 * 300) + self.assertEqual(tracker.zone_input('office'), 0.0) + + def test_manual_baseline_fraction_and_round_trip(self): + built = sources({**UPS, 'baseline_mode': 'manual', 'baseline_w': 150, 'heat_fraction': 0.5}) + tracker = hs.HeatSourceTracker(built) + tracker.update(0, {'ups': 350.0}) + self.assertEqual(tracker.state['ups']['variable_kw'], 0.1) + restored = hs.HeatSourceTracker(built, tracker.as_dict()) + self.assertEqual(list(restored._baselines['ups'].samples), [350.0]) + + def test_form_validation(self): + others = sources(UPS) + cases = { + 'source_name_required': {'name': ' '}, + 'sensor_in_other_source': {'name': 'PC', 'sensor': 'sensor.ups_power'}, + 'invalid_parent_source': {'name': 'NAS', 'sensor': 'sensor.nas', 'included_in': 'missing'}, + 'baseline_required': {'name': 'PC', 'sensor': 'sensor.pc', 'baseline_mode': 'manual'}, + None: {'name': 'PC', 'sensor': 'sensor.pc', 'included_in': 'ups'}, + } + for expected, data in cases.items(): + with self.subTest(expected=expected): + self.assertEqual(hs.validate_source(data, others), expected) + nested = sources(UPS, PC) + self.assertEqual(hs.validate_source({'name': 'NAS', 'sensor': 'sensor.nas', 'included_in': 'pc'}, nested), + 'invalid_parent_source') + self.assertEqual(hs.validate_source({'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', + 'included_in': 'other'}, [s for s in nested if s.key != 'ups'] + + sources({'key': 'other', 'sensor': 'sensor.o', 'zone': 'office'})), + 'source_has_children') + + +if __name__ == '__main__': + unittest.main()