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,
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS,
|
||||
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
||||
)
|
||||
from .heat_sources import (
|
||||
BASELINE_AUTO, BASELINE_MANUAL, DEFAULT_RESPONSE_MINUTES, MAX_POWER_W, RESPONSE_MINUTES_RANGE,
|
||||
SOURCE_BASELINE_MODE, SOURCE_BASELINE_W, SOURCE_ENABLED, SOURCE_FRACTION, SOURCE_INCLUDED_IN,
|
||||
SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE,
|
||||
HeatSource, build_sources, validate_source,
|
||||
)
|
||||
from .weather_sources import sources_for
|
||||
from .zones import (
|
||||
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE,
|
||||
@@ -59,6 +65,7 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||
CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||
CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||
CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)),
|
||||
CONF_INTERNAL_HALF_LIFE: vol.All(vol.Coerce(int), vol.Range(min=0, max=720)),
|
||||
}.items():
|
||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed
|
||||
return vol.Schema(schema)
|
||||
@@ -112,6 +119,12 @@ def entry_zones(entry: config_entries.ConfigEntry) -> list[Zone]:
|
||||
if sub.subentry_type == SUBENTRY_ZONE)
|
||||
|
||||
|
||||
def entry_sources(entry: config_entries.ConfigEntry) -> list[HeatSource]:
|
||||
zones = {zone.key for zone in entry_zones(entry)}
|
||||
return build_sources(((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones)
|
||||
|
||||
|
||||
def _number(lo: float, hi: float, step: float) -> selector.NumberSelector:
|
||||
return selector.NumberSelector(selector.NumberSelectorConfig(
|
||||
min=lo, max=hi, step=step, mode=selector.NumberSelectorMode.BOX))
|
||||
@@ -178,7 +191,7 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry):
|
||||
return {SUBENTRY_ZONE: ZoneSubentryFlow}
|
||||
return {SUBENTRY_ZONE: ZoneSubentryFlow, SUBENTRY_HEAT_SOURCE: HeatSourceSubentryFlow}
|
||||
|
||||
|
||||
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
@@ -297,3 +310,70 @@ class ZoneSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||
title=data[ZONE_NAME], data=data)
|
||||
return self.async_create_entry(title=data[ZONE_NAME], data=data, unique_id=data[ZONE_KEY])
|
||||
|
||||
|
||||
class HeatSourceSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||
"""Add or edit an electrical heat source (UPS, PC, server) in a zone."""
|
||||
|
||||
def _others(self, key: str | None) -> list[HeatSource]:
|
||||
return [s for s in entry_sources(self._get_entry()) if s.key != key]
|
||||
|
||||
def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema:
|
||||
zones = entry_zones(self._get_entry())
|
||||
parents = [s for s in self._others(key) if s.included_in is None]
|
||||
schema: dict[Any, Any] = {
|
||||
vol.Required(SOURCE_NAME, default=data.get(SOURCE_NAME, "")): selector.TextSelector(),
|
||||
vol.Required(SOURCE_SENSOR, **({"default": data[SOURCE_SENSOR]} if data.get(SOURCE_SENSOR) else {})):
|
||||
selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor", device_class="power")),
|
||||
vol.Required(SOURCE_ZONE, **({"default": data[SOURCE_ZONE]} if data.get(SOURCE_ZONE) else {})):
|
||||
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])),
|
||||
vol.Required(SOURCE_FRACTION, default=data.get(SOURCE_FRACTION, 1.0)): _number(0.0, 1.0, 0.05),
|
||||
vol.Required(SOURCE_BASELINE_MODE, default=data.get(SOURCE_BASELINE_MODE, BASELINE_AUTO)):
|
||||
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||
options=[BASELINE_AUTO, BASELINE_MANUAL], translation_key=SOURCE_BASELINE_MODE)),
|
||||
vol.Optional(SOURCE_BASELINE_W, description={"suggested_value": data.get(SOURCE_BASELINE_W)}):
|
||||
_number(0.0, MAX_POWER_W, 1.0),
|
||||
vol.Required(SOURCE_RESPONSE, default=data.get(SOURCE_RESPONSE, DEFAULT_RESPONSE_MINUTES)):
|
||||
_number(*RESPONSE_MINUTES_RANGE, 1.0),
|
||||
vol.Required(SOURCE_ENABLED, default=data.get(SOURCE_ENABLED, True)): selector.BooleanSelector(),
|
||||
}
|
||||
if parents:
|
||||
schema[vol.Optional(SOURCE_INCLUDED_IN, description={
|
||||
"suggested_value": data.get(SOURCE_INCLUDED_IN)})] = selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(options=[
|
||||
selector.SelectOptionDict(value=s.key, label=s.name) for s in parents]))
|
||||
return vol.Schema(schema)
|
||||
|
||||
async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]):
|
||||
key = existing[SOURCE_KEY]
|
||||
if not entry_zones(self._get_entry()):
|
||||
return self.async_abort(reason="no_zones")
|
||||
if user_input is not None:
|
||||
data = {**existing, **user_input, SOURCE_KEY: key}
|
||||
data.setdefault(SOURCE_INCLUDED_IN, None)
|
||||
error = validate_source(data, self._others(key))
|
||||
if error:
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(data, key),
|
||||
errors={"base": error})
|
||||
data[SOURCE_NAME] = data[SOURCE_NAME].strip()
|
||||
for number in (SOURCE_FRACTION, SOURCE_RESPONSE):
|
||||
data[number] = float(data[number])
|
||||
if data.get(SOURCE_BASELINE_W) is not None:
|
||||
data[SOURCE_BASELINE_W] = float(data[SOURCE_BASELINE_W])
|
||||
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||
title=data[SOURCE_NAME], data=data)
|
||||
return self.async_create_entry(title=data[SOURCE_NAME], data=data, unique_id=key)
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing, key))
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||
if not hasattr(self, "_key"):
|
||||
self._key = f"source_{uuid4().hex[:10]}"
|
||||
return await self._form("user", user_input, {SOURCE_KEY: self._key})
|
||||
|
||||
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||
subentry = self._get_reconfigure_subentry()
|
||||
existing = dict(subentry.data)
|
||||
existing.setdefault(SOURCE_KEY, subentry.subentry_id)
|
||||
return await self._form("reconfigure", user_input, existing)
|
||||
Reference in new issue
Block a user