From 6d87e1082b13eea0a02bd4b4bf91f6420cd53a49 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 13 Jul 2026 13:32:54 +0000 Subject: [PATCH] feat(1.3): deterministische Regel-Engine (Requirements aus Normprognose) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 1.3 (TDD, Suite gruen 47/47): - logic/rules.py: Regeltabelle Addendum §3 + §3a-Degradation, Basis-Outfit immer, Offset genau einmal (+1 = Schwellen +2°C nach oben, mehr Waerme), kanonischer Merge (max-Prioritaet, warmth-Floor via max), Schwellen aus Options (AD-21/23). - Doku-Fix: Offset-Beispiel in epics.md-AC korrigiert (Richtung). - luna-pro-Story-Review: 1 Finding verworfen mit Evidenz (§3a 'uebrige Regeln'). Co-Authored-By: Claude Opus 4.8 (1M context) --- LEDGER.md | 5 + _bmad-output/planning-artifacts/epics.md | 2 +- custom_components/what_to_wear/logic/rules.py | 144 +++++++++++++++ tests/logic/test_rules.py | 168 ++++++++++++++++++ 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 custom_components/what_to_wear/logic/rules.py create mode 100644 tests/logic/test_rules.py diff --git a/LEDGER.md b/LEDGER.md index fea567d..7bd1a13 100644 --- a/LEDGER.md +++ b/LEDGER.md @@ -220,6 +220,11 @@ Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale. vor Aggregation; F4 naive datetimes verwerfen; F5 `_num`-Typguard; F6 kein bool als Messwert; F8 condition-Fallback per Schwere-Max), 1 teils (F1 lightning-rainy→gewitter behalten, rules-korrekt + dokumentiert), 1 verworfen mit Evidenz (F7 gemischte Per-Eintrag-Einheiten treten bei HA nicht auf). +- **1.3 Regel-Engine** ✅ — 47 Tests grün. `logic/rules.py` + `tests/logic/test_rules.py`. Doku-Fix: + Offset-Beispiel in epics.md korrigiert (Richtung war verkehrt: +1 = Schwellen +2 °C nach oben ⇒ bei + 9 °C Wärmegrad 3→4, mehr Wärme). **luna-pro-Review:** 1 Finding **verworfen mit Evidenz** (Schnee + + temp fehlt → warmth:4 ist korrekt: §3a entfernt nur die Temperatur-Band-Regel, Schnee ist »übrige + Regel« mit Wärme-Floor; temp_missing-Note bleibt richtig). ## Offene Punkte / nächste Schritte - Phase 4: Epics & Stories aus dem Spine + Party-Mode + Readiness-Check. diff --git a/_bmad-output/planning-artifacts/epics.md b/_bmad-output/planning-artifacts/epics.md index f6c3e15..ff95261 100644 --- a/_bmad-output/planning-artifacts/epics.md +++ b/_bmad-output/planning-artifacts/epics.md @@ -251,7 +251,7 @@ So that jede Empfehlung erklärbar auf konkreten Wetterbedingungen beruht. **When** die Regeln greifen **Then** folgt das Verhalten exakt der Degradations-Tabelle §3a; ein Datenlage-Hinweis entsteht nur bei betroffenem muss/soll-Requirement (FR-4.3); Tagesmaximum ≥ 2 Bänder über Morgenfenster → `hint_layering` (FR-4.4) -**Given** Grenzwert-Fixtures (halboffene Bänder auf ungerundeten SI-Werten: 7.9999 °C; Offset +1 verschiebt 7.5-°C-Morgen von Band 4 nach Band 3; Frost+Schnee+Starkregen) +**Given** Grenzwert-Fixtures (halboffene Bänder auf ungerundeten SI-Werten: 7.9999 °C bleibt Wärmegrad 4, 8.0 °C wird Wärmegrad 3; Offset +1 schiebt alle °C-Schwellen um +2 °C nach oben, sodass ein 9-°C-Morgen von Wärmegrad 3 auf Wärmegrad 4 steigt — »friert leichter ⇒ früher wärmer«; Frost+Schnee+Starkregen) **When** die Unit-Tests laufen **Then** sind sie grün und die Merge-Semantik liefert genau einen `waterproof_outer (must)` und einen `warmth:5` diff --git a/custom_components/what_to_wear/logic/rules.py b/custom_components/what_to_wear/logic/rules.py new file mode 100644 index 0000000..8e28b59 --- /dev/null +++ b/custom_components/what_to_wear/logic/rules.py @@ -0,0 +1,144 @@ +"""Deterministic rules engine (Story 1.3, FR-4, AD-21/AD-23). + +Pure core: turns a :class:`NormForecast` into a canonically merged set of +:class:`Requirement` objects plus language-neutral data-note keys. Thresholds +come entirely from the options mapping (``const.OPTIONS_DEFAULTS`` shape), so the +options flow (Story 3.1) tunes behaviour without any change here. The cold +sensitivity offset shifts every °C threshold once (AD-23): ``+1`` moves the band +edges up by 2 °C, so at a given temperature more warmth is recommended +("friert leichter ⇒ früher wärmer"). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .model import NormForecast, Priority, Requirement, RequirementKey + +# Reference values for the day-spread (layering) hint. +_BASE_KEYS = (RequirementKey.BASE_TOP, RequirementKey.BASE_BOTTOM, RequirementKey.BASE_SHOES) + + +@dataclass(frozen=True, slots=True) +class RulesResult: + """Merged requirements plus data-note keys (language-neutral, AD-13).""" + + requirements: tuple[Requirement, ...] + data_notes: tuple[str, ...] + + +def _warmth_level(temp: float, limits: list[float]) -> int: + """Map a temperature to a warmth level 1..5 using half-open bands. + + limits = the four inner edges [e0,e1,e2,e3] (already offset-shifted): + (-inf,e0)->5 [e0,e1)->4 [e1,e2)->3 [e2,e3)->2 [e3,inf)->1 + """ + if temp < limits[0]: + return 5 + if temp < limits[1]: + return 4 + if temp < limits[2]: + return 3 + if temp < limits[3]: + return 2 + return 1 + + +def evaluate(nf: NormForecast, options: dict[str, Any]) -> RulesResult: + """Evaluate the rule table against ``nf`` using ``options`` thresholds.""" + offset = int(options["cold_sensitivity_offset"]) + shift = 2.0 * offset + limits = [float(x) + shift for x in options["warmth_band_limits"]] + heat_threshold = float(options["heat_threshold"]) + shift + + # merged[key] = (priority, level) + merged: dict[RequirementKey, tuple[Priority, int | None]] = {} + notes: set[str] = set() + + def add(key: RequirementKey, priority: Priority, level: int | None = None) -> None: + if key in merged: + old_pri, old_level = merged[key] + priority = max(priority, old_pri) + if level is not None or old_level is not None: + level = max(level or 0, old_level or 0) + merged[key] = (priority, level) + + # Base outfit is unconditional. + for k in _BASE_KEYS: + add(k, Priority.MUST) + + # --- Temperature / warmth (reference: feels-like morning, §3a fallback) --- + ref = nf.feels_like_morning.value + if ref is None: + ref = nf.temp_morning.value # §3a: fall back to measured (attr-only, no note) + if ref is not None: + level = _warmth_level(ref, limits) + add(RequirementKey.WARMTH, Priority.MUST, level) + if ref < limits[0]: # coldest band adds accessories + add(RequirementKey.HAT, Priority.SHOULD) + add(RequirementKey.GLOVES, Priority.SHOULD) + add(RequirementKey.SCARF, Priority.MAY) + elif ref < limits[1]: # 2nd-coldest band adds a hat (may) + add(RequirementKey.HAT, Priority.MAY) + if ref >= heat_threshold: + add(RequirementKey.SUN_PROTECTION, Priority.SHOULD) + add(RequirementKey.HINT_HEAT, Priority.MAY) + else: + notes.add("temp_missing") # a must-requirement (warmth) is affected + + # --- Rain --- + prob = nf.rain_probability.value + amount = nf.rain_amount.value + if prob is None and amount is None: + notes.add("rain_missing") # soll/muss affected + else: + must = (prob is not None and prob >= float(options["rain_prob_must"])) or ( + amount is not None and amount >= float(options["rain_amount_must"]) + ) + should = (prob is not None and prob >= float(options["rain_prob_should"])) or ( + amount is not None and amount >= float(options["rain_amount_should"]) + ) + if must: + add(RequirementKey.WATERPROOF_OUTER, Priority.MUST) + add(RequirementKey.STURDY_SHOES, Priority.SHOULD) + elif should: + add(RequirementKey.WATERPROOF_OUTER, Priority.SHOULD) + add(RequirementKey.STURDY_SHOES, Priority.SHOULD) + + # --- Wind (gust; proxy on mean wind when gust missing) --- + gust = nf.gust.value + if gust is not None: + if gust >= float(options["gust_should"]): + add(RequirementKey.WINDPROOF_OUTER, Priority.SHOULD) + elif nf.wind.value is not None: + if nf.wind.value >= float(options["wind_proxy_should"]): + add(RequirementKey.WINDPROOF_OUTER, Priority.SHOULD) + # both missing -> attr-only degradation, no data note. + + # --- UV --- + uv = nf.uv_index.value + if uv is not None and uv >= float(options["uv_should"]): + add(RequirementKey.SUN_PROTECTION, Priority.SHOULD) + + # --- Condition (snow / thunderstorm) --- + if nf.condition == "schnee": + add(RequirementKey.WARMTH, Priority.MUST, 4) # floor 4 (merges via max) + add(RequirementKey.STURDY_SHOES, Priority.SHOULD) + add(RequirementKey.WATERPROOF_OUTER, Priority.SHOULD) + elif nf.condition == "gewitter": + add(RequirementKey.WATERPROOF_OUTER, Priority.SHOULD) + add(RequirementKey.HINT_THUNDERSTORM, Priority.MAY) + + # --- Day-spread layering hint (Zwiebelprinzip, FR-4.4) --- + morning_ref = nf.feels_like_morning.value + if morning_ref is None: + morning_ref = nf.temp_morning.value + daymax = nf.temp_max.value + if morning_ref is not None and daymax is not None: + if _warmth_level(morning_ref, limits) - _warmth_level(daymax, limits) >= 2: + add(RequirementKey.HINT_LAYERING, Priority.MAY) + + requirements = tuple( + Requirement(key=k, priority=p, level=lvl) for k, (p, lvl) in merged.items() + ) + return RulesResult(requirements=requirements, data_notes=tuple(sorted(notes))) diff --git a/tests/logic/test_rules.py b/tests/logic/test_rules.py new file mode 100644 index 0000000..36680f7 --- /dev/null +++ b/tests/logic/test_rules.py @@ -0,0 +1,168 @@ +"""Story 1.3 — deterministic rules engine (no HA harness).""" +from __future__ import annotations + +from custom_components.what_to_wear import const +from custom_components.what_to_wear.logic import rules +from custom_components.what_to_wear.logic.model import NormField, NormForecast, Priority, RequirementKey + + +def _nf(**over) -> NormForecast: + """A NormForecast with everything missing unless overridden (value or NormField).""" + base = {k: NormField(None, None, None) for k in ( + "feels_like_morning", "temp_morning", "temp_min", "temp_max", + "feels_like_min", "feels_like_max", "rain_probability", "rain_amount", + "wind", "gust", "uv_index", + )} + for key, val in over.items(): + if key == "condition": + continue + base[key] = val if isinstance(val, NormField) else NormField(float(val), "daily", None) + return NormForecast(condition=over.get("condition"), **base) + + +def _opts(**over): + o = const.default_options() + o.update(over) + return o + + +def _req_map(result): + return {r.key: r for r in result.requirements} + + +def test_base_outfit_always_present() -> None: + res = rules.evaluate(_nf(), _opts()) # everything missing + keys = _req_map(res) + for k in (RequirementKey.BASE_TOP, RequirementKey.BASE_BOTTOM, RequirementKey.BASE_SHOES): + assert keys[k].priority is Priority.MUST + + +def test_cold_below_zero_full_winter() -> None: + res = rules.evaluate(_nf(feels_like_morning=-1.0, temp_max=2.0), _opts()) + m = _req_map(res) + assert m[RequirementKey.WARMTH].level == 5 + assert m[RequirementKey.WARMTH].priority is Priority.MUST + assert m[RequirementKey.HAT].priority is Priority.SHOULD + assert m[RequirementKey.GLOVES].priority is Priority.SHOULD + assert m[RequirementKey.SCARF].priority is Priority.MAY + + +def test_warmth_bands_half_open() -> None: + # 7.9999 -> warmth 4 ([0,8)); 8.0 -> warmth 3 ([8,15)). + assert _req_map(rules.evaluate(_nf(feels_like_morning=7.9999), _opts()))[RequirementKey.WARMTH].level == 4 + assert _req_map(rules.evaluate(_nf(feels_like_morning=8.0), _opts()))[RequirementKey.WARMTH].level == 3 + assert _req_map(rules.evaluate(_nf(feels_like_morning=15.0), _opts()))[RequirementKey.WARMTH].level == 2 + assert _req_map(rules.evaluate(_nf(feels_like_morning=22.0), _opts()))[RequirementKey.WARMTH].level == 1 + + +def test_offset_shifts_thresholds_up_more_warmth() -> None: + # 9 °C: offset 0 -> warmth 3; offset +1 (edges +2 °C) -> warmth 4 (friert leichter). + assert _req_map(rules.evaluate(_nf(feels_like_morning=9.0), _opts(cold_sensitivity_offset=0)))[RequirementKey.WARMTH].level == 3 + assert _req_map(rules.evaluate(_nf(feels_like_morning=9.0), _opts(cold_sensitivity_offset=1)))[RequirementKey.WARMTH].level == 4 + # Negative offset -> less warmth: -1 shifts edges down 2 °C, 9 °C -> still 3, but 7 °C -> 3 (from 4). + assert _req_map(rules.evaluate(_nf(feels_like_morning=7.0), _opts(cold_sensitivity_offset=-1)))[RequirementKey.WARMTH].level == 3 + + +def test_feels_like_falls_back_to_measured_no_text_note() -> None: + # feels_like missing but temp_morning present -> use measured, no data note (§3a). + res = rules.evaluate(_nf(temp_morning=-1.0), _opts()) + assert _req_map(res)[RequirementKey.WARMTH].level == 5 + assert "temp_missing" not in res.data_notes + + +def test_temp_completely_missing_no_warmth_but_base_and_note() -> None: + res = rules.evaluate(_nf(), _opts()) # no feels, no temp + m = _req_map(res) + assert RequirementKey.WARMTH not in m + assert m[RequirementKey.BASE_TOP].priority is Priority.MUST + assert "temp_missing" in res.data_notes # must-requirement affected -> text note + + +def test_rain_should_and_must_branches_merge_to_must() -> None: + should = rules.evaluate(_nf(feels_like_morning=10.0, rain_probability=50.0), _opts()) + assert _req_map(should)[RequirementKey.WATERPROOF_OUTER].priority is Priority.SHOULD + assert _req_map(should)[RequirementKey.STURDY_SHOES].priority is Priority.SHOULD + must = rules.evaluate(_nf(feels_like_morning=10.0, rain_probability=75.0), _opts()) + assert _req_map(must)[RequirementKey.WATERPROOF_OUTER].priority is Priority.MUST + + +def test_rain_amount_branch_when_probability_missing() -> None: + res = rules.evaluate(_nf(feels_like_morning=10.0, rain_amount=2.0), _opts()) + assert _req_map(res)[RequirementKey.WATERPROOF_OUTER].priority is Priority.SHOULD + assert "rain_missing" not in res.data_notes + + +def test_rain_both_missing_note() -> None: + res = rules.evaluate(_nf(feels_like_morning=10.0), _opts()) # no rain fields + assert RequirementKey.WATERPROOF_OUTER not in _req_map(res) + assert "rain_missing" in res.data_notes + + +def test_wind_gust_and_proxy() -> None: + gusty = rules.evaluate(_nf(feels_like_morning=10.0, gust=45.0), _opts()) + assert _req_map(gusty)[RequirementKey.WINDPROOF_OUTER].priority is Priority.SHOULD + # gust missing -> wind proxy at >= 30 km/h. + proxy = rules.evaluate(_nf(feels_like_morning=10.0, wind=35.0), _opts()) + assert _req_map(proxy)[RequirementKey.WINDPROOF_OUTER].priority is Priority.SHOULD + # both missing -> no windproof, and NO data note (attr-only degradation). + none = rules.evaluate(_nf(feels_like_morning=10.0), _opts()) + assert RequirementKey.WINDPROOF_OUTER not in _req_map(none) + assert "wind_missing" not in none.data_notes + + +def test_uv_and_heat() -> None: + uv = rules.evaluate(_nf(feels_like_morning=20.0, uv_index=7.0), _opts()) + assert _req_map(uv)[RequirementKey.SUN_PROTECTION].priority is Priority.SHOULD + heat = rules.evaluate(_nf(feels_like_morning=30.0, temp_max=30.0), _opts()) + m = _req_map(heat) + assert m[RequirementKey.WARMTH].level == 1 + assert m[RequirementKey.SUN_PROTECTION].priority is Priority.SHOULD + assert m[RequirementKey.HINT_HEAT].priority is Priority.MAY + + +def test_snow_forces_warmth_floor_and_merges() -> None: + # Snow requires warmth >= 4; if the temp band gives 5, keep 5 (max). If temp + # missing, snow still yields warmth 4. + res = rules.evaluate(_nf(condition="schnee"), _opts()) # temp missing + m = _req_map(res) + assert m[RequirementKey.WARMTH].level == 4 + assert m[RequirementKey.STURDY_SHOES].priority is Priority.SHOULD + assert m[RequirementKey.WATERPROOF_OUTER].priority is Priority.SHOULD + + +def test_layering_hint_when_daymax_two_bands_warmer() -> None: + # morning warmth 5 (-1 °C), tagesmax warmth 2 (18 °C) -> 5-2=3 >= 2 -> layering. + res = rules.evaluate(_nf(feels_like_morning=-1.0, temp_max=18.0), _opts()) + assert RequirementKey.HINT_LAYERING in _req_map(res) + # small spread -> no hint. + res2 = rules.evaluate(_nf(feels_like_morning=10.0, temp_max=12.0), _opts()) + assert RequirementKey.HINT_LAYERING not in _req_map(res2) + + +def test_frost_snow_heavy_rain_merge_single_entries() -> None: + # AC: exactly one waterproof_outer (must) and one warmth:5. + res = rules.evaluate( + _nf(feels_like_morning=-1.0, temp_max=1.0, rain_probability=75.0, condition="schnee"), + _opts(), + ) + wp = [r for r in res.requirements if r.key is RequirementKey.WATERPROOF_OUTER] + wm = [r for r in res.requirements if r.key is RequirementKey.WARMTH] + assert len(wp) == 1 and wp[0].priority is Priority.MUST + assert len(wm) == 1 and wm[0].level == 5 + + +def test_no_duplicate_keys_in_output() -> None: + res = rules.evaluate( + _nf(feels_like_morning=-1.0, temp_max=20.0, rain_probability=80.0, + rain_amount=10.0, gust=50.0, uv_index=8.0, condition="schnee"), + _opts(), + ) + keys = [r.key for r in res.requirements] + assert len(keys) == len(set(keys)), f"duplicate requirement keys: {keys}" + + +def test_rules_reads_thresholds_from_options() -> None: + # Custom rain threshold changes the outcome without touching rules code. + o = _opts(rain_prob_should=90) + res = rules.evaluate(_nf(feels_like_morning=10.0, rain_probability=50.0), o) + assert RequirementKey.WATERPROOF_OUTER not in _req_map(res) # 50 < 90