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:
1 parent
46587af8a3
commit
00e70c612b
12 files changed
+1083
-15
No files matched your search
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -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)
|
||||||
@@ -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'])
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user