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>
148 lines
8.6 KiB
Python
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()
|