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

+42
View File
@@ -209,3 +209,45 @@ async def test_heat_source_subentry_flow(hass, entry, inputs):
sources = status.attributes["heat_sources"]
assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice
assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400)
async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs):
await setup_entry(hass, entry)
hass.states.async_set("binary_sensor.bedroom_window", "off", {"device_class": "window"})
flows = hass.config_entries.subentries
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
form = result["data_schema"]({"name": "Bedroom window", "sensor": "binary_sensor.bedroom_window",
"zone": "sensor.room"})
assert (form["azimuth"], form["opening_factor"], form["inverted"]) == (345.0, 1.0, False)
result = await flows.async_configure(result["flow_id"], {**form, "azimuth": 340})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert [(w.name, w.azimuth) for w in coordinator.windows] == [("Bedroom window", 340.0)]
# No wind sources yet: only the terms whose inputs exist.
assert coordinator.ventilation["sensor.room"].features == ["vent_natural", "recovery"]
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
result = await flows.async_configure(result["flow_id"], {**form, "name": "Again"})
assert result["errors"] == {"base": "sensor_in_other_window"}
flows.async_abort(result["flow_id"])
# Wind, direction and gust sources plus the gust option add their terms.
for sensor, value, unit in (("sensor.wind", "4", "m/s"), ("sensor.wind_dir", "204", "°"),
("sensor.gust", "9", "m/s")):
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
options = hass.config_entries.options
result = await options.async_init(entry.entry_id)
result = await options.async_configure(result["flow_id"], result["data_schema"](
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({
"outside_temperature_sources": ["sensor.outside"], "outside_wind_sources": ["sensor.wind"],
"outside_wind_direction_sources": ["sensor.wind_dir"], "outside_wind_gust_sources": ["sensor.gust"]}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({}))
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
# Cross-ventilation needs windows in two zones, so it stays off.
assert coordinator.ventilation["sensor.room"].features == [
"vent_natural", "recovery", "vent_wind", "vent_wind_dir", "vent_gust"]
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
window = status.attributes["windows"]["Bedroom window"]
assert (window["state"], window["relative_wind_direction"]) == ("closed", -136.0)
assert {"windows", "ventilation"} <= status.state_info["unrecorded_attributes"]
+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
+143
View File
@@ -0,0 +1,143 @@
"""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()