Test power spikes, calm wind and restart of the new model layers

A single 5-minute +700 W spike moves the room's thermal response by at
most 0.2 kW and leaves the baseline at the persistent load. In calm air
the wind terms vanish while natural ventilation remains. Extended and
ventilation coefficients and heat-source baselines survive a restart
unchanged (no hour of baseline re-learning), and enabling an extra
ventilation term later keeps everything learned for the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:57:20 +02:00
1 parent b1577a0c78
commit e62798dc4f
3 files changed
+45

No files matched your search

+33
View File
@@ -1033,3 +1033,36 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
for zone, other in (('living', 'bed'), ('bed', 'living')): for zone, other in (('living', 'bed'), ('bed', 'living')):
with self.subTest(zone=zone): with self.subTest(zone=zone):
self.assertLess(c.extended[zone].coefficient(f'couple:{other}'), 0.002) self.assertLess(c.extended[zone].coefficient(f'couple:{other}'), 0.002)
async def test_new_model_layers_survive_a_restart(self):
self.c = self.bedroom_with_window()
self.c.entry.subentries['h'] = types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'bed'})
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
for k in range(20):
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir', 'sensor.ups_power')
if k == 18:
self.open_window('on', 240)
await self.sample()
extended, vent = self.c.extended['bed'], self.c.ventilation['bed']
self.assertGreater(extended.count, 0)
self.assertGreater(vent.count, 0)
payload = self.c._store.async_save.call_args.args[0]
restored = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
restored._store.async_load.return_value = payload
await restored.async_restore_model()
self.assertEqual((restored.extended['bed'].theta, restored.extended['bed'].count), (extended.theta, extended.count))
self.assertEqual((restored.ventilation['bed'].theta, restored.ventilation['bed'].count), (vent.theta, vent.count))
self.assertEqual(restored._heat_sources._baselines['ups'].value(), 200) # no hour of re-learning
# Adding a feature keeps what was learned for the others.
self.entry.data['ventilation_cross'] = True
self.entry.subentries['z2'] = types.SimpleNamespace(subentry_id='z2', subentry_type='zone', data={
'key': 'living', 'name': 'Living', 'sensors': ['sensor.living']})
self.entry.subentries['w2'] = types.SimpleNamespace(subentry_id='w2', subentry_type='window', data={
'key': 'living_window', 'name': 'Living window', 'sensor': 'binary_sensor.living_window', 'zone': 'living'})
grown = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
grown._store.async_load.return_value = payload
await grown.async_restore_model()
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta)
+8
View File
@@ -93,6 +93,14 @@ class TrackerTests(unittest.TestCase):
self.assertEqual(tracker.zone_input('office'), states[-1]['ups']['response_kw']) self.assertEqual(tracker.zone_input('office'), states[-1]['ups']['response_kw'])
self.assertEqual(tracker.zone_input('living'), 0.0) self.assertEqual(tracker.zone_input('living'), 0.0)
def test_short_spike_barely_moves_response_or_baseline(self):
tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}})
states = run(tracker, [{'ups': 200.0}] * 3 + [{'ups': 900.0}] + [{'ups': 200.0}] * 4)
peak = max(s['ups']['response_kw'] for s in states)
self.assertAlmostEqual(peak, 0.7 * (1 - exp(-1 / 3))) # one 5-minute interval of +700 W
self.assertLess(peak, 0.2)
self.assertEqual(states[-1]['ups']['baseline_w'], 200)
def test_unknown_power_resets_until_settled_again(self): def test_unknown_power_resets_until_settled_again(self):
tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}}) tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}})
run(tracker, [{'ups': 200.0}] * 7) run(tracker, [{'ups': 200.0}] * 7)
+4
View File
@@ -55,6 +55,10 @@ class GeometryTests(unittest.TestCase):
[-9.0, -36.0, 21.6, -45.0, -9.0, 0.0]): [-9.0, -36.0, 21.6, -45.0, -9.0, 0.0]):
self.assertAlmostEqual(value, expected) self.assertAlmostEqual(value, expected)
self.assertIsNone(wm.ventilation_vector(['vent_wind'], 20, 2, {**inputs, 'wind': None})) self.assertIsNone(wm.ventilation_vector(['vent_wind'], 20, 2, {**inputs, 'wind': None}))
# Calm air: wind terms vanish, natural ventilation stays.
calm = wm.ventilation_vector(features, 20.0, 2.0, {**inputs, 'wind': 0.0, 'gust': 0.0})
self.assertEqual([round(v, 9) for v in calm[1:4]], [0.0, 0.0, 0.0])
self.assertEqual(calm[0], -9.0)
self.assertEqual(wm.ventilation_vector(['vent_natural'], 20, 2, {**inputs, 'wind': None}), [-9.0]) self.assertEqual(wm.ventilation_vector(['vent_natural'], 20, 2, {**inputs, 'wind': None}), [-9.0])
self.assertIsNone(wm.ventilation_vector(['recovery'], 20, 2, {**inputs, 'recovery': 0.5})) self.assertIsNone(wm.ventilation_vector(['recovery'], 20, 2, {**inputs, 'recovery': 0.5}))
self.assertEqual(wm.ventilation_vector(['recovery'], 19, 2, {**inputs, 'recovery': 0.5, 't_ref': 21}), self.assertEqual(wm.ventilation_vector(['recovery'], 19, 2, {**inputs, 'recovery': 0.5, 't_ref': 21}),