mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
fix(P5): classifier dogfood round — range-map depth bug, fallback-DPT softness, RU terms
Building a deliberately messy synthetic house instantly exposed four real gaps: - _build_range_name_map: a middle group 0 starts at the same raw address as its main, so the modulo heuristic let "Вкл/Выкл" clobber "Освещение" and the main-range rescue silently died. Depth in the range tree decides now. - a category from a whole-main DPT fallback (e.g. 20.609 -> hvac) was treated as strong; only an exact (main,sub) table entry is strong now (is_exact_dpt). - DPT 1.010 start/stop marked soft: ventilation timers use it, not just shutters. - terms learned: Cyrillic а/с, сплит, вытяжк, яркост (RU only); weather/метео in sensor, and sensor now outranks hvac so weather-station temperature is a sensor, not an HVAC actuator. Real-project noise after the round: Minsk 1 outlier/685 GAs (was 31 pre-P5), demo 2/239, Razdory 46/3646. Regressions in test_domain_classifier. 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
e9105d45aa
commit
3c086fe228
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+28
-15
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user