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
@@ -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"]
|
||||
Reference in new issue
Block a user