mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-30 03:41:58 +02:00
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>
85 lines
5.0 KiB
Python
85 lines
5.0 KiB
Python
"""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"
|
||
|
||
# --- 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'.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|