feat(P5): contextual domain classification — DPT is one signal, not the verdict

A 1-bit switch is domain-agnostic, yet DPT 1.001 defaulted to 'lighting', so an
"AC on/off" was classified lighting and tripped downstream checks. The domain is
now a combination: explicit name > strong domain-encoding DPT > main-group name;
a bare 1-bit GA with no signal is honestly 'unknown', never a guessed lighting.
An explicit name contradicting a STRONG DPT yields 'unknown' (a genuine conflict,
shown by explain_ga as contested), not a silent pick. Word-boundary matching so
"ac" no longer fires inside "terrace"; name terms broadened (EN+RU HVAC incl.
ac/a-c/air-conditioner, RGB/colour lighting).

check_policy taxonomy-outlier now flags only misplaced ACTUATORS (lighting/
shutter/hvac); sensors, scene links, thresholds and timeout params are the
cross-cutting GAs they are. HA generator still emits a switch for a now-HVAC
simple on/off, so no device is dropped.

Verified on 3 real projects: every AC on/off is HVAC; outlier noise fell demo
11->2, Minsk 31->6, Razdory 200->57. tests/test_domain_classifier.py (13/13 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay1
2026-07-15 10:44:04 +02:00
co-authored by Claude Opus 4.8
parent 1a60264c78
commit e9105d45aa
8 changed files with 277 additions and 67 deletions
+19
View File
@@ -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
+29 -20
View File
@@ -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"
+3 -3
View File
@@ -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)
+12 -2
View File
@@ -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:
+117 -26
View File
@@ -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
+66
View File
@@ -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()
+27 -12
View File
@@ -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__":
+4 -4
View File
@@ -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),