what_to_wear/tests/logic/test_normalize.py
Nora 67430837c8 feat(1.2): SI-Forecast-Normalizer mit expliziter Missingness
Story 1.2 (TDD, Suite gruen 31/31):
- logic/normalize.py: RawForecast -> NormForecast (SI), NormField je Feld,
  Einheiten explizit konvertiert (nie geraten), Plausibilitaet nach Konvertierung,
  Morgenfenster [06:00,09:00) lokal, hourly-only-Ableitung, Condition-Mapping (AD-4/17).
- luna-pro-Story-Review fand echten Zeitzonen-Bug (UTC->lokal vor Fensterauswertung);
  6/8 Findings uebernommen, 1 teils, 1 verworfen (Evidenz im Ledger).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:27:48 +00:00

263 lines
11 KiB
Python

"""Story 1.2 — SI normalizer with explicit missingness (no HA harness)."""
from __future__ import annotations
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from custom_components.what_to_wear.logic import normalize as n
from custom_components.what_to_wear.logic.model import RawForecast
TZ = ZoneInfo("Europe/Berlin")
UTC = timezone.utc
TARGET = date(2026, 7, 14)
def _dt(h: int, minute: int = 0, d: date = TARGET) -> datetime:
return datetime(d.year, d.month, d.day, h, minute, tzinfo=TZ)
def _metno_units() -> dict[str, str]:
return {"temperature": "°C", "wind_speed": "km/h", "precipitation": "mm"}
def _metno_raw() -> RawForecast:
# met.no profile: daily has templow/temp/precip/prob/wind/gust/uv, NO apparent_temperature.
daily = (
{
"datetime": _dt(12),
"temperature": 24.0,
"templow": 12.0,
"precipitation": 3.0,
"precipitation_probability": 70,
"wind_speed": 15.0,
"wind_gust_speed": 30.0,
"uv_index": 6,
"condition": "rainy",
},
)
# 3-hour raster hourly.
hourly = tuple(
{
"datetime": _dt(h),
"temperature": 10.0 + h * 0.1,
"precipitation": 0.5,
"wind_speed": 12.0,
"condition": "rainy",
}
for h in (0, 3, 6, 9, 12, 15, 18, 21)
)
return RawForecast(time_zone="Europe/Berlin", units=_metno_units(), daily=daily, hourly=hourly)
def test_metno_profile_morning_and_daily() -> None:
nf = n.normalize(_metno_raw(), TARGET)
# met.no gives no apparent temperature -> feels_like_morning missing (fallback is rules' job).
assert nf.feels_like_morning.value is None
# morning temp = min over the [06:00,09:00) window -> the 06:00 entry (10.6).
assert nf.temp_morning.value is not None
assert abs(nf.temp_morning.value - 10.6) < 1e-9
assert nf.temp_morning.source == "hourly"
# daily aggregates present in SI.
assert nf.temp_max.value == 24.0
assert nf.temp_min.value == 12.0
assert nf.rain_probability.value == 70
assert nf.rain_amount.value == 3.0
assert nf.gust.value == 30.0
assert nf.uv_index.value == 6
assert nf.condition == "regen"
def test_fahrenheit_mph_inch_conversion() -> None:
units = {"temperature": "°F", "wind_speed": "mph", "precipitation": "in"}
daily = (
{
"datetime": _dt(12),
"temperature": 50.0, # 10 °C
"templow": 32.0, # 0 °C
"precipitation": 1.0, # 25.4 mm
"wind_speed": 10.0, # 16.09 km/h
"wind_gust_speed": 20.0, # 32.19 km/h
},
)
raw = RawForecast(time_zone="Europe/Berlin", units=units, daily=daily, hourly=())
nf = n.normalize(raw, TARGET)
assert abs(nf.temp_max.value - 10.0) < 1e-6
assert abs(nf.temp_min.value - 0.0) < 1e-6
assert abs(nf.rain_amount.value - 25.4) < 1e-6
assert abs(nf.wind.value - 16.09344) < 1e-4
assert abs(nf.gust.value - 32.18688) < 1e-4
def test_missing_unit_means_field_missing_never_guessed() -> None:
# A value present but its unit attribute absent -> field missing (AD-4).
units = {"wind_speed": "km/h"} # no temperature/precipitation unit
daily = ({"datetime": _dt(12), "temperature": 20.0, "precipitation": 2.0, "wind_speed": 10.0},)
raw = RawForecast(time_zone="Europe/Berlin", units=units, daily=daily, hourly=())
nf = n.normalize(raw, TARGET)
assert nf.temp_max.value is None
assert nf.rain_amount.value is None
assert nf.wind.value == 10.0
def test_plausibility_checked_after_conversion() -> None:
units = {"temperature": "°C", "wind_speed": "km/h", "precipitation": "mm"}
daily = (
{
"datetime": _dt(12),
"temperature": 999.0, # implausible -> missing
"templow": 12.0,
"wind_speed": 500.0, # implausible -> missing
"uv_index": 99, # implausible -> missing
},
)
raw = RawForecast(time_zone="Europe/Berlin", units=units, daily=daily, hourly=())
nf = n.normalize(raw, TARGET)
assert nf.temp_max.value is None
assert nf.temp_max.note is not None
assert nf.wind.value is None
assert nf.uv_index.value is None
assert nf.temp_min.value == 12.0 # plausible one survives
# A °F value that is plausible in °F but implausible before conversion must
# be judged AFTER conversion: 200 °F -> 93.3 °C is implausible; 100 °F -> 37.8 °C ok.
hot = RawForecast(
time_zone="Europe/Berlin",
units={"temperature": "°F"},
daily=({"datetime": _dt(12), "temperature": 100.0, "templow": 200.0},),
hourly=(),
)
nf2 = n.normalize(hot, TARGET)
assert nf2.temp_max.value is not None and abs(nf2.temp_max.value - 37.777) < 1e-2
assert nf2.temp_min.value is None # 200 °F -> 93.3 °C implausible
def test_hourly_only_derives_daily_aggregates() -> None:
# No daily at all -> aggregates derived from hourly (source=derived).
hourly = (
{"datetime": _dt(6), "temperature": 8.0, "precipitation": 1.0, "wind_speed": 10.0,
"precipitation_probability": 40, "wind_gust_speed": 20.0, "uv_index": 3},
{"datetime": _dt(12), "temperature": 18.0, "precipitation": 2.0, "wind_speed": 14.0,
"precipitation_probability": 60, "wind_gust_speed": 28.0, "uv_index": 5},
)
raw = RawForecast(
time_zone="Europe/Berlin",
units={"temperature": "°C", "wind_speed": "km/h", "precipitation": "mm"},
daily=(),
hourly=hourly,
)
nf = n.normalize(raw, TARGET)
assert nf.temp_max.value == 18.0 and nf.temp_max.source == "derived"
assert nf.temp_min.value == 8.0 and nf.temp_min.source == "derived"
assert nf.rain_amount.value == 3.0 # sum
assert nf.rain_probability.value == 60 # max
assert nf.gust.value == 28.0 # max
assert nf.uv_index.value == 5 # max
def test_morning_window_coverage_predicate() -> None:
# >= 1 hourly entry with local time in [06:00, 09:00) on the target date.
assert n.morning_window_coverage([{"datetime": _dt(7)}], TARGET, TZ)
# 3h raster with a 06:00 entry counts.
assert n.morning_window_coverage([{"datetime": _dt(6)}], TARGET, TZ)
# 09:00 is excluded (half-open).
assert not n.morning_window_coverage([{"datetime": _dt(9)}], TARGET, TZ)
# only 05:00 and 10:00 -> not covered.
assert not n.morning_window_coverage(
[{"datetime": _dt(5)}, {"datetime": _dt(10)}], TARGET, TZ
)
# entries on a different date do not count.
other = date(2026, 7, 15)
assert not n.morning_window_coverage([{"datetime": _dt(7, d=other)}], TARGET, TZ)
# naive datetimes are dropped (AD-24).
assert not n.morning_window_coverage(
[{"datetime": datetime(2026, 7, 14, 7, 0)}], TARGET, TZ
)
def test_utc_timestamps_are_converted_to_local_window() -> None:
# HA delivers UTC timestamps. 05:00Z in July = 07:00 Berlin (UTC+2) -> in window;
# 08:00Z = 10:00 Berlin -> outside. The morning temp must pick the local-07:00 one.
hourly = (
{"datetime": datetime(2026, 7, 14, 5, 0, tzinfo=UTC), "temperature": 11.0}, # 07:00 local
{"datetime": datetime(2026, 7, 14, 8, 0, tzinfo=UTC), "temperature": 20.0}, # 10:00 local
)
raw = RawForecast(
time_zone="Europe/Berlin", units={"temperature": "°C"}, daily=(), hourly=hourly
)
assert n.morning_window_coverage([dict(e) for e in hourly], TARGET, TZ)
nf = n.normalize(raw, TARGET)
assert nf.temp_morning.value == 11.0 # the local-07:00 entry, not the 10:00 one
def test_per_element_plausibility_before_aggregation() -> None:
# An implausible element must not poison the aggregate (finding F3).
hourly = (
{"datetime": _dt(6), "temperature": -100.0}, # implausible morning value
{"datetime": _dt(7), "temperature": 8.0}, # valid -> should be the morning min
{"datetime": _dt(12), "temperature": 999.0}, # implausible max input
{"datetime": _dt(15), "temperature": 20.0}, # valid -> should be the max
)
raw = RawForecast(
time_zone="Europe/Berlin", units={"temperature": "°C"}, daily=(), hourly=hourly
)
nf = n.normalize(raw, TARGET)
assert nf.temp_morning.value == 8.0 # -100 filtered out
assert nf.temp_max.value == 20.0 # 999 filtered out, valid 20 survives
def test_lightning_rainy_maps_to_gewitter() -> None:
# AD-17 lists lightning-rainy under both regen and gewitter; only schnee/gewitter
# drive rules, so it resolves to gewitter (rain is handled by numeric fields).
assert n.map_condition("lightning-rainy") == "gewitter"
assert n.map_condition("lightning") == "gewitter"
assert n.map_condition("snowy-rainy") == "schnee"
assert n.map_condition("pouring") == "regen"
assert n.map_condition("sunny") is None
assert n.map_condition(None) is None
def test_day_condition_severity_max_over_hourly() -> None:
# No daily -> most severe hourly condition of the day wins (deterministic).
hourly = (
{"datetime": _dt(6), "temperature": 2.0, "condition": "rainy"},
{"datetime": _dt(9), "temperature": 3.0, "condition": "lightning"},
{"datetime": _dt(12), "temperature": 4.0, "condition": "cloudy"},
)
raw = RawForecast(
time_zone="Europe/Berlin", units={"temperature": "°C"}, daily=(), hourly=hourly
)
nf = n.normalize(raw, TARGET)
assert nf.condition == "gewitter"
def test_non_numeric_values_do_not_crash() -> None:
daily = ({"datetime": _dt(12), "temperature": "n/a", "templow": None, "wind_speed": True},)
raw = RawForecast(
time_zone="Europe/Berlin",
units={"temperature": "°C", "wind_speed": "km/h"},
daily=daily,
hourly=(),
)
nf = n.normalize(raw, TARGET) # must not raise
assert nf.temp_max.value is None
assert nf.wind.value is None # bool is not a measurement
def test_dst_spring_forward_day_window_intact() -> None:
# 2026-03-29 is a DST spring-forward day in Europe/Berlin (23h). The 06-09
# morning window still exists; normalization must not crash and must pick it.
dst = date(2026, 3, 29)
hourly = tuple(
{"datetime": datetime(dst.year, dst.month, dst.day, h, tzinfo=TZ), "temperature": float(h)}
for h in (0, 3, 6, 9, 12)
)
raw = RawForecast(
time_zone="Europe/Berlin",
units={"temperature": "°C"},
daily=({"datetime": datetime(dst.year, dst.month, dst.day, 12, tzinfo=TZ),
"temperature": 12.0, "templow": 2.0},),
hourly=hourly,
)
nf = n.normalize(raw, dst)
assert n.morning_window_coverage([dict(e) for e in hourly], dst, TZ)
assert nf.temp_morning.value == 6.0 # the 06:00 entry