Files
heating-mpc-ha/tests/test_windows.py
T
buzeraandClaude Opus 5.5 e62798dc4f 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>
2026-10-27 02:57:20 +02:00

148 lines
8.6 KiB
Python

"""Windows, wind exposure, ventilation inputs and recovery, without Home Assistant."""
import sys
import types
import unittest
from math import cos, exp, radians
from pathlib import Path
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
from heating_mpc import windows as wm # noqa: E402
WEATHER = {'temperature': 2.0, 'wind': 4.0, 'wind_direction': 204.0, 'wind_gust': 9.0}
def window(key, zone='bed', **extra):
return {'key': key, 'name': key.title(), 'sensor': f'binary_sensor.{key}', 'zone': zone, **extra}
class GeometryTests(unittest.TestCase):
def test_wind_relative_to_facade(self):
# The apartment's façade faces about 345° (NNW); OpenWeatherMap wind from 204°.
self.assertAlmostEqual(wm.angle_difference(204, 345), -141)
self.assertAlmostEqual(wm.facing_cos(204, 345), cos(radians(141)))
self.assertAlmostEqual(wm.facing_cos(345, 345), 1) # blowing straight at the window
self.assertAlmostEqual(wm.facing_cos(165, 345), -1) # from behind the building
self.assertAlmostEqual(wm.facing_cos(75, 345), 0, places=9) # crosswind
# Around north, differences wrap instead of jumping by 360°.
self.assertAlmostEqual(wm.angle_difference(5, 355), 10)
self.assertAlmostEqual(wm.angle_difference(355, 5), -10)
self.assertAlmostEqual(wm.facing_cos(5, 355), cos(radians(10)))
def test_circular_mean(self):
self.assertAlmostEqual(wm.circular_mean([350, 10]) % 360 if wm.circular_mean([350, 10]) < 359 else 0, 0)
self.assertAlmostEqual(wm.circular_mean([350, 20], [1, 1]), 5)
self.assertIsNone(wm.circular_mean([90, 270]))
self.assertAlmostEqual(wm.circular_mean([0, 90], [3, 1]), 18.4349488, places=5)
def test_contact_states_and_open_fraction(self):
self.assertEqual([wm.contact_open(s) for s in ('on', 'off', 'unknown', 'unavailable', None)],
[True, False, None, None, None])
self.assertEqual([wm.contact_open(s, inverted=True) for s in ('on', 'off')], [False, True])
self.assertEqual(wm.open_fraction(True, True, 0, 100, 400), 1.0)
self.assertEqual(wm.open_fraction(False, False, 0, 100, 400), 0.0)
self.assertAlmostEqual(wm.open_fraction(False, True, 220, 100, 400), 0.6) # opened 120 s in
self.assertAlmostEqual(wm.open_fraction(True, False, 220, 100, 400), 0.4) # closed 120 s in
self.assertIsNone(wm.open_fraction(False, False, 220, 100, 400)) # round trip: unknown
self.assertIsNone(wm.open_fraction(None, True, 0, 100, 400))
def test_ventilation_vector(self):
features = list(wm.VENTILATION_FEATURES)
inputs = {'opening': 0.5, 'facing': -0.3, 'other_opening': 1.0, 'recovery': 0.0, 't_ref': None,
'wind': 4.0, 'gust': 9.0}
for value, expected in zip(wm.ventilation_vector(features, 20.0, 2.0, inputs),
[-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}),
[1.0])
def test_window_config_validation(self):
built = wm.build_windows([
(window('a', azimuth=705), None), (window('b', opening_factor=9), None),
(window('c', zone='gone'), None), (window('a'), None),
({**window('d'), 'sensor': 'sensor.d'}, None), (window('e', inverted=True), 's5')], ['bed'])
self.assertEqual([(w.key, w.azimuth, w.inverted) for w in built], [('a', 345.0, False), ('e', 345.0, True)])
self.assertEqual(wm.validate_window({'name': ' '}, built), 'window_name_required')
self.assertEqual(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.a'}, built),
'sensor_in_other_window')
self.assertIsNone(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.x'}, built))
class TrackerTests(unittest.TestCase):
def setUp(self):
self.tracker = wm.VentilationTracker(wm.build_windows([(window('bed_window'), None)], ['bed']),
recovery_minutes=20)
self.t = 1_000_000.0
self.changed = 0.0
def poll(self, state, temp, changed_offset=None, weather=WEATHER):
if changed_offset is not None:
self.changed = self.t - changed_offset
self.tracker.update(self.t, {'bed_window': (state, self.changed)}, {'bed': temp}, weather)
inputs = self.tracker.zone_inputs('bed', weather)
self.t += 300
return inputs
def test_ventilation_event_and_recovery(self):
self.assertFalse(self.poll('off', 21.0)['known']) # no previous poll yet
closed = self.poll('off', 21.0)
self.assertEqual((closed['opening'], closed['affected']), (0.0, False))
opened = self.poll('on', 20.6, changed_offset=180) # opened 3 minutes ago
self.assertAlmostEqual(opened['opening'], 0.6)
self.assertAlmostEqual(opened['facing'], 0.6 * cos(radians(141)))
self.assertTrue(opened['affected'])
self.assertEqual(opened['t_ref'], 21.0) # temperature at the poll before opening
for temp in (20.2, 19.9):
self.assertEqual(self.poll('on', temp)['opening'], 1.0)
closing = self.poll('off', 20.0, changed_offset=60) # closed a minute ago
self.assertAlmostEqual(closing['opening'], 0.8)
self.assertAlmostEqual(closing['recovery'], exp(-60 / 1200))
after = [self.poll('off', 20.0 + 0.1 * k) for k in range(12)]
self.assertTrue(after[0]['affected'] and after[0]['recovery'] > 0.7)
self.assertEqual((after[-1]['recovery'], after[-1]['affected']), (0.0, False)) # ~3 time constants
event = self.tracker.events[-1]
self.assertEqual((event['temperature_before'], event['lowest_temperature'], event['temperature_at_close']),
(21.0, 19.9, 20.0))
self.assertEqual((event['duration_minutes'], event['wind_direction'], event['windows']), (17.0, 204.0, ['Bed_Window'])) # 3 min + 15 min - 1 min
self.assertEqual(event['temperature_after_minutes'], 30)
restored = wm.VentilationTracker(self.tracker.windows, saved=self.tracker.as_dict())
self.assertEqual(list(restored.events), list(self.tracker.events))
def test_unknown_state_blocks_learning_and_multiple_windows_add(self):
tracker = wm.VentilationTracker(wm.build_windows(
[(window('w1'), None), (window('w2', opening_factor=0.5), None), (window('w3', zone='living'), None)],
['bed', 'living']))
for t in (0.0, 300.0):
tracker.update(t, {'w1': ('on', -10), 'w2': ('on', -10), 'w3': ('on', -10)}, {'bed': 20, 'living': 21},
WEATHER)
bed = tracker.zone_inputs('bed', WEATHER)
self.assertEqual((bed['opening'], bed['other_opening']), (1.5, 1.0)) # living's window is open too
tracker.update(600.0, {'w1': ('unavailable', 550), 'w2': ('on', -10), 'w3': ('off', 400)},
{'bed': 20, 'living': 21}, WEATHER)
bed = tracker.zone_inputs('bed', WEATHER)
self.assertEqual((bed['opening'], bed['known'], bed['affected']), (None, False, True))
self.assertIsNone(tracker.zone_inputs('kitchen', WEATHER))
def test_forecast_assumes_closing_then_recovery(self):
self.poll('off', 21.0)
self.poll('off', 21.0)
self.poll('on', 20.5, changed_offset=100)
steps = self.tracker.forecast('bed', 12, open_minutes=15, weather=WEATHER, now=self.t)
self.assertEqual([s['opening'] for s in steps[:4]], [1.0, 1.0, 1.0, 0.0])
self.assertAlmostEqual(steps[0]['facing'], cos(radians(141)))
self.assertAlmostEqual(steps[3]['recovery'], exp(-300 / 1200))
self.assertGreater(steps[3]['recovery'], steps[6]['recovery'])
self.assertEqual(steps[5]['t_ref'], 21.0)
if __name__ == '__main__':
unittest.main()