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"]