SWS-12500-custom-component/tests/test_wslink_derived_indices.py

154 lines
5.5 KiB
Python

"""Heat / wind-chill indices must work on WSLink too.
`_auto_enable_derived_sensors` creates both entities as soon as temperature and
humidity (resp. wind speed) arrive, but the WSLink descriptions only ever read the
station's own ``t1heat`` / ``t1chill``. A station that does not report those left both
entities Unavailable forever, while the WU/PWS platform computed them.
The payload is metric, the NWS formulas are Fahrenheit-based (and mph for wind chill),
and the WSLink entities are declared in Celsius - so the conversions matter as much as
the fallback itself.
"""
from __future__ import annotations
import pytest
from custom_components.sws12500.const import (
CHILL_INDEX,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
WIND_SPEED,
)
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
from custom_components.sws12500.utils import (
celsius_to_fahrenheit,
chill_index,
fahrenheit_to_celsius,
heat_index,
wslink_chill_index,
wslink_heat_index,
)
# The remapped form of a real payload from a station that sends neither t1heat nor
# t1chill (t1tem/t1hum/t1ws in metric units).
LIVE_PAYLOAD: dict[str, str] = {
OUTSIDE_TEMP: "6.2",
OUTSIDE_HUMIDITY: "40",
WIND_SPEED: "30.6",
}
def _description(key: str):
return next(desc for desc in SENSOR_TYPES_WSLINK if desc.key == key)
# ---------------------------------------------------------------------------
# Fallback
# ---------------------------------------------------------------------------
def test_indices_are_computed_when_the_station_omits_them() -> None:
"""The reported bug: both entities stayed empty for this exact payload."""
assert wslink_heat_index(LIVE_PAYLOAD) is not None
assert wslink_chill_index(LIVE_PAYLOAD) is not None
def test_computed_values_are_celsius_and_plausible() -> None:
"""6.2 C at 40% RH with strong wind: heat index near ambient, chill below it."""
heat = wslink_heat_index(LIVE_PAYLOAD)
chill = wslink_chill_index(LIVE_PAYLOAD)
assert heat is not None and chill is not None
# Sanity-check the magnitude - a Fahrenheit result leaking through would be ~39/28.
assert -10 < heat < 15, heat
assert -10 < chill < 15, chill
# Wind chill must be colder than the still-air temperature at 30.6 m/s.
assert chill < 6.2
def test_wind_is_converted_to_mph() -> None:
"""`chill_index` converts temperature but not wind, and its formula needs mph.
Feeding m/s straight in understates the wind and yields a warmer chill.
"""
correct = wslink_chill_index(LIVE_PAYLOAD)
naive_f = chill_index(
{OUTSIDE_TEMP: celsius_to_fahrenheit(6.2), WIND_SPEED: 30.6}, # m/s, unconverted
)
assert naive_f is not None
naive = round(fahrenheit_to_celsius(naive_f), 2)
assert correct is not None
assert correct < naive, f"unconverted wind gives {naive}, converted gives {correct}"
# ---------------------------------------------------------------------------
# The station's own reading still wins
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("fn", "key"),
[(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)],
ids=["heat", "chill"],
)
def test_station_reported_value_takes_precedence(fn, key) -> None:
"""t1heat / t1chill are already Celsius and must be passed through untouched."""
assert fn({**LIVE_PAYLOAD, key: "3.5"}) == 3.5
@pytest.mark.parametrize(
("fn", "key"),
[(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)],
ids=["heat", "chill"],
)
def test_empty_reported_value_falls_back_to_computing(fn, key) -> None:
"""The station sends "" for absent fields; that must not shadow the fallback."""
assert fn({**LIVE_PAYLOAD, key: ""}) is not None
# ---------------------------------------------------------------------------
# Missing inputs stay quiet
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, OUTSIDE_HUMIDITY])
def test_heat_index_needs_temp_and_humidity(missing) -> None:
payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing}
assert wslink_heat_index(payload) is None
@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, WIND_SPEED])
def test_chill_index_needs_temp_and_wind(missing) -> None:
payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing}
assert wslink_chill_index(payload) is None
def test_no_inputs_at_all_is_none() -> None:
assert wslink_heat_index({}) is None
assert wslink_chill_index({}) is None
# ---------------------------------------------------------------------------
# Wiring
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("key", "expected"), [(HEAT_INDEX, wslink_heat_index), (CHILL_INDEX, wslink_chill_index)])
def test_descriptions_use_the_wslink_helpers(key, expected) -> None:
"""`value_from_data_fn` wins in WeatherSensor.native_value, so it must be set."""
desc = _description(key)
assert desc.value_from_data_fn is expected
def test_wu_platform_still_computes_in_fahrenheit() -> None:
"""The WU descriptions are Fahrenheit-native; they must keep the raw helpers."""
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
by_key = {d.key: d for d in SENSOR_TYPES_WEATHER_API}
assert by_key[HEAT_INDEX].value_from_data_fn is heat_index
assert by_key[CHILL_INDEX].value_from_data_fn is chill_index