Model electrical heat sources (UPS, gaming PC) per zone

Electrical equipment heats its room, and a gaming session can add
hundreds of watts, which the 3-parameter model attributed to the boiler
or the bias term.

heat_sources.py adds heat sources as config subentries ("Add heat
source"): an active-power sensor (W/kW; VA and % rejected), the zone,
the share that becomes heat, an automatic baseline (20th percentile of
24 h, so the persistent UPS load is not double counted with the bias
term) or a manual one, an optional "included in" parent (the UPS total
minus a separately measured PC, one level deep) and a first-order
thermal response time. The heat during each interval is the power held
from its start, like the radiator lag. After startup or an outage a
source's response must run two time constants before it counts as
settled.

zone_model.py layers each zone: the base model is kept unchanged as the
fallback and comparison, and an extended FeatureLearner adds "internal"
(kW above baseline after the response) and neighbour coupling for
adjacent zones. It learns only intervals where all its inputs are
known, and is used for forecasts once as trained as the base. The
optimizer accepts a multi-zone step model; forecasts let extra power
fade to baseline with a configurable half-life (default 30 min).

On a synthetic office with thermostat cycling and gaming sessions the
extended model recovers the electrical, boiler and loss coefficients
within 10 %. Status lists each source's power, effective power,
baseline, variable and responding heat; models show the extended
coefficients with confidence. Saved models gain optional zone_models
and heat_sources keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:39:18 +02:00
1 parent 46587af8a3
commit 00e70c612b
12 files changed
+1083 -15

No files matched your search

+82 -2
View File
@@ -13,9 +13,15 @@ from .const import (
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE,
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS, DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS, CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS,
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA, 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 .weather_sources import sources_for
from .zones import ( from .zones import (
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE, 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_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_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_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(): }.items():
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed
return vol.Schema(schema) return vol.Schema(schema)
@@ -112,6 +119,12 @@ def entry_zones(entry: config_entries.ConfigEntry) -> list[Zone]:
if sub.subentry_type == SUBENTRY_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: def _number(lo: float, hi: float, step: float) -> selector.NumberSelector:
return selector.NumberSelector(selector.NumberSelectorConfig( return selector.NumberSelector(selector.NumberSelectorConfig(
min=lo, max=hi, step=step, mode=selector.NumberSelectorMode.BOX)) min=lo, max=hi, step=step, mode=selector.NumberSelectorMode.BOX))
@@ -178,7 +191,7 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@classmethod @classmethod
@callback @callback
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry): 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): 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(), return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
title=data[ZONE_NAME], data=data) title=data[ZONE_NAME], data=data)
return self.async_create_entry(title=data[ZONE_NAME], data=data, unique_id=data[ZONE_KEY]) 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)
+3
View File
@@ -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. # True keeps using their last value for sensors that report only on change.
CONF_ROOM_STALE_KEEP = "room_stale_use_last_value" CONF_ROOM_STALE_KEEP = "room_stale_use_last_value"
CONF_BAND = "comfort_band" 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. # Each source list is ordered: first valid sensor wins; remaining are fallbacks.
WEATHER_SOURCES = { WEATHER_SOURCES = {
@@ -42,6 +44,7 @@ DEFAULTS = {
CONF_SWITCH_COST: 0.15, CONF_SWITCH_COST: 0.15,
CONF_HEAT_COST: 0.025, CONF_HEAT_COST: 0.025,
CONF_BAND: 0.15, CONF_BAND: 0.15,
CONF_INTERNAL_HALF_LIFE: 30,
} }
# A room sensor silent this long is treated as dead even when keeping last values. # A room sensor silent this long is treated as dead even when keeping last values.
ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60 ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60
+100 -2
View File
@@ -17,15 +17,18 @@ from .const import (
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES, 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, 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_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 .data_quality import ReportAgeHistory, freshness
from .evaluation import ForecastEvaluator from .evaluation import ForecastEvaluator
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
from .learning import FeatureLearner
from .physics import ( from .physics import (
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag, LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
lag_from_time_in_state, optimize, relay_segments, lag_from_time_in_state, optimize, relay_segments,
) )
from .weather_sources import choose_weather_sources, sources_for, normalize from .weather_sources import choose_weather_sources, sources_for, normalize
from .zone_model import ZoneModel, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -46,6 +49,12 @@ def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
return build_zones(items) 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]]): class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""The existing generic thermostat stays the only actuator owner.""" """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.rooms: list[str] = list(self.zones)
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors] self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self.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._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory() self._report_ages = ReportAgeHistory()
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY) 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") _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return return
self._evaluator.load(stored.get("evaluation")) 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 # Models carry room identity, so changing the room list is safe. Models of
# rooms no longer configured are retained, most recently removed last. # rooms no longer configured are retained, most recently removed last.
for key in ("retired_learners", "learners"): # current learners take precedence 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 + min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3
for m in self.learners.values()), 1) 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: def _prediction_inputs(self) -> tuple:
"""Values the prediction depends on, used to detect changes during calculation.""" """Values the prediction depends on, used to detect changes during calculation."""
heater = self.config[CONF_HEATER] heater = self.config[CONF_HEATER]
@@ -278,6 +346,23 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"retired_zone_models": sorted(self._retired), "retired_zone_models": sorted(self._retired),
"excluded_zones": [room for room in self.rooms if temps[room] is None], "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_valid = heater is not None and heater.state in ("on", "off")
heater_on = heater_valid and heater.state == "on" heater_on = heater_valid and heater.state == "on"
steady = False steady = False
@@ -331,6 +416,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
status["sample_result"] = "learned" if all(accepted) else "partly_rejected" status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts() self._update_counts()
learned = True 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._count_result(status, status["sample_result"])
self._previous = { self._previous = {
@@ -348,6 +442,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"learners": {room: learner.as_dict() for room, learner in self.learners.items()}, "learners": {room: learner.as_dict() for room, learner in self.learners.items()},
# Optional keys; older 0.2.x readers ignore them and keep working. # Optional keys; older 0.2.x readers ignore them and keep working.
"retired_learners": self._retired, "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(), "evaluation": self._evaluator.as_dict(),
"on_samples": self._on_samples, "off_samples": self._off_samples, "on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER], "heater_entity": self.config[CONF_HEATER],
@@ -374,7 +470,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"samples": model.count, "samples": model.count,
"on_samples": model.on_count, "off_samples": model.off_count, "on_samples": model.on_count, "off_samples": model.off_count,
"rejected_samples": model.rejected_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() 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]), heating_cost=float(self.config[CONF_HEAT_COST]),
comfort_band=float(self.config[CONF_BAND]), comfort_band=float(self.config[CONF_BAND]),
target_offsets={room: self.zones[room].target_offset for room in vals}, 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 # Discard a result if a value used by the prediction changed while
# saving or while the worker calculated the hypothetical plan. Optional # saving or while the worker calculated the hypothetical plan. Optional
@@ -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()}}
+19 -5
View File
@@ -8,7 +8,10 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from itertools import product from itertools import product
from math import exp, isfinite 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 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, minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
switching_cost: float = .15, heating_cost: float = .025, switching_cost: float = .15, heating_cost: float = .025,
comfort_band: float = .15, target_offsets: dict[str, float] | None = None, comfort_band: float = .15, target_offsets: dict[str, float] | None = None,
step_model: StepModel | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations. """Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand. 120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
`target_offsets` shifts individual zones' targets (°C) relative to `target`. `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): if not temps or set(temps) != set(learners):
raise ValueError("Missing room temperatures or learner") raise ValueError("Missing room temperatures or learner")
@@ -214,6 +220,12 @@ def optimize(
mean_target = sum(norm[room] * targets[room] for room in temps) mean_target = sum(norm[room] * targets[room] for room in temps)
blocks = horizon_minutes // 15 blocks = horizon_minutes // 15
nsteps_per_block = 3 nsteps_per_block = 3
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") best = float("inf")
result = None result = None
min_on_s = minimum_on_minutes * 60 min_on_s = minimum_on_minutes * 60
@@ -238,7 +250,9 @@ def optimize(
elapsed_s = 0.0 elapsed_s = 0.0
current = bit current = bit
for tick in range(nsteps_per_block): for tick in range(nsteps_per_block):
state, lag = simulate_step(state, outside, lag, bit, learners) state, lag = 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( mismatch = sum(
norm[room] * max(0.0, abs(value - targets[room]) - comfort_band) ** 2 norm[room] * max(0.0, abs(value - targets[room]) - comfort_band) ** 2
for room, value in state.items() for room, value in state.items()
@@ -259,9 +273,9 @@ def optimize(
# Re-simulate the chosen plan for its trajectory, keeping the search loop lean. # Re-simulate the chosen plan for its trajectory, keeping the search loop lean.
trajectory: dict[str, list[float]] = {room: [] for room in temps} trajectory: dict[str, list[float]] = {room: [] for room in temps}
state, lag = dict(temps), effective_heat state, lag = dict(temps), effective_heat
for bit in plan: for idx, bit in enumerate(plan):
for _ in range(nsteps_per_block): 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)
for room, value in state.items(): for room, value in state.items():
trajectory[room].append(round(value, 3)) trajectory[room].append(round(value, 3))
return { return {
+75 -2
View File
@@ -67,10 +67,12 @@
"comfort_band": "Comfort tolerance (°C)", "comfort_band": "Comfort tolerance (°C)",
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather 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": { "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": { "weather": {
@@ -171,6 +173,77 @@
"abort": { "abort": {
"reconfigure_successful": "The zone was updated." "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"
}
} }
} }
} }
@@ -67,10 +67,12 @@
"comfort_band": "Comfort tolerance (°C)", "comfort_band": "Comfort tolerance (°C)",
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "room_max_age_minutes": "Maximum room sensor report age (minutes)",
"weather_max_age_minutes": "Maximum weather 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": { "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": { "weather": {
@@ -171,6 +173,77 @@
"abort": { "abort": {
"reconfigure_successful": "The zone was updated." "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"
}
} }
} }
} }
@@ -67,10 +67,12 @@
"comfort_band": "Toleranță confort (°C)", "comfort_band": "Toleranță confort (°C)",
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)", "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)", "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": { "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": { "weather": {
@@ -171,6 +173,77 @@
"abort": { "abort": {
"reconfigure_successful": "Zona a fost actualizată." "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ă"
}
} }
} }
} }
@@ -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)}
+31
View File
@@ -178,3 +178,34 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs):
config = hass.data[DOMAIN][entry.entry_id].config config = hass.data[DOMAIN][entry.entry_id].config
assert sources_for(config, "wind") == [] assert sources_for(config, "wind") == []
assert sources_for(config, "pressure") == ["sensor.pressure"] 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)
+88
View File
@@ -735,3 +735,91 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data['status'], 'ready') self.assertEqual(data['status'], 'ready')
self.assertEqual(spy.call_args.args[4], {'zone_living': 2.0}) self.assertEqual(spy.call_args.args[4], {'zone_living': 2.0})
self.assertEqual(spy.call_args.kwargs['target_offsets'], {'zone_living': 0.5}) 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'])
+138
View File
@@ -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()