fix(P5): dogfood round 2 — presence is a sensor, 9.001 soft, passive-range rule

- DPT 1.018 occupancy: category sensor (was diagnostics; the name always says
  sensor and the table fought it into a fake conflict).
- 9.001 temperature is SOFT: room temperature defaults to hvac, but a weather-
  named temperature re-domains to sensor without a fabricated conflict.
- illuminance terms: "освещённость"/"illuminance" are sensor words and must not
  be swallowed by the greedy lighting prefix "освещ".
- passive-range rule: a measurement inside a Sensors/Energy/Diagnostics main
  takes that main's domain; a COMMAND is never retyped by a passive range.

Deliberately NOT tuned further: remaining real-project findings (Minsk 10/685,
Razdory 75/3646, demo 2/239) are defensible mixed-main observations a reviewer
should see once — zeroing them out would overfit one integrator's idiosyncrasy.
13/13 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay1
2026-07-15 11:33:20 +02:00
co-authored by Claude Opus 4.8
parent 3c086fe228
commit ccfedbdeca
4 changed files with 29 additions and 8 deletions
+7 -1
View File
@@ -48,7 +48,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
**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.
HVAC actuator). Round 2: presence **1.018 is a sensor** (was diagnostics — its name always says so
and the table fought it); **9.001 temperature is soft** (room temp defaults to hvac, a weather-named
temp re-domains to sensor without a fake conflict); **illuminance** terms ("освещённость" ≠
"освещение"); and a **passive-range rule** — a measurement inside a Sensors/Energy/Diagnostics main
takes that domain, while a command is never retyped by a passive range. Real-project noise after
both rounds: demo 2 outliers / 239 GAs (the two central macros), Minsk 10 / 685 (all defensible —
a genuinely mixed water/energy main), Razdory 75 / 3646 (~2%).
### Added
+1 -1
View File
@@ -65,7 +65,7 @@ _EXACT: dict[tuple[int, Optional[int]], DptInfo] = {
(1, 9): {"category": CATEGORY_UNKNOWN, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Open/Close"},
(1, 10): {"category": CATEGORY_SHUTTER, "kind": KIND_COMMAND, "ha_platform": "cover", "value_type": None, "label": "Start/Stop"},
(1, 11): {"category": CATEGORY_LIGHTING, "kind": KIND_STATUS, "ha_platform": "binary_sensor", "value_type": None, "label": "State (status)"},
(1, 18): {"category": CATEGORY_DIAG, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Occupancy"},
(1, 18): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Occupancy"},
(1, 19): {"category": CATEGORY_DIAG, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Window/Door"},
(3, 7): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": None, "label": "Dimming control (relative)"},
(3, 8): {"category": CATEGORY_SHUTTER, "kind": KIND_COMMAND, "ha_platform": "cover", "value_type": None, "label": "Blinds control (relative)"},
+14 -5
View File
@@ -50,8 +50,9 @@ _DOMAIN_TERMS: dict[str, list[str]] = {
# датчик, 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", "датчик", "движени", "присутств", "влажн",
"люкс", "метео", "погод"],
"humidity", "weather", "meteo", "illuminance", "датчик", "движени",
"присутств", "влажн", "люкс", "метео", "погод",
"освещённост", "освещенност"], # illuminance ≠ "освещение" (checked first)
"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",
@@ -111,7 +112,9 @@ 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), (1, 10), (5, 1)} # 1.010 start/stop is generic (vent timers too)
# 1.010 start/stop is generic (vent timers too); 9.001 temperature is hvac by
# default but a weather/sensor name legitimately re-domains it without conflict.
_SOFT_DPT = {(1, 1), (1, 11), (1, 10), (5, 1), (9, 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.
@@ -119,7 +122,8 @@ _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:
main: Optional[int], sub: Optional[int], dpt_cat: str,
dpt_kind: str = "unknown") -> str:
"""Combine signals into a domain: explicit name > strong DPT > range context.
* an explicit name domain wins — unless it *contradicts* a strong (domain-
@@ -149,6 +153,11 @@ def _classify_category(name: str, main_name: str, middle_name: str,
range_dom = _domain_from_text(main_name) or _domain_from_text(middle_name)
if range_dom in _RANGE_ASSIGNABLE:
return range_dom
# Passive mains constrain passive GAs: a measurement sitting in a Sensors/
# Energy/Diagnostics range is that domain (a 9.001 in "Sensors" is a sensor,
# not a default hvac) — but a COMMAND is never retyped by a passive range.
if dpt_kind != "command" and range_dom in ("sensor", "energy", "diagnostics"):
return range_dom
if (main, sub) in _AGNOSTIC_NO_DEFAULT:
return CATEGORY_UNKNOWN_STR
return dpt_cat
@@ -289,7 +298,7 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject:
# 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"])
main, sub, info["category"], info["kind"])
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.
+7 -1
View File
@@ -49,7 +49,13 @@ def main():
# --- 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
assert _classify_category("Foyer blind", "", "", 9, 4, "sensor") == "unknown" # lux DPT vs blind
# 9.001 temperature is SOFT: a weather-named temp re-domains to sensor without conflict
assert _classify_category("Weather outdoor temperature", "", "", 9, 1, "hvac") == "sensor"
# a measurement in a passive (Sensors) main takes the range's domain...
assert _classify_category("Gostinaya temperatura", "Sensors", "", 9, 1, "hvac", "sensor") == "sensor"
# ...but a passive range never retypes a COMMAND
assert _classify_category("Kanal 7", "Sensors", "", 1, 1, "lighting", "command") == "unknown"
# ...but agreement keeps the domain
assert _classify_category("Living room dimmer", "", "", 3, 7, "lighting") == "lighting"