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>
118 lines
4.2 KiB
Python
118 lines
4.2 KiB
Python
"""Read-only weather source selection and unit normalization.
|
|
|
|
Pure-Python helpers so source and unit behavior can be tested without HAOS.
|
|
Optional weather variables are observability inputs in v0.2, not MPC features.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from math import isfinite
|
|
|
|
from .const import CONF_OUTSIDE, WEATHER_SOURCES
|
|
|
|
|
|
LIMITS = {
|
|
"temperature": (-45.0, 55.0),
|
|
"feels_like": (-65.0, 65.0),
|
|
"wind": (0.0, 90.0), # m/s
|
|
"wind_gust": (0.0, 120.0), # m/s
|
|
"wind_direction": (0.0, 360.0), # degrees the wind comes from
|
|
"clouds": (0.0, 100.0), # percentage
|
|
"humidity": (0.0, 100.0), # percentage
|
|
"pressure": (800.0, 1100.0), # hPa
|
|
"visibility": (0.0, 100000.0), # metres
|
|
"rain": (0.0, 500.0), # mm (sampling period defined by upstream sensor)
|
|
"snow": (0.0, 500.0),
|
|
}
|
|
|
|
|
|
def sources_for(config: dict, metric: str) -> list[str]:
|
|
"""Return ordered de-duplicated entity IDs, including v0.1 migration."""
|
|
key = WEATHER_SOURCES[metric]
|
|
sources = config.get(key)
|
|
if sources is None and metric == "temperature":
|
|
sources = config.get(CONF_OUTSIDE)
|
|
if isinstance(sources, str):
|
|
sources = [sources]
|
|
if not isinstance(sources, (list, tuple)):
|
|
return []
|
|
return list(dict.fromkeys(s for s in sources if isinstance(s, str) and s.startswith("sensor.")))
|
|
|
|
|
|
def normalize(metric: str, raw: object, unit: str | None = None) -> float | None:
|
|
"""Validate and normalize common HA units; reject unknown/mismatched units."""
|
|
try:
|
|
value = float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not isfinite(value):
|
|
return None
|
|
if unit is not None and not isinstance(unit, str):
|
|
return None
|
|
unit = (unit or "").strip().lower().replace(" ", "")
|
|
if metric in ("temperature", "feels_like"):
|
|
if unit in ("°f", "f", "fahrenheit"):
|
|
value = (value - 32.0) * 5.0 / 9.0
|
|
elif unit not in ("", "°c", "c", "celsius"):
|
|
return None
|
|
elif metric == "wind_direction":
|
|
if unit not in ("", "°", "deg", "degree", "degrees"):
|
|
return None
|
|
if value == 360.0:
|
|
value = 0.0
|
|
elif metric in ("wind", "wind_gust"):
|
|
if unit in ("km/h", "kmh", "kph"):
|
|
value /= 3.6
|
|
elif unit in ("mph",):
|
|
value *= 0.44704
|
|
elif unit in ("kn", "kt", "knots"):
|
|
value *= 0.514444
|
|
elif unit in ("ft/s",):
|
|
value *= 0.3048
|
|
elif unit not in ("", "m/s", "mps"):
|
|
return None
|
|
elif metric == "pressure":
|
|
if unit == "pa":
|
|
value /= 100.0
|
|
elif unit == "kpa":
|
|
value *= 10.0
|
|
elif unit in ("inhg",):
|
|
value *= 33.8638867
|
|
elif unit not in ("", "hpa", "mbar"):
|
|
return None
|
|
elif metric == "visibility":
|
|
if unit in ("km",):
|
|
value *= 1000.0
|
|
elif unit not in ("", "m"):
|
|
return None
|
|
elif metric in ("humidity", "clouds"):
|
|
if unit not in ("", "%"):
|
|
return None
|
|
elif metric in ("rain", "snow"):
|
|
if unit not in ("", "mm", "mm/h"):
|
|
return None
|
|
lo, hi = LIMITS[metric]
|
|
return round(value, 4) if lo <= value <= hi else None
|
|
|
|
|
|
def choose_weather_sources(
|
|
config: dict, states: dict[str, tuple[object, str | None]]
|
|
) -> tuple[dict[str, float | None], dict[str, str | None], dict[str, dict[str, float]]]:
|
|
"""Choose first valid sensor per metric, expose alternatives for comparisons.
|
|
|
|
`states` maps entity ID -> (value, unit). In HA these are current state snapshots.
|
|
"""
|
|
values: dict[str, float | None] = {}
|
|
active: dict[str, str | None] = {}
|
|
comparisons: dict[str, dict[str, float]] = {}
|
|
for metric in WEATHER_SOURCES:
|
|
valid: dict[str, float] = {}
|
|
for entity_id in sources_for(config, metric):
|
|
raw, unit = states.get(entity_id, (None, None))
|
|
value = normalize(metric, raw, unit)
|
|
if value is not None:
|
|
valid[entity_id] = value
|
|
comparisons[metric] = valid
|
|
active[metric] = next(iter(valid), None)
|
|
values[metric] = valid[active[metric]] if active[metric] else None
|
|
return values, active, comparisons
|