diff --git a/CHANGELOG.md b/CHANGELOG.md index d620c65..e2f6867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 violation callers return a normal `{"error": ...}` rather than a traceback. Still strictly read-only. Raised by external security review. `tests/test_safexml.py`. +### Changed + +- **Contextual domain classification — a DPT alone no longer decides the domain** (`project.py`, + `explain.py`, `generate_ha.py`, `policy.py`). A 1-bit switch is domain-agnostic (a light just as + easily as a pump or an AC), yet the classifier defaulted DPT 1.001 → *lighting*, so "Living room AC + on/off" came out as lighting and then tripped downstream checks. The domain is now a **combination of + signals**: an explicit name domain wins; a strong, domain-encoding DPT (shutter 1.008, HVAC-mode + 20.102, temperature 9.001, dimming 3.007) keeps its domain; for a domain-soft DPT (1.001/1.011/5.001) + the **main group name** disambiguates; with no signal at all a bare 1-bit GA is honestly **unknown** + rather than a guessed lighting. When an explicit name contradicts a *strong* DPT the result is + `unknown` (a genuine conflict, surfaced by `explain_ga` as `contested`) — not a silent pick. Matching + is word-boundary, not substring, so "ac" no longer fires inside "terrace". Name terms broadened + (English + Russian HVAC incl. `ac`/`a/c`/`air conditioner`, RGB/colour lighting). Verified on three + real projects: every "AC on/off" is now HVAC; policy taxonomy-outlier **noise fell sharply** (demo + 11→2, one real project 31→6, another 200→57) because `check_policy` now flags only misplaced + **actuators** (lighting/shutter/HVAC) and treats sensors, scene links, thresholds and timeout + 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. + ### Added - **Explainable aggregate scores** (`advanced.py`, `handover.py`). Every headline percentage now ships diff --git a/nickol_knx_mcp/explain.py b/nickol_knx_mcp/explain.py index 516d09e..b6909f1 100644 --- a/nickol_knx_mcp/explain.py +++ b/nickol_knx_mcp/explain.py @@ -19,7 +19,8 @@ from __future__ import annotations from typing import Any, Optional -from .project import LoadedProject, _refine_category, _override_kind_by_name +from .project import (LoadedProject, _override_kind_by_name, _domain_from_text, + _SOFT_DPT) from .dpt_map import classify_dpt from .intent import classify_intent from .pairing import (function_status_pairs, find_status, positional_status, @@ -34,15 +35,22 @@ def explain_ga(project: LoadedProject, address: str) -> dict[str, Any]: base = classify_dpt(ga.dpt_main, ga.dpt_sub) - # --- category: DPT (structural) vs name refine (heuristic) --- - refined_cat = _refine_category(ga.name, base["category"]) + # --- category: the FINAL resolved domain (name > strong DPT > range context), + # as stored on the record; here we surface the signals behind it. --- + refined_cat = ga.category + 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" 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} → {base['category']}", + cat_ev.append({"signal": f"DPT {ga.dpt or ga.dpt_main} → {dpt_cat}" + + (" (domain-agnostic default)" if soft else ""), "tier": "structural"}) - if refined_cat != base["category"]: - cat_ev.append({"signal": f"name resolved ambiguous DPT → {refined_cat}", - "tier": "heuristic"}) + if name_dom: + cat_ev.append({"signal": f"name keyword → {name_dom}", "tier": "heuristic"}) + if range_dom and soft and not name_dom: + cat_ev.append({"signal": f"group-range name → {range_dom}", "tier": "heuristic"}) # --- kind: DPT vs name override --- refined_kind = _override_kind_by_name(ga.name, base["kind"]) @@ -60,18 +68,15 @@ def explain_ga(project: LoadedProject, address: str) -> dict[str, Any]: "type": fn.get("function_type"), "role": ref.get("role")}) - # --- conflict: DPT-domain vs name-domain (the silent-misclassification hotspot) --- + # --- conflict: an explicit name domain that a STRONG DPT contradicts. A soft + # 1-bit DPT is domain-agnostic, so name winning over it is NOT a conflict (the + # AC-on/off case is now correctly HVAC); a genuine conflict is e.g. a + # temperature DPT named "blind", which the classifier resolves to 'unknown'. --- conflicts = [] - low = (ga.name or "").lower() - _NAME_DOMAIN = {"hvac": ("ac", " a/c", "climate", "heat", "cool", "hvac", "конд", "клима", - "отоплен", "температур"), - "shutter": ("blind", "shutter", "cover", "жалюзи", "штор", "ролл"), - "energy": ("meter", "energy", "power", "счётчик", "энерг", "мощност")} - for dom, toks in _NAME_DOMAIN.items(): - if any(t in low for t in toks) and refined_cat != dom and refined_cat != "unknown": - conflicts.append( - f"name suggests '{dom}' but classified '{refined_cat}' (DPT took precedence " - "over the name)") + if name_dom and not soft and name_dom != dpt_cat: + conflicts.append( + f"name suggests '{name_dom}' but the DPT ({ga.dpt or ga.dpt_main}) strongly " + f"encodes '{dpt_cat}' — contradictory, so the domain is left '{refined_cat}'") # --- status pairing: which strategy, if any --- fpairs = function_status_pairs(project) @@ -97,8 +102,12 @@ def explain_ga(project: LoadedProject, address: str) -> dict[str, Any]: confidence = "contested" elif fn_hits: confidence = "authoritative" - elif ga.dpt_main is not None and refined_cat != "unknown": - confidence = "heuristic" if refined_cat != base["category"] else "structural" + elif refined_cat == "unknown": + confidence = "low" + elif name_dom or (range_dom and soft): + confidence = "heuristic" # name/range decided the domain + elif ga.dpt_main is not None: + confidence = "structural" # a strong DPT decided it else: confidence = "low" diff --git a/nickol_knx_mcp/generate_ha.py b/nickol_knx_mcp/generate_ha.py index fb14045..c79e140 100644 --- a/nickol_knx_mcp/generate_ha.py +++ b/nickol_knx_mcp/generate_ha.py @@ -266,8 +266,8 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: for sib in same_main_gas(ga): if sib.address in consumed: continue - if sib.category == "lighting" and sib.dpt_main == 1 and sib.kind == "command" \ - and _identity_match(ga.name, sib.name): + if sib.category in ("lighting", "unknown") and sib.dpt_main == 1 \ + and sib.kind == "command" and _identity_match(ga.name, sib.name): entity["address"] = sib.address s1 = status_for_dpt(sib, 1) # on/off status (1.x) if s1: @@ -311,7 +311,7 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: if ga.address in consumed: continue if ga.dpt_main == 1 and ga.dpt_sub == 1 and ga.kind == "command" \ - and ga.category in ("lighting", "unknown"): + and ga.category in ("lighting", "unknown", "hvac"): if not ga.name.strip(): review.append({"reason": "switch_unnamed", "address": ga.address, "name": ""}) consumed.add(ga.address) diff --git a/nickol_knx_mcp/policy.py b/nickol_knx_mcp/policy.py index 0ff70fd..85e8843 100644 --- a/nickol_knx_mcp/policy.py +++ b/nickol_knx_mcp/policy.py @@ -26,6 +26,11 @@ from typing import Any, Optional from .project import LoadedProject from .intent import INTENT_FUNCTIONAL +# Categories whose placement a main-group taxonomy actually constrains. Sensors, +# diagnostics, scene links and metering are cross-cutting — they legitimately +# appear inside any actuator domain's main group — so only these are checked. +_ACTUATOR_DOMAINS = {"lighting", "shutter", "hvac"} + # The CLAUDE.md taxonomy, expressed as an overridable default profile. DEFAULT_POLICY: dict[str, Any] = { "name": "default (CLAUDE.md methodology — override per project)", @@ -134,9 +139,14 @@ def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, An if not (ga.name or "").strip(): continue # empty name -> unreliable classification; root cause is empty_name - # 1. main-group taxonomy conformance (only when the domain is known) + # 1. main-group taxonomy conformance (only when the domain is known). + # Only ACTUATOR categories are checked: a lighting/shutter/HVAC main + # legitimately also holds motion sensors, thresholds, timeout parameters + # and scene links, so flagging those cross-cutting GAs as taxonomy + # outliers is noise. A misplaced actuator (an HVAC load in the lighting + # main) is the real signal. allowed = mg.get(ga.main) - if allowed and ga.category and ga.category != "unknown": + if allowed and ga.category in _ACTUATOR_DOMAINS: if ga.category in allowed: domain_ok += 1 else: diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index 16cc94e..9b17da2 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -14,10 +14,12 @@ from typing import Any, Optional from xknxproject import XKNXProj from xknxproject.models import KNXProject -from .dpt_map import classify_dpt, dpt_key +from .dpt_map import classify_dpt, dpt_key, CATEGORY_UNKNOWN from .intent import classify_intent, INTENT_FUNCTIONAL from .safexml import preflight_archive +CATEGORY_UNKNOWN_STR = CATEGORY_UNKNOWN + # Multilingual keyword sets (EN / DE / RU) used by the heuristic fallbacks. STATUS_KEYWORDS = [ @@ -29,32 +31,115 @@ COMMAND_KEYWORDS = [ "soll", "вкл", "выкл", "упр", "команд", "задан", ] -# Category disambiguation by name (used for DPTs ambiguous between domains, -# e.g. 5.001 = brightness OR shutter position; 1.001 = light OR generic). -_CATEGORY_KEYWORDS: dict[str, list[str]] = { - "shutter": ["blind", "shutter", "jalousie", "roll", "rollo", "marqui", - "awning", "curtain", "position", "behang", "lamelle", - "raffstore", "markise", "auf/ab", "ab/auf", "штор", "жалюзи", - "рольставн", "ролет", "ролл", "позиц", "вверх/вниз", "ламел"], - "lighting": ["light", "lamp", "dimm", "led", "spot", "свет", "лампа", - "подсветк", "освещ", "люстра", "диммер"], - "hvac": ["heat", "cool", "climate", "thermostat", "hvac", "valve", "fan", - "отопл", "климат", "тепл", "конвектор", "вентил", "клапан", - "кондиц", "тёпл"], - "energy": ["energy", "power", "consum", "meter", "kwh", "watt", "энерг", - "мощност", "потребл", "счётчик", "счетчик"], - "scene": ["scene", "scene", "сцен", "preset", "пресет"], +# Domain terms by name (used to decide a GA's functional domain as ONE signal, +# combined with the DPT and the group-range context — see _classify_category). +# Checked in this priority order (most specific first; lighting is the generic +# last resort). Matching is by WORD BOUNDARY, not bare substring, so short tokens +# like "ac" don't fire inside "terrace" and "led" doesn't fire inside "ledge". +_DOMAIN_TERMS: dict[str, list[str]] = { "diagnostics": ["alarm", "fault", "error", "diag", "leak", "smoke", - "тревог", "ошибк", "диагност", "утечк", "дым"], + "тревог", "ошибк", "диагност", "утечк", "дым", "авари", "неисправ"], + "energy": ["energy", "power", "consum", "meter", "kwh", "watt", "энерг", + "мощност", "потребл", "счётчик", "счетчик", "тариф"], + "scene": ["scene", "preset", "mood", "сцен", "пресет"], + "shutter": ["blind", "shutter", "jalousie", "roll", "rollo", "marqui", + "awning", "curtain", "behang", "lamelle", "raffstore", "markise", + "auf/ab", "ab/auf", "штор", "жалюзи", "рольставн", "ролет", "ролл", + "позиц", "вверх/вниз", "ламел", "position", "shade"], + "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", + "свет", "лампа", "подсветк", "освещ", "люстра", "диммер", "бра", "торшер"], } +# Domains a group-range name may legitimately pin for an actuator command whose +# DPT is domain-soft: a switch/scaling in a "Lighting"/"Shutters"/"HVAC" range is +# that load. A range named "Scenes"/"Sensors"/"Energy" does NOT retype a 1-bit +# command into a scene/sensor/energy datapoint — those carry their own strong DPTs. +_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"} + + +def _compile_domain(terms: list[str]) -> "re.Pattern": + parts = [] + for t in terms: + esc = re.escape(t) + parts.append(rf"\b{esc}\b" if (t in _AMBIGUOUS_TERMS or len(t) <= 2) else rf"\b{esc}") + return re.compile("|".join(parts)) + + +_DOMAIN_RE = {dom: _compile_domain(terms) for dom, terms in _DOMAIN_TERMS.items()} + + +def _domain_from_text(text: str) -> Optional[str]: + """Return the functional domain a piece of text (a GA or range name) implies, + or None. Word-boundary matching over _DOMAIN_TERMS, first domain by priority.""" + low = (text or "").lower() + if not low: + return None + for dom, rx in _DOMAIN_RE.items(): + if rx.search(low): + return dom + return None + def _refine_category(name: str, current: str) -> str: - low = name.lower() - for cat, words in _CATEGORY_KEYWORDS.items(): - if any(w in low for w in words): - return cat - return current + """Name-domain wins over the given category, else keep it. Kept for callers + that only have a name + a starting category (explain, tests).""" + return _domain_from_text(name) or current + + +# DPTs whose category is a *soft* default because the type does not, by itself, +# pin a domain — the name / range decides. A 1-bit switch is a light as easily as +# 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)} +# 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. +_AGNOSTIC_NO_DEFAULT = {(1, 1), (1, 11)} + + +def _classify_category(name: str, main_name: str, middle_name: str, + main: Optional[int], sub: Optional[int], dpt_cat: str) -> str: + """Combine signals into a domain: explicit name > strong DPT > range context. + + * an explicit name domain wins — unless it *contradicts* a strong (domain- + encoding) DPT, in which case the result is 'unknown' (a genuine conflict, + not a silent pick — surfaced by explain_ga as 'contested'); + * a strong DPT keeps its domain; + * for a soft DPT with no name domain, the group-range name decides; + * 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 + name_dom = _domain_from_text(name) + if name_dom: + if soft or name_dom == dpt_cat: + return name_dom + return CATEGORY_UNKNOWN_STR # strong DPT vs explicit name -> honest unknown + if not soft: + return dpt_cat + # Domain context comes from the MAIN group only — per the 3-level convention the + # main group is the function domain, while the middle group is a sub-function + # (Switch / Status / Parameters / "motion-detector settings") whose name carries + # misleading domain words. Fall back to the middle name only if the main is unnamed. + range_dom = _domain_from_text(main_name) or _domain_from_text(middle_name) + if range_dom in _RANGE_ASSIGNABLE: + return range_dom + if (main, sub) in _AGNOSTIC_NO_DEFAULT: + return CATEGORY_UNKNOWN_STR + return dpt_cat @dataclass @@ -184,7 +269,14 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: sub = dpt.get("sub") if dpt else None info = classify_dpt(main, sub) kind = _override_kind_by_name(ga.get("name", ""), info["kind"]) - category = _refine_category(ga.get("name", ""), info["category"]) + m, mid, s = _split_three_level(ga.get("address", addr)) + main_name = range_names.get(str(m), "") if m is not None else "" + middle_name = range_names.get(f"{m}/{mid}", "") if m is not None and mid is not None else "" + # Domain is a COMBINATION of signals (name > strong DPT > range context), + # not the DPT alone — a 1-bit switch is domain-agnostic, so an "AC on/off" + # is HVAC, not lighting, and a truly context-less switch is 'unknown'. + category = _classify_category(ga.get("name", ""), main_name, middle_name, + main, sub, info["category"]) ha_platform = info["ha_platform"] # If the name says shutter but DPT mapped it to light (5.001/1.001), # correct the HA platform so the generator builds a cover, not a light. @@ -195,7 +287,6 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: if category == "diagnostics" and main == 1 and ha_platform == "switch": ha_platform = "binary_sensor" kind = "sensor" - m, mid, s = _split_three_level(ga.get("address", addr)) rec = GARecord( address=ga.get("address", addr), name=ga.get("name", ""), @@ -212,8 +303,8 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: label=info["label"], intent=classify_intent(ga.get("name", "")), main=m, middle=mid, sub=s, - main_name=range_names.get(str(m), "") if m is not None else "", - middle_name=range_names.get(f"{m}/{mid}", "") if m is not None and mid is not None else "", + main_name=main_name, + middle_name=middle_name, ) gas[rec.address] = rec diff --git a/tests/test_domain_classifier.py b/tests/test_domain_classifier.py new file mode 100644 index 0000000..5e5abb5 --- /dev/null +++ b/tests/test_domain_classifier.py @@ -0,0 +1,66 @@ +"""P5 contextual domain classification: a DPT alone must not decide the domain. +A 1-bit switch is domain-agnostic — the name (then the main-group context) decides, +and with no signal the honest answer is 'unknown', never a guessed 'lighting'. +Matching is by word boundary so short tokens like 'ac' don't fire inside 'terrace'. +""" +from nickol_knx_mcp.project import build_loaded_from_raw, _domain_from_text, _classify_category + + +def _ga(addr, name, dmain, dsub): + return {"name": name, "identifier": f"GA-{addr}", "raw_address": 0, "address": addr, + "project_uid": None, "dpt": {"main": dmain, "sub": dsub}, "data_secure": False, + "communication_object_ids": [], "description": "", "comment": ""} + + +def _proj(gas, ranges=None): + raw = {"info": {"group_address_style": "ThreeLevel", "schema_version": "21"}, + "group_addresses": gas, "communication_objects": {}, "devices": {}, + "functions": {}, "topology": {}, "group_ranges": ranges or {}} + return build_loaded_from_raw(raw, "t.knxproj") + + +def main(): + # --- word-boundary safety: 'ac' must NOT fire inside 'terrace' --- + assert _domain_from_text("Terrace light") == "lighting", "'ac' fired inside 'terrace'" + assert _domain_from_text("Living room AC") == "hvac" + assert _domain_from_text("Кухня кондиционер") == "hvac" + assert _domain_from_text("Bedroom blind position") == "shutter" + assert _domain_from_text("Relay 3") is None # no domain word -> None + assert _domain_from_text("Kitchen socket") is None # sockets aren't a modelled domain + + # --- name over an agnostic 1-bit DPT: AC on/off is HVAC, not lighting --- + for variant in ("Living room AC on/off", "A/C кухня", "air conditioner bedroom", + "Кондиционер гостиная"): + assert _classify_category(variant, "", "", 1, 1, "lighting") == "hvac", variant + + # --- a bare, context-less 1-bit switch is honestly unknown (not 'lighting') --- + assert _classify_category("Relay 3", "", "", 1, 1, "lighting") == "unknown" + assert _classify_category("Living room fireplace on/off", "", "", 1, 1, "lighting") == "unknown" + + # --- main-group context disambiguates a soft DPT when the name is silent --- + assert _classify_category("Relay 3", "Lighting", "Switch", 1, 1, "lighting") == "lighting" + assert _classify_category("Kanal 5", "Освещение", "", 1, 1, "lighting") == "lighting" + # but a NON-actuator range name (Scenes/Sensors) must NOT retype a 1-bit command + assert _classify_category("Fireplace on/off", "Central / Scenes", "", 1, 1, "lighting") == "unknown" + + # --- 5.001 is soft (brightness OR position): name disambiguates without conflict --- + assert _classify_category("Bedroom blind position", "", "", 5, 1, "lighting") == "shutter" + assert _classify_category("Kitchen pendant value", "", "", 5, 1, "lighting") == "lighting" # default + + # --- strong DPT contradicted by an explicit name -> honest unknown (a conflict) --- + assert _classify_category("Bathroom fan", "", "", 3, 7, "lighting") == "unknown" # dimming vs fan + assert _classify_category("Foyer blind", "", "", 9, 1, "hvac") == "unknown" # temp DPT vs blind + # ...but agreement keeps the domain + assert _classify_category("Living room dimmer", "", "", 3, 7, "lighting") == "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" + + 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'.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_explain.py b/tests/test_explain.py index 81253b1..5043bb1 100644 --- a/tests/test_explain.py +++ b/tests/test_explain.py @@ -18,33 +18,48 @@ def _proj(gas): def main(): gas = { - "3/2/1": _ga("3/2/1", "Living room AC on/off", 1, 1), # DPT lighting, name hvac -> conflict - "1/0/1": _ga("1/0/1", "Kitchen light switch", 1, 1), # clean lighting command + # 1-bit switch (domain-agnostic DPT) named "AC" -> correctly HVAC, NOT a + # conflict: the DPT does not encode a domain, the name legitimately does. + "3/2/1": _ga("3/2/1", "Living room AC on/off", 1, 1), + # strong, domain-encoding DPT (3.007 dimming = lighting) named for another + # domain ("fan" = hvac) -> a GENUINE conflict -> 'unknown' / contested. + "3/3/1": _ga("3/3/1", "Bathroom fan", 3, 7), + "1/0/1": _ga("1/0/1", "Kitchen light switch", 1, 1), # name-derived lighting command "1/4/1": _ga("1/4/1", "Kitchen light status", 1, 11), # its status } p = _proj(gas) - # 1. conflict surfaced + confidence contested + # 1. AC on/off is now correctly HVAC (name over an agnostic DPT), no conflict ac = explain_ga(p, "3/2/1") - assert ac["category"]["value"] == "lighting" - assert ac["conflicts"] != "none" and any("hvac" in c for c in ac["conflicts"]), ac["conflicts"] - assert ac["confidence"] == "contested", ac["confidence"] - assert any(e["tier"] == "structural" for e in ac["category"]["evidence"]) + assert ac["category"]["value"] == "hvac", ac["category"] + assert ac["conflicts"] == "none", ac["conflicts"] + assert ac["confidence"] == "heuristic", ac["confidence"] # name decided it + assert any(e["tier"] == "structural" for e in ac["category"]["evidence"]) # DPT still shown + assert any("hvac" in e["signal"] for e in ac["category"]["evidence"]) - # 2. clean lighting switch: no conflict, structural evidence, paired to its status by name token + # 2. strong DPT vs name -> genuine conflict, contested, resolved to unknown + fan = explain_ga(p, "3/3/1") + assert fan["category"]["value"] == "unknown", fan["category"] + assert fan["conflicts"] != "none" and any("hvac" in c for c in fan["conflicts"]), fan["conflicts"] + assert fan["confidence"] == "contested", fan["confidence"] + + # 3. name-derived lighting switch: no conflict, heuristic (name, not the agnostic + # DPT), paired to its status by name token sw = explain_ga(p, "1/0/1") + assert sw["category"]["value"] == "lighting", sw["category"] assert sw["conflicts"] == "none", sw["conflicts"] - assert sw["confidence"] == "structural", sw["confidence"] + assert sw["confidence"] == "heuristic", sw["confidence"] assert sw["status_pairing"]["paired"] is True assert sw["status_pairing"]["method"] == "name_token" assert sw["status_pairing"]["status"] == "1/4/1" assert sw["status_pairing"]["tier"] == "heuristic" - # 3. missing address -> error + # 4. missing address -> error assert "error" in explain_ga(p, "9/9/9") - print("test_explain: OK — AC on/off flagged as name/DPT conflict (contested); clean " - "switch is structural + name-token paired to its status; missing address errors.") + print("test_explain: OK — AC on/off is HVAC via name over an agnostic DPT (no conflict); " + "a strong DPT contradicting the name is contested/unknown; name-derived lighting " + "switch is heuristic + name-token paired; missing address errors.") if __name__ == "__main__": diff --git a/tests/test_policy.py b/tests/test_policy.py index 12b651f..5c3a623 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -25,10 +25,10 @@ def _project(): # main 1: three lighting (1.001) + ONE shutter (1.008) -> the shutter is the deviant # main 2: three shutter (1.008) -> consistent gas = { - "1/0/1": _ga("1/0/1", "Kitchen A", 1, 1), - "1/0/2": _ga("1/0/2", "Kitchen B", 1, 1), - "1/0/3": _ga("1/0/3", "Kitchen C", 1, 1), - "1/0/4": _ga("1/0/4", "Odd one here", 1, 8), # shutter DPT in the lighting main + "1/0/1": _ga("1/0/1", "Kitchen light A", 1, 1), + "1/0/2": _ga("1/0/2", "Kitchen light B", 1, 1), + "1/0/3": _ga("1/0/3", "Kitchen light C", 1, 1), + "1/0/4": _ga("1/0/4", "Odd blind here", 1, 8), # shutter DPT in the lighting main "2/0/1": _ga("2/0/1", "Zone one", 1, 8), "2/0/2": _ga("2/0/2", "Zone two", 1, 8), "2/0/3": _ga("2/0/3", "Zone three", 1, 8),