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:
Nikolay1
2026-07-15 11:27:22 +02:00
co-authored by Claude Opus 4.8
parent e9105d45aa
commit 3c086fe228
5 changed files with 66 additions and 17 deletions
+11
View File
@@ -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
+6
View File
@@ -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:
+3 -2
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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'.")