diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f6867..751f15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 parameters as the cross-cutting GAs they are. `explain_ga` shows the winning signal and tier; the HA generator still emits a switch for a now-HVAC simple on/off, so no device is dropped. + Dogfooding the classifier against a deliberately messy synthetic house then hardened it further: + the **main-range name map no longer lets a "middle 0" clobber its main group's name** (both start at + the same raw address, so the old modulo heuristic overwrote "Освещение" with "Вкл/Выкл" and the + range rescue silently died — depth in the range tree decides now); a category that comes from a + **whole-main-group DPT fallback is soft** (a guess by construction — only an exact `(main, sub)` + table entry is strong), so a vendor enum like 20.609 no longer poses as authoritative HVAC; + **DPT 1.010 start/stop is soft** (ventilation timers use it too, not just shutters); terms learned: + Cyrillic **а/с**, **сплит**, **вытяжк**, **яркост** (RU only — EN "brightness" also means weather + lux), meteo/weather (sensor now outranks hvac so "Метеостанция - Температура" is a sensor, not an + HVAC actuator). Real-project noise after the round: Minsk 1 outlier / 685 GAs, demo 2 / 239. + ### Added - **Explainable aggregate scores** (`advanced.py`, `handover.py`). Every headline percentage now ships diff --git a/nickol_knx_mcp/dpt_map.py b/nickol_knx_mcp/dpt_map.py index 1bb75a5..99f15ce 100644 --- a/nickol_knx_mcp/dpt_map.py +++ b/nickol_knx_mcp/dpt_map.py @@ -122,6 +122,12 @@ _MAIN_FALLBACK: dict[int, DptInfo] = { } +def is_exact_dpt(main: Optional[int], sub: Optional[int]) -> bool: + """True when (main, sub) has an exact table entry — i.e. the category came + from the specific DPT, not from a whole-main-group fallback guess.""" + return (main, sub) in _EXACT + + def classify_dpt(main: Optional[int], sub: Optional[int]) -> DptInfo: """Classify a DPT into category / kind / HA platform.""" if main is None: diff --git a/nickol_knx_mcp/explain.py b/nickol_knx_mcp/explain.py index b6909f1..9b6d989 100644 --- a/nickol_knx_mcp/explain.py +++ b/nickol_knx_mcp/explain.py @@ -21,7 +21,7 @@ from typing import Any, Optional from .project import (LoadedProject, _override_kind_by_name, _domain_from_text, _SOFT_DPT) -from .dpt_map import classify_dpt +from .dpt_map import classify_dpt, is_exact_dpt from .intent import classify_intent from .pairing import (function_status_pairs, find_status, positional_status, self_reporting, base_tokens) @@ -41,7 +41,8 @@ def explain_ga(project: LoadedProject, address: str) -> dict[str, Any]: dpt_cat = base["category"] name_dom = _domain_from_text(ga.name) range_dom = _domain_from_text(f"{ga.main_name} {ga.middle_name}") - soft = (ga.dpt_main, ga.dpt_sub) in _SOFT_DPT or dpt_cat == "unknown" + soft = ((ga.dpt_main, ga.dpt_sub) in _SOFT_DPT or dpt_cat == "unknown" + or not is_exact_dpt(ga.dpt_main, ga.dpt_sub)) cat_ev: list[dict[str, str]] = [] if ga.dpt_main is not None: cat_ev.append({"signal": f"DPT {ga.dpt or ga.dpt_main} → {dpt_cat}" diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index 9b17da2..8ca3931 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -14,7 +14,7 @@ from typing import Any, Optional from xknxproject import XKNXProj from xknxproject.models import KNXProject -from .dpt_map import classify_dpt, dpt_key, CATEGORY_UNKNOWN +from .dpt_map import classify_dpt, dpt_key, is_exact_dpt, CATEGORY_UNKNOWN from .intent import classify_intent, INTENT_FUNCTIONAL from .safexml import preflight_archive @@ -46,16 +46,22 @@ _DOMAIN_TERMS: dict[str, list[str]] = { "awning", "curtain", "behang", "lamelle", "raffstore", "markise", "auf/ab", "ab/auf", "штор", "жалюзи", "рольставн", "ролет", "ролл", "позиц", "вверх/вниз", "ламел", "position", "shade"], + # sensor BEFORE hvac: a name with an explicit sensor word (weather station, + # датчик, motion) is a sensor even when it measures temperature — otherwise + # "Метеостанция - Температура" lands in hvac via the "температур" term. + "sensor": ["sensor", "motion", "presence", "occupancy", "brightness sens", "lux", + "humidity", "weather", "meteo", "датчик", "движени", "присутств", "влажн", + "люкс", "метео", "погод"], "hvac": ["hvac", "climate", "thermostat", "heat", "cool", "ac", "a/c", "aircon", "air con", "air-con", "conditioner", "ventilation", "radiator", "boiler", "underfloor", "fancoil", "fan coil", "fan-coil", "fan", "valve", "setpoint", "температур", "климат", "отопл", "тёпл", "тепл", "конвектор", "вентил", - "клапан", "кондиц", "котёл", "радиатор", "фанкойл", "уставк"], - "sensor": ["sensor", "motion", "presence", "occupancy", "brightness sens", "lux", - "humidity", "датчик", "движени", "присутств", "влажн", "люкс"], + "клапан", "кондиц", "котёл", "радиатор", "фанкойл", "уставк", + "а/с", "сплит", "вытяжк"], "lighting": ["light", "lamp", "dimm", "led", "spot", "sconce", "chandelier", "rgb", "rgbw", "colour", "color", "xyy", - "свет", "лампа", "подсветк", "освещ", "люстра", "диммер", "бра", "торшер"], + "свет", "лампа", "подсветк", "освещ", "люстра", "диммер", "бра", "торшер", + "яркост"], # RU only: EN "brightness" also means weather lux — too ambiguous } # Domains a group-range name may legitimately pin for an actuator command whose @@ -65,8 +71,10 @@ _DOMAIN_TERMS: dict[str, list[str]] = { _RANGE_ASSIGNABLE = {"lighting", "shutter", "hvac"} # Short/ambiguous tokens need a boundary on BOTH sides (whole word), else they -# fire inside unrelated words ("ac" in "terrace", "led" in "ledge"). -_AMBIGUOUS_TERMS = {"ac", "a/c", "led", "fan", "co2", "uv", "rgb", "spot", "lux", "roll"} +# fire inside unrelated words ("ac" in "terrace", "led" in "ledge"). "а/с" is the +# Cyrillic air-con abbreviation. "spot" is NOT here: prefix-matching lets "spots" +# hit, and no KNX name plausibly embeds it mid-word. +_AMBIGUOUS_TERMS = {"ac", "a/c", "а/с", "led", "fan", "co2", "uv", "rgb", "lux", "roll"} def _compile_domain(terms: list[str]) -> "re.Pattern": @@ -103,7 +111,7 @@ def _refine_category(name: str, current: str) -> str: # a pump or an AC; a 5.001 scaling is a brightness OR a shutter position. For soft # DPTs a contradicting name is NOT a conflict (it disambiguates), unlike a strong, # domain-encoding DPT (a shutter 1.008, an HVAC-mode 20.102, a temperature 9.001). -_SOFT_DPT = {(1, 1), (1, 11), (5, 1)} +_SOFT_DPT = {(1, 1), (1, 11), (1, 10), (5, 1)} # 1.010 start/stop is generic (vent timers too) # The subset that is domain-AGNOSTIC end to end: with no name and no range signal # these stay 'unknown' — we never infer 'lighting' from a bare 1-bit DPT. Other # soft DPTs (5.001) keep their sensible default (brightness) when unsignalled. @@ -122,7 +130,11 @@ def _classify_category(name: str, main_name: str, middle_name: str, * with no signal at all, a bare 1-bit DPT is 'unknown' (never guessed 'lighting'); a 5.001 falls back to its brightness default. """ - soft = (main, sub) in _SOFT_DPT or dpt_cat == CATEGORY_UNKNOWN_STR + # Soft = the DPT does not pin the domain: the listed agnostic types, an + # unknown category, or a category that came from a whole-main-group FALLBACK + # (a guess by construction) rather than an exact (main, sub) table entry. + soft = ((main, sub) in _SOFT_DPT or dpt_cat == CATEGORY_UNKNOWN_STR + or not is_exact_dpt(main, sub)) name_dom = _domain_from_text(name) if name_dom: if soft or name_dom == dpt_cat: @@ -225,23 +237,24 @@ def _build_range_name_map(raw: KNXProject) -> dict[str, str]: """ out: dict[str, str] = {} - def walk(rng: dict[str, Any]) -> None: + def walk(rng: dict[str, Any], depth: int) -> None: start = rng.get("address_start") name = rng.get("name", "") if isinstance(start, int): - # main range: start aligned to 0x0800 boundaries; derive main number main = (start >> 11) & 0x1F middle = (start >> 8) & 0x07 - # heuristic: if start is multiple of 2048 -> a main range, else middle - if start % 2048 == 0: + # Depth decides main vs middle, NOT address arithmetic: a middle group + # 0 starts at the same address as its main (start % 2048 == 0 too), so + # the old modulo heuristic let "middle 0" overwrite the main's name. + if depth == 0: out[str(main)] = name else: out[f"{main}/{middle}"] = name for child in rng.get("group_ranges", {}).values(): - walk(child) + walk(child, depth + 1) for rng in raw.get("group_ranges", {}).values(): - walk(rng) + walk(rng, 0) return out diff --git a/tests/test_domain_classifier.py b/tests/test_domain_classifier.py index 5e5abb5..fd63da3 100644 --- a/tests/test_domain_classifier.py +++ b/tests/test_domain_classifier.py @@ -53,10 +53,28 @@ def main(): # ...but agreement keeps the domain assert _classify_category("Living room dimmer", "", "", 3, 7, "lighting") == "lighting" + # --- Cyrillic air-con abbreviations + colloquial 'сплит' (dogfood find) --- + for variant in ("а/с кухня вкл/выкл", "А/С детская 1 on/off", "Сплит Kids 2 status"): + assert _domain_from_text(variant) == "hvac", variant + # plural 'spots' must match the lighting prefix + assert _domain_from_text("Terrace spots on/off") == "lighting" + # --- end to end through the model: an AC switch in the HVAC main is HVAC --- p = _proj({"3/0/1": _ga("3/0/1", "Master bedroom AC on/off", 1, 1)}) assert p.gas["3/0/1"].category == "hvac" + # --- middle-0 range must not overwrite the MAIN range's name (dogfood find): + # a middle group 0 starts at the same raw address as its main, so the old + # modulo heuristic clobbered "Освещение" with "Вкл/Выкл" and the range + # rescue silently died. Depth decides now. --- + ranges = {"r1": {"name": "Освещение", "address_start": 2048, "group_ranges": { + "r10": {"name": "Вкл/Выкл", "address_start": 2048, "group_ranges": {}}}}} + p2 = _proj({"1/0/9": _ga("1/0/9", "Kanal 9", 1, 1)}, ranges) + assert p2.gas["1/0/9"].main_name == "Освещение", p2.gas["1/0/9"].main_name + assert p2.gas["1/0/9"].middle_name == "Вкл/Выкл", p2.gas["1/0/9"].middle_name + assert p2.gas["1/0/9"].category == "lighting", \ + "main-range name must rescue a context-less switch in the lighting main" + print("test_domain_classifier: OK — DPT is one signal among name + main-group context; " "AC/kondicioner is HVAC, bare switches are unknown, 5.001 disambiguates by name, " "strong-DPT vs name is a conflict, and 'ac' never fires inside 'terrace'.")