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
@@ -17,15 +17,18 @@ from .const import (
|
||||
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES,
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES,
|
||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
|
||||
CONF_BAND, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
||||
CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
||||
)
|
||||
from .data_quality import ReportAgeHistory, freshness
|
||||
from .evaluation import ForecastEvaluator
|
||||
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
|
||||
from .learning import FeatureLearner
|
||||
from .physics import (
|
||||
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
|
||||
lag_from_time_in_state, optimize, relay_segments,
|
||||
)
|
||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
||||
from .zone_model import ZoneModel, extended_features, feature_vector
|
||||
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -46,6 +49,12 @@ def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
|
||||
return build_zones(items)
|
||||
|
||||
|
||||
def entry_source_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[HeatSource]:
|
||||
subentries = getattr(entry, "subentries", None) or {}
|
||||
return build_sources(((sub.data, sub.subentry_id) for sub in subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones)
|
||||
|
||||
|
||||
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""The existing generic thermostat stays the only actuator owner."""
|
||||
|
||||
@@ -59,6 +68,14 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.rooms: list[str] = list(self.zones)
|
||||
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
|
||||
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||
self.sources = entry_source_list(entry, self.zones)
|
||||
self._heat_sources = HeatSourceTracker(self.sources)
|
||||
# Extended model per zone: base terms plus electrical heat and adjacent zones.
|
||||
self._extended_features = {
|
||||
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors)
|
||||
for key, zone in self.zones.items()}
|
||||
self.extended: dict[str, FeatureLearner] = {
|
||||
key: FeatureLearner(features) for key, features in self._extended_features.items() if features}
|
||||
self._retired: dict[str, dict[str, Any]] = {}
|
||||
self._report_ages = ReportAgeHistory()
|
||||
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
|
||||
@@ -86,6 +103,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
||||
return
|
||||
self._evaluator.load(stored.get("evaluation"))
|
||||
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"))
|
||||
saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
|
||||
for key, features in self._extended_features.items():
|
||||
if features and key in saved_models:
|
||||
# Matched by feature name: a new source or neighbour retrains only its term.
|
||||
self.extended[key] = FeatureLearner.from_dict(saved_models[key], features)
|
||||
# Models carry room identity, so changing the room list is safe. Models of
|
||||
# rooms no longer configured are retained, most recently removed last.
|
||||
for key in ("retired_learners", "learners"): # current learners take precedence
|
||||
@@ -185,6 +208,51 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
+ min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3
|
||||
for m in self.learners.values()), 1)
|
||||
|
||||
def _extended_diagnostics(self, room: str) -> dict[str, Any]:
|
||||
model = self.extended.get(room)
|
||||
if model is None:
|
||||
return {"model_in_use": "base"}
|
||||
return {"model_in_use": "extended" if ZoneModel(self.learners[room], model).uses_extended else "base",
|
||||
"extended": {"samples": model.count, "on_samples": model.on_count,
|
||||
"off_samples": model.off_count,
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None,
|
||||
"coefficients": model.coefficients()}}
|
||||
|
||||
def _source_diagnostics(self, quality: dict[str, dict]) -> dict[str, Any]:
|
||||
names = {s.key: s.name for s in self.sources}
|
||||
result = {}
|
||||
for source in self.sources:
|
||||
info = self._heat_sources.state.get(source.key, {})
|
||||
|
||||
def watts(kw):
|
||||
return None if kw is None else round(kw * 1000, 1)
|
||||
result[source.name] = {
|
||||
"zone": self.zones[source.zone].name, "sensor": source.sensor,
|
||||
"status": quality[source.key]["status"], "enabled": source.enabled,
|
||||
"power_w": info.get("power_w"), "effective_w": info.get("effective_w"),
|
||||
"included_in": names.get(source.included_in),
|
||||
"baseline_w": info.get("baseline_w"), "baseline_mode": source.baseline_mode,
|
||||
"variable_w": watts(info.get("variable_kw")), "thermal_response_w": watts(info.get("response_kw")),
|
||||
"settled": info.get("settled", False), "heat_fraction": source.fraction,
|
||||
"response_minutes": source.response_minutes,
|
||||
}
|
||||
return result
|
||||
|
||||
def _step_model(self, temps: dict[str, float], outside: float, horizon: int):
|
||||
"""Multi-zone step for the optimizer, or None when only base models are in use."""
|
||||
models = {key: ZoneModel(self.learners[key], self.extended.get(key)) for key in temps}
|
||||
if not any(model.uses_extended for model in models.values()):
|
||||
return None
|
||||
steps = horizon // 5
|
||||
half_life = float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30))
|
||||
forecast = {key: self._heat_sources.zone_forecast(key, steps, half_life) for key in temps}
|
||||
|
||||
def step(state: dict[str, float], lag: float, k: int) -> dict[str, float]:
|
||||
return {key: value + models[key].delta(value, {
|
||||
"outside": outside, "lag": lag, "internal": forecast[key][min(k, steps - 1)], "temps": state})
|
||||
for key, value in state.items()}
|
||||
return step
|
||||
|
||||
def _prediction_inputs(self) -> tuple:
|
||||
"""Values the prediction depends on, used to detect changes during calculation."""
|
||||
heater = self.config[CONF_HEATER]
|
||||
@@ -278,6 +346,23 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"retired_zone_models": sorted(self._retired),
|
||||
"excluded_zones": [room for room in self.rooms if temps[room] is None],
|
||||
}
|
||||
# Electrical heat sources: processed every poll so their responses stay continuous.
|
||||
source_readings: dict[str, float | None] = {}
|
||||
source_quality = {}
|
||||
for source in self.sources:
|
||||
quality = self._sensor_quality(source.sensor, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
watts = None
|
||||
if quality["status"] == "valid":
|
||||
state = self.hass.states.get(source.sensor)
|
||||
watts = normalize_power(state.state, state.attributes.get("unit_of_measurement"))
|
||||
if watts is None:
|
||||
quality["status"] = "invalid_value_or_unit"
|
||||
source_readings[source.key] = watts
|
||||
source_quality[source.key] = quality
|
||||
self._heat_sources.update(now.timestamp(), source_readings)
|
||||
internal = {key: self._heat_sources.zone_input(key) for key in self.zones}
|
||||
if self.sources:
|
||||
status["heat_sources"] = self._source_diagnostics(source_quality)
|
||||
heater_valid = heater is not None and heater.state in ("on", "off")
|
||||
heater_on = heater_valid and heater.state == "on"
|
||||
steady = False
|
||||
@@ -331,6 +416,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
|
||||
self._update_counts()
|
||||
learned = True
|
||||
# Extended models learn the same interval when all their inputs are known.
|
||||
before = {"outside": self._previous["outside"], "lag": self._lag["lag"],
|
||||
"temps": self._previous["temps"]}
|
||||
for room in pairs:
|
||||
model = self.extended.get(room)
|
||||
x = model and feature_vector(model.features, self._previous["temps"][room],
|
||||
{**before, "internal": internal[room]})
|
||||
if x:
|
||||
model.learn(x, (vals[room] - self._previous["temps"][room]) * 300 / dt, heater_on)
|
||||
|
||||
self._count_result(status, status["sample_result"])
|
||||
self._previous = {
|
||||
@@ -348,6 +442,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||
# Optional keys; older 0.2.x readers ignore them and keep working.
|
||||
"retired_learners": self._retired,
|
||||
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
|
||||
"heat_sources": self._heat_sources.as_dict(),
|
||||
"evaluation": self._evaluator.as_dict(),
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
@@ -374,7 +470,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"samples": model.count,
|
||||
"on_samples": model.on_count, "off_samples": model.off_count,
|
||||
"rejected_samples": model.rejected_count,
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None}
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None,
|
||||
**self._extended_diagnostics(room)}
|
||||
for room, model in self.learners.items()
|
||||
},
|
||||
})
|
||||
@@ -414,6 +511,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
heating_cost=float(self.config[CONF_HEAT_COST]),
|
||||
comfort_band=float(self.config[CONF_BAND]),
|
||||
target_offsets={room: self.zones[room].target_offset for room in vals},
|
||||
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON])),
|
||||
)
|
||||
# Discard a result if a value used by the prediction changed while
|
||||
# saving or while the worker calculated the hypothetical plan. Optional
|
||||
|
||||
Reference in new issue
Block a user