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:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:47:40 +02:00
1 parent 00e70c612b
commit ed8f9fe2e2
13 files changed
+1157 -39

No files matched your search

+141 -1
View File
@@ -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