From e62798dc4fe3a4c23882a7455f361e886a6c9429 Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Tue, 27 Oct 2026 02:57:20 +0200 Subject: [PATCH] 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 --- tests/test_coordinator.py | 33 +++++++++++++++++++++++++++++++++ tests/test_heat_sources.py | 8 ++++++++ tests/test_windows.py | 4 ++++ 3 files changed, 45 insertions(+) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index a88c10e..0f194e9 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1033,3 +1033,36 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): for zone, other in (('living', 'bed'), ('bed', 'living')): with self.subTest(zone=zone): 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) diff --git a/tests/test_heat_sources.py b/tests/test_heat_sources.py index f88380a..ec3b58e 100644 --- a/tests/test_heat_sources.py +++ b/tests/test_heat_sources.py @@ -93,6 +93,14 @@ class TrackerTests(unittest.TestCase): self.assertEqual(tracker.zone_input('office'), states[-1]['ups']['response_kw']) 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): tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}}) run(tracker, [{'ups': 200.0}] * 7) diff --git a/tests/test_windows.py b/tests/test_windows.py index 9728048..dfc1e01 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -55,6 +55,10 @@ class GeometryTests(unittest.TestCase): [-9.0, -36.0, 21.6, -45.0, -9.0, 0.0]): self.assertAlmostEqual(value, expected) 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.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}),