feat(1.3): deterministische Regel-Engine (Requirements aus Normprognose)
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) <noreply@anthropic.com>
This commit is contained in:
parent
67430837c8
commit
6d87e1082b
4 changed files with 318 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
144
custom_components/what_to_wear/logic/rules.py
Normal file
144
custom_components/what_to_wear/logic/rules.py
Normal file
|
|
@ -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)))
|
||||
168
tests/logic/test_rules.py
Normal file
168
tests/logic/test_rules.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue