feat: GA-intent classification to cut real-project noise (Track D)

Classify every group address as functional/reserve/logic/scratch (intent.py)
and reclassify intentional non-functional GAs instead of crying wolf:
- reserve spare with no DPT -> INFO reserve_without_dpt (not a missing_dpt error)
- reserve name reused across DPTs -> no duplicate_name / inconsistent_dpt
- logic/virtual + scratch GAs -> excluded from missing-status warnings
dpt_mismatch_co and all real functional findings are untouched.

Driven by a real 685-GA Zennio project: false errors 29 -> 6,
missing-status noise 79 -> 45, all 12 real dpt_mismatch_co catches preserved.
Intent breakdown now exposed via list_group_addresses, report inventory and
the analyze_all summary. New regression test covers the reserve/logic/scratch
patterns and proves real DPT + status problems still surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay Miroshnichenko
2026-06-30 19:48:28 +02:00
co-authored by Claude Opus 4.8
parent 9a8d87869a
commit 2ffb6d371f
7 changed files with 192 additions and 7 deletions
+11
View File
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **GA-intent classification — noise reduction on real projects** (`intent.py`). Every group
address is now classified as `functional` / `reserve` / `logic` / `scratch`. Intentional
non-functional GAs no longer "cry wolf": reserve spares with no DPT become an INFO note
(`reserve_without_dpt`) instead of a 🔴 error; reserve names repeated across different DPTs
are not flagged `duplicate_name` / `inconsistent_dpt`; internal logic / virtual signals and
scratch leftovers are excluded from missing-status warnings. The `dpt_mismatch_co` check and
every real functional finding are untouched. Driven by a real 685-GA Zennio project where
this cut false errors **29 → 6** and missing-status noise **79 → 45** while preserving all
12 real `dpt_mismatch_co` catches. `list_group_addresses`, the report inventory and the
`analyze_all` summary now expose the intent breakdown. New regression test covers the
reserve / logic / scratch patterns (and proves real DPT + status problems still surface).
- `docs/` — a self-contained GitHub Pages landing site (project overview, two-layer
architecture, demo-house stats, an interactive 5-tab dashboard preview, the HA "brain",
and a call for testers).
+29 -6
View File
@@ -16,6 +16,7 @@ from typing import Any, Optional
from .project import LoadedProject, GARecord, STATUS_KEYWORDS
from .pairing import find_status, function_status_pairs
from .intent import INTENT_FUNCTIONAL, INTENT_RESERVE, INTENT_SCRATCH
SEVERITY_ERROR = "error"
SEVERITY_WARN = "warning"
@@ -58,6 +59,12 @@ def validate_naming(project: LoadedProject,
))
continue
# Reserve / logic / scratch GAs are intentional placeholders — short
# names, repeated "Резерв", non-conventional names are expected, so they
# must not raise naming warnings (that is the noise we are removing).
if ga.intent != INTENT_FUNCTIONAL:
continue
seen_names[name.lower()].append(addr)
if len(name) < min_name_len:
@@ -180,6 +187,8 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]:
for addr, ga in project.gas.items():
if addr in covered:
continue
if ga.intent != INTENT_FUNCTIONAL:
continue # reserve / logic / scratch GAs need no status by design
if ga.kind != "command":
continue
if ga.dpt_main is None:
@@ -207,12 +216,22 @@ def detect_dpt_issues(project: LoadedProject) -> list[dict[str, Any]]:
# 1. missing DPT
for addr, ga in project.gas.items():
if ga.dpt_main is None:
findings.append(_finding(
SEVERITY_ERROR, "missing_dpt", addr,
f"'{ga.name}' has no DPT assigned. Home Assistant requires a DPT "
"to decode this group address.",
name=ga.name,
))
# A reserve / scratch GA with no DPT is intentional (a spare), so it
# is an INFO note, not a 🔴 error that blocks the project.
if ga.intent in (INTENT_RESERVE, INTENT_SCRATCH):
findings.append(_finding(
SEVERITY_INFO, "reserve_without_dpt", addr,
f"'{ga.name}' is a {ga.intent} placeholder with no DPT — "
"intentional, assign a DPT only when you start using it.",
name=ga.name, intent=ga.intent,
))
else:
findings.append(_finding(
SEVERITY_ERROR, "missing_dpt", addr,
f"'{ga.name}' has no DPT assigned. Home Assistant requires a DPT "
"to decode this group address.",
name=ga.name,
))
# 2. CO <-> GA dpt mismatch
cos = project.raw.get("communication_objects", {})
@@ -242,6 +261,10 @@ def detect_dpt_issues(project: LoadedProject) -> list[dict[str, Any]]:
if ga.name.strip():
by_name[ga.name.strip().lower()].append(ga)
for low, recs in by_name.items():
# Reserve / logic / scratch GAs intentionally share a generic name
# ("Резерв") across different DPTs — not an inconsistency.
if recs[0].intent != INTENT_FUNCTIONAL:
continue
dpts = {r.dpt for r in recs if r.dpt}
if len(dpts) > 1:
findings.append(_finding(
+71
View File
@@ -0,0 +1,71 @@
"""Classify a group address by INTENT: functional / reserve / logic / scratch.
Real ETS projects are full of intentional NON-functional group addresses:
* **reserve** — spare placeholders ("Резерв", "Reserve", "Spare") left for
future use, often with no DPT on purpose;
* **logic** — internal/virtual signals used only inside the bus program
(intermediate logic results, summed signals, time markers, request triggers)
that have no physical state to read back;
* **scratch** — test/leftover GAs ("Новый групповой адрес", a bare "1").
Treating these like functional device GAs is what makes the tool "cry wolf" on a
real project: a spare with no DPT becomes a 🔴 error, a logic signal becomes a
"missing status" 🟡 warning. This classifier lets the checks reclassify that
noise (downgrade / skip) instead of drowning the real findings.
Name-based and deliberately conservative: when unsure we return ``functional`` so
a real problem is never hidden. Multilingual (RU / EN / DE).
"""
from __future__ import annotations
import re
INTENT_FUNCTIONAL = "functional"
INTENT_RESERVE = "reserve"
INTENT_LOGIC = "logic"
INTENT_SCRATCH = "scratch"
# Spare / reserved placeholders. "резерв" lower-cases both "Резерв" and "РЕЗЕРВ".
_RESERVE_TOKENS = (
"резерв", "reserve", "reserved", "spare", "запас", "не использ", "unused",
"не задейств",
)
# Internal logic / virtual signals — no physical status by design.
_LOGIC_TOKENS = (
"промежуточн", "суммарн", "логик", "logic", "метка времени", "выход ф-ци",
"ф-ция управлен", "ф-ции управлен", "ф-ция упр", "дубль", "вспомогат",
"intermediate", "internal signal", "virtual", "запрос", "request",
"годовой период", "сигнал включения", "сигнал отключения",
)
# Known scratch / default ETS names (exact, lower-cased).
_SCRATCH_EXACT = (
"новый групповой адрес", "new group address", "new group object",
"neue gruppenadresse",
)
def classify_intent(name: str) -> str:
"""Return one of functional / reserve / logic / scratch for a GA name."""
raw = (name or "").strip()
if not raw:
# Empty name is a separate, real problem (``empty_name``); leave it
# functional so that check still fires.
return INTENT_FUNCTIONAL
low = raw.lower()
if low in _SCRATCH_EXACT:
return INTENT_SCRATCH
# Bare numeric placeholders like "1", "5", "12" — classic leftovers.
if re.fullmatch(r"\d{1,3}", raw):
return INTENT_SCRATCH
if any(t in low for t in _RESERVE_TOKENS):
return INTENT_RESERVE
if any(t in low for t in _LOGIC_TOKENS):
return INTENT_LOGIC
return INTENT_FUNCTIONAL
+6
View File
@@ -15,6 +15,7 @@ from xknxproject import XKNXProj
from xknxproject.models import KNXProject
from .dpt_map import classify_dpt, dpt_key
from .intent import classify_intent, INTENT_FUNCTIONAL
# Multilingual keyword sets (EN / DE / RU) used by the heuristic fallbacks.
@@ -71,6 +72,10 @@ class GARecord:
ha_platform: str
value_type: Optional[str]
label: str
# Purpose of the GA: functional / reserve / logic / scratch. Non-functional
# GAs are intentional noise (spares, internal logic, leftovers) and the
# checks reclassify them instead of raising false errors/warnings.
intent: str = INTENT_FUNCTIONAL
# 3-level decomposition
main: Optional[int] = None
middle: Optional[int] = None
@@ -203,6 +208,7 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject:
ha_platform=ha_platform,
value_type=info["value_type"],
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 "",
+11
View File
@@ -43,8 +43,10 @@ def build_report(project: LoadedProject,
cat_counts = Counter(ga.category for ga in gas.values())
kind_counts = Counter(ga.kind for ga in gas.values())
intent_counts = Counter(ga.intent for ga in gas.values())
no_dpt = sum(1 for ga in gas.values() if ga.dpt_main is None)
secure = sum(1 for ga in gas.values() if ga.data_secure)
non_functional = sum(v for k, v in intent_counts.items() if k != "functional")
all_findings = naming + status + dpts
sev_counts = Counter(f["severity"] for f in all_findings)
@@ -71,6 +73,14 @@ def build_report(project: LoadedProject,
", ".join(f"{k}={v}" for k, v in sorted(cat_counts.items())) + "\n")
md.append("**By kind:** " +
", ".join(f"{k}={v}" for k, v in sorted(kind_counts.items())) + "\n")
if non_functional:
md.append(
"**By purpose:** " +
", ".join(f"{k}={v}" for k, v in sorted(intent_counts.items())) +
f" — {non_functional} non-functional GA(s) (reserve / logic / scratch) "
"are excluded from error and missing-status checks to keep the report "
"focused on real device addresses.\n"
)
md.append("\n## 2. Findings\n")
md.append(
@@ -112,6 +122,7 @@ def build_report(project: LoadedProject,
"warnings": sev_counts.get("warning", 0),
"info": sev_counts.get("info", 0),
"missing_status": len(status),
"intent": dict(intent_counts),
"ha_entities": {k: v for k, v in c.items() if k != "review"},
"ha_review": c["review"],
}
+1 -1
View File
@@ -103,7 +103,7 @@ def list_group_addresses(category: Optional[str] = None,
continue
out.append({
"address": ga.address, "name": ga.name, "dpt": ga.dpt,
"category": ga.category, "kind": ga.kind,
"category": ga.category, "kind": ga.kind, "intent": ga.intent,
"ha_platform": ga.ha_platform, "secure": ga.data_secure,
"description": ga.description,
})
+63
View File
@@ -250,3 +250,66 @@ _covers7 = _pkg7.get("cover", [])
assert len(_covers7) == 1 and _covers7[0].get("move_short_address") == "2/0/1", _covers7
assert any(r["reason"] == "manual_datetime" for r in _ha7["review"]), "datetime not routed to review"
print("OK: full light entity (no dup switch); 1.001/1.017 cover; datetime -> review")
# --------------------------------------------------------------------------- #
# Regression (Track D): GA-intent classification removes real-project NOISE.
# Patterns taken verbatim from the real Minsk (Zennio) project, where reserve
# spares + internal logic GAs produced ~29 false errors and dozens of false
# missing-status warnings. Non-functional GAs must be reclassified, while a real
# functional problem must STILL be flagged (no over-suppression).
# --------------------------------------------------------------------------- #
print("\n=== REGRESSION: GA-intent noise reduction (reserve / logic / scratch) ===")
from nickol_knx_mcp.intent import (
classify_intent, INTENT_RESERVE, INTENT_LOGIC, INTENT_SCRATCH, INTENT_FUNCTIONAL,
)
# Direct classifier checks on real names.
assert classify_intent("Резерв") == INTENT_RESERVE
assert classify_intent("РЕЗЕРВ") == INTENT_RESERVE
assert classify_intent("Промежуточный результат логики индикации") == INTENT_LOGIC
assert classify_intent("Метеостанция - Запрос температуры") == INTENT_LOGIC
assert classify_intent("суммарный сигнал включения групп света") == INTENT_LOGIC
assert classify_intent("1") == INTENT_SCRATCH
assert classify_intent("Новый групповой адрес") == INTENT_SCRATCH
assert classify_intent("Kitchen ceiling light switch") == INTENT_FUNCTIONAL
_rawD = {"group_addresses": {
# reserve spares: no DPT (intentional) + same name, different DPTs
"0/1/8": ga("0/1/8", "Резерв", None, None),
"1/2/49": ga("1/2/49", "Резерв", 5, 1),
"1/2/50": ga("1/2/50", "Резерв", 1, 3),
# internal logic command with no status (should NOT be flagged)
"1/1/7": ga("1/1/7", "Промежуточный результат логики индикации", 1, 1),
"8/0/18": ga("8/0/18", "Метеостанция - Запрос температуры", 1, 17),
# scratch leftover
"9/2/0": ga("9/2/0", "1", 1, 1),
# a REAL functional problem that must survive: missing DPT + missing status
"3/1/21": ga("3/1/21", "07. Спальня - выход Д СО - порог 2", None, None),
"1/0/0": ga("1/0/0", "Kitchen ceiling light switch", 1, 1),
}}
_pD = build_loaded_from_raw(_rawD, "mem")
assert _pD.gas["0/1/8"].intent == INTENT_RESERVE
assert _pD.gas["1/1/7"].intent == INTENT_LOGIC
_dptD = detect_dpt_issues(_pD)
_codes = {(f["code"], f["address"]) for f in _dptD}
# reserve with no DPT -> INFO reserve_without_dpt, NOT a missing_dpt error
assert ("reserve_without_dpt", "0/1/8") in _codes, _codes
assert ("missing_dpt", "0/1/8") not in _codes, "reserve wrongly raised a DPT error"
# functional missing DPT still a real error
assert ("missing_dpt", "3/1/21") in _codes, "real missing DPT was suppressed!"
# "Резерв" reused with different DPTs is NOT an inconsistency
assert not any(f["code"] == "inconsistent_dpt" for f in _dptD), "reserve flagged inconsistent_dpt"
_naflD = validate_naming(_pD)
# reserve dupes + scratch short name must not raise naming warnings
assert not any(f["code"] == "duplicate_name" for f in _naflD), "reserve flagged duplicate_name"
assert not any(f["code"] == "name_too_short" for f in _naflD), "scratch '1' flagged name_too_short"
_msD = {f["address"] for f in detect_missing_status(_pD)}
assert "1/1/7" not in _msD, "logic GA wrongly flagged missing_status"
assert "8/0/18" not in _msD, "weather request wrongly flagged missing_status"
assert "9/2/0" not in _msD, "scratch GA wrongly flagged missing_status"
# the real functional switch with no status IS still flagged
assert "1/0/0" in _msD, "real functional command lost its missing-status warning!"
print("OK: reserve/logic/scratch de-noised; real DPT + status problems preserved")