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

+88
View File
@@ -735,3 +735,91 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data['status'], 'ready')
self.assertEqual(spy.call_args.args[4], {'zone_living': 2.0})
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'])