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
@@ -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()}}
|
||||
Reference in new issue
Block a user