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

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

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

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

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

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

No files matched your search

+19 -5
View File
@@ -8,7 +8,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
from itertools import product
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
@@ -180,11 +183,14 @@ def optimize(
minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
switching_cost: float = .15, heating_cost: float = .025,
comfort_band: float = .15, target_offsets: dict[str, float] | None = None,
step_model: StepModel | None = None,
) -> dict[str, Any]:
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
`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):
raise ValueError("Missing room temperatures or learner")
@@ -214,6 +220,12 @@ def optimize(
mean_target = sum(norm[room] * targets[room] for room in temps)
blocks = horizon_minutes // 15
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")
result = None
min_on_s = minimum_on_minutes * 60
@@ -238,7 +250,9 @@ def optimize(
elapsed_s = 0.0
current = bit
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(
norm[room] * max(0.0, abs(value - targets[room]) - comfort_band) ** 2
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.
trajectory: dict[str, list[float]] = {room: [] for room in temps}
state, lag = dict(temps), effective_heat
for bit in plan:
for _ in range(nsteps_per_block):
state, lag = simulate_step(state, outside, lag, bit, learners)
for idx, bit in enumerate(plan):
for tick in range(nsteps_per_block):
state, lag = advance(state, lag, bit, idx * nsteps_per_block + tick)
for room, value in state.items():
trajectory[room].append(round(value, 3))
return {