Model windows, ventilation, wind exposure and recovery
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>
This commit is contained in:
1 parent
00e70c612b
commit
ed8f9fe2e2
13 files changed
+1157
-39
No files matched your search
+141
-1
@@ -5,7 +5,7 @@ import types
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import exp, pi, sin
|
||||
from math import cos, exp, pi, radians, sin
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -823,3 +823,143 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||
await self.sample()
|
||||
self.assertIsNone(spy.call_args.kwargs['step_model'])
|
||||
|
||||
def bedroom_with_window(self, with_window=True):
|
||||
self.entry.data.update(outside_wind_sources=['sensor.wind'],
|
||||
outside_wind_direction_sources=['sensor.wind_dir'])
|
||||
self.entry.subentries = {
|
||||
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
|
||||
'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.room']})}
|
||||
if with_window:
|
||||
self.entry.subentries['w'] = types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
|
||||
'key': 'bed_window', 'name': 'Bedroom window', 'sensor': 'binary_sensor.bed_window',
|
||||
'zone': 'bed', 'azimuth': 345})
|
||||
self.set_state('sensor.wind', '4', unit_of_measurement='m/s')
|
||||
self.set_state('sensor.wind_dir', '204', unit_of_measurement='°')
|
||||
self.set_state('binary_sensor.bed_window', 'off')
|
||||
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||
|
||||
def open_window(self, state, seconds_before_next_sample):
|
||||
window = self.states['binary_sensor.bed_window']
|
||||
window.state = state
|
||||
window.last_changed = self.now - timedelta(seconds=seconds_before_next_sample)
|
||||
|
||||
async def test_open_window_goes_to_ventilation_model_not_envelope_loss(self):
|
||||
self.c = self.bedroom_with_window()
|
||||
self.assertEqual(self.c.ventilation['bed'].features,
|
||||
['vent_natural', 'recovery', 'vent_wind', 'vent_wind_dir'])
|
||||
for _ in range(2):
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||
await self.sample()
|
||||
self.open_window('on', 240) # opened a minute after the last poll
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||
base = self.c.learners['bed'].count
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_result'], 'ventilation')
|
||||
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
|
||||
window = data['windows']['Bedroom window']
|
||||
self.assertEqual((window['state'], window['open_minutes'], window['relative_wind_direction']),
|
||||
('open', 4.0, -141.0))
|
||||
self.assertEqual(window['wind_exposure'], round(cos(radians(141)), 3))
|
||||
ventilation = data['ventilation']['Bedroom']
|
||||
self.assertEqual((ventilation['opening'], ventilation['affected']), (0.8, True))
|
||||
# Unknown contact state: neither model learns, and it never counts as closed.
|
||||
self.open_window('unavailable', 120)
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['windows']['Bedroom window']['state'], 'unknown')
|
||||
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
|
||||
|
||||
async def test_ventilation_week_keeps_envelope_loss_clean(self):
|
||||
"""Same synthetic week through a zone with and without its window configured."""
|
||||
with_window = self.bedroom_with_window(True)
|
||||
without = self.bedroom_with_window(False)
|
||||
a, b, c = 0.004, 0.12, 0.0005
|
||||
k_nat, k_wind, k_dir, k_rec = 0.01, 0.001, 0.0006, 0.15
|
||||
advance = coordinator.advance_lag
|
||||
self.states['climate.reference'].state = 'off'
|
||||
temp, lag, on, outside = 21.0, 0.0, False, 2.0
|
||||
window_open, closed_at, t_ref, prev_temp = False, None, None, temp
|
||||
for k in range(2400):
|
||||
speed, direction = 2 + 3 * abs(sin(k / 50)), (k * 7) % 360
|
||||
outside = 2 + 4 * sin(2 * pi * k / 288) # daily swing keeps loss and bias apart
|
||||
for entity, value in (('sensor.room', temp), ('sensor.outside', outside),
|
||||
('sensor.wind', speed), ('sensor.wind_dir', direction)):
|
||||
self.states[entity].state = f'{value:.5f}'
|
||||
self.states[entity].last_reported = self.now
|
||||
with patch.object(coordinator, 'datetime') as clock:
|
||||
clock.now.return_value = self.now
|
||||
for c_ in (with_window, without):
|
||||
await c_._async_update_data()
|
||||
poll = self.now
|
||||
self.now += timedelta(seconds=300)
|
||||
# Thermostat: switch 120 s after the poll when needed.
|
||||
command = temp < 20.7 or (on and temp < 21.3)
|
||||
if command != on:
|
||||
self.switch('on' if command else 'off', 180)
|
||||
lag = advance(advance(lag, on, 120), command, 180)
|
||||
else:
|
||||
lag = advance(lag, on, 300)
|
||||
on = command
|
||||
# Ventilation: 15-20 minutes every four hours, changes 60 s after a poll.
|
||||
phase = k % 48
|
||||
fraction, recovery = (1.0 if window_open else 0.0), 0.0
|
||||
if phase == 0 and not window_open:
|
||||
t_ref, window_open, fraction = prev_temp, True, 0.8
|
||||
self.open_window('on', 240)
|
||||
elif phase == 4 and window_open:
|
||||
window_open, fraction, closed_at = False, 0.2, poll.timestamp() + 60
|
||||
self.open_window('off', 240)
|
||||
if closed_at is not None and not window_open:
|
||||
recovery = exp(-(self.now.timestamp() - closed_at) / 1200)
|
||||
recovery = recovery if recovery >= 0.05 else 0.0
|
||||
facing = fraction * cos(radians((direction - 345 + 180) % 360 - 180))
|
||||
difference = outside - temp
|
||||
prev_temp = temp
|
||||
temp += (a * difference + b * lag + c
|
||||
+ (k_nat * fraction + k_wind * fraction * speed) * difference
|
||||
+ k_dir * facing * speed * difference
|
||||
+ k_rec * recovery * ((t_ref if t_ref is not None else temp) - temp))
|
||||
vent = with_window.ventilation['bed']
|
||||
# Natural and wind terms both scale with opening x (T_out - T); with wind only
|
||||
# 2-5 m/s their split is uncertain, but their combined effect is identified.
|
||||
mean_speed = 3.5
|
||||
learned = vent.coefficient('vent_natural') + vent.coefficient('vent_wind') * mean_speed
|
||||
self.assertAlmostEqual(learned, k_nat + k_wind * mean_speed, delta=0.15 * (k_nat + k_wind * mean_speed))
|
||||
self.assertAlmostEqual(vent.coefficient('recovery'), k_rec, delta=0.15 * k_rec)
|
||||
self.assertAlmostEqual(vent.coefficient('vent_wind_dir'), k_dir, delta=0.3 * k_dir)
|
||||
# With the window configured the envelope model stays close to the truth...
|
||||
clean = with_window.learners['bed'].theta
|
||||
self.assertAlmostEqual(clean[0], a, delta=0.1 * a)
|
||||
self.assertAlmostEqual(clean[1], b, delta=0.05 * b)
|
||||
self.assertLess(abs(clean[2] - c), 0.003)
|
||||
# ...without it, ventilation shows up as a constant cooling bias and an inflated boiler effect.
|
||||
mixed = without.learners['bed'].theta
|
||||
self.assertLess(mixed[2], -0.008)
|
||||
self.assertGreater(mixed[1], 1.05 * b)
|
||||
|
||||
async def test_forecast_assumes_window_closes_then_room_recovers(self):
|
||||
self.c = self.bedroom_with_window()
|
||||
self.states['climate.reference'].state = 'off'
|
||||
for _ in range(2):
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||
await self.sample()
|
||||
self.open_window('on', 240)
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||
await self.sample()
|
||||
model = self.c.learners['bed']
|
||||
model.count, model.on_count, model.off_count = 200, 100, 100
|
||||
vent = self.c.ventilation['bed']
|
||||
vent.theta[vent.features.index('vent_natural')] = 0.01
|
||||
vent.theta[vent.features.index('recovery')] = 0.15
|
||||
self.states['climate.reference'].state = 'heat'
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['status'], 'ready')
|
||||
step = spy.call_args.kwargs['step_model']
|
||||
base = model.predict_delta(19.0, 5.0, 0.0)
|
||||
open_change = step({'bed': 19.0}, 0.0, 0)['bed'] - 19.0
|
||||
recovering = step({'bed': 19.0}, 0.0, 4)['bed'] - 19.0 # 15 min default: closed by step 3
|
||||
self.assertLess(open_change, base - 0.1) # 0.01 x (5 - 19) = -0.14 °C/5 min
|
||||
self.assertGreater(recovering, base) # air recovers towards 20 °C
|
||||
Reference in new issue
Block a user