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