Opening a window adds heat exchange that the envelope model mistook for
poor insulation or a constant cooling bias.
windows.py adds windows as config subentries ("Add window"): a contact
sensor (optionally inverted; unknown/unavailable stays unknown, never
"closed"), its zone, the direction its outside faces (default 345°,
editable) and an opening factor. Each poll computes the share of the
interval each window was open (one change located by its timestamp, a
round trip is unknown), the wind exposure with proper circular angles
(meteorological "from" direction vs the window's facing) and a recovery
weight after closing that decays towards 0, pulling the air back
towards its pre-ventilation temperature. A bounded event log records
opening/closing, duration, temperatures before/lowest/at close/30 min
after, weather and other open windows.
While a zone's window is open, unknown or recovering, the base and
extended models skip it; a separate ventilation learner fits the
closed-window model's residual with natural exchange, wind speed, a
directional term bounded by the wind term (a leeward window still
ventilates), recovery, and optional gust excess and cross-ventilation
terms. Forecasts assume an open window closes after a configurable time
(default 15 min), then recovers. New poll outcome "ventilation".
Weather gains optional wind direction (degrees, 360 -> 0) and wind gust
sources with the same priorities and fallback; knots and ft/s accepted.
On a synthetic week with ventilation events every four hours and a
daily outdoor swing, the configured zone keeps loss/heat/bias close to
the truth and recovers the combined natural+wind effect within 15 %,
recovery within 15 % and direction within 30 %. The same data without
the window configured produces a -0.013 °C/5 min cooling bias and an
11 % inflated boiler effect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
8.3 KiB
Python
144 lines
8.3 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}))
|
|
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()
|