mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
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:
co-authored by
Claude Opus 4.8
parent
1a60264c78
commit
e9105d45aa
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user