Files
nickol-knx-mcp/tests/test_theben_corrections.py
Nikolay1andClaude Opus 4.8 bf7eba8898 room-library: Theben-benchmark corrections (dogfood on 5 official ETS examples)
Ran KNX Association-ecosystem vendor (Theben) 5 official ETS example .knxproj
through our QA. Findings folded back into the product:

- repair.py _infer_dpt: relative dimming (Brighter/darker, heller/dunkler,
  светлее/темнее) now infers 3.007 DPT_Control_Dimming instead of falling to
  1.001; explicit '% / value' stays 5.001; scene checked before the broad
  value branch. DPTs verified against XKNX/xknx via deepwiki.
- room_library.py: new reusable blocks distilled from vendor patterns —
  shutter_venetian (slat + slat-status + wind/frost safety-lock), climate_floor
  modulating actuating-value + status, ventilation (airing stage + CO2 +
  humidity), lighting_dali, multisensor_air, central scene/all-off/all-on.
- project.py classifier: airing/Lüftung/проветривание -> hvac (was unknown).
- analyze.py: safety_input_no_status INFO exemption for write-only wind/frost
  lock inputs (Theben central wind-safety pattern).
- SCHEMA.md: new function rows; automation_intents implementation: knx|external
  with safety_related => knx rule; DPT-tolerance note (missing-DPT hard error
  applies to generated output; ETS4 object-derived DPT on import is legitimate).
- templates: ventilation slot (bathroom/kitchen), opt-in venetian + wind/frost
  safety intent (living/bedroom), new house-level central.yaml.
- tests: +test_theben_corrections.py (10); golden bedroom 26->28 GA (climate
  actuating-value). pytest 20 passed (pre-existing unrelated test_protocol
  collection error untouched). senior-code-audit: APPROVED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:21:18 +02:00

169 lines
8.4 KiB
Python

"""Theben-benchmark corrections (see docs/roadmap/room-library/theben-benchmark/).
Covers:
(a) repair `_infer_dpt` — relative dimming (brighter/darker) -> 3.007, while an
explicit "Dimming value %"/"Brightness value" stays 5.001 (dogfood bug fix);
(b) the new/extended FUNCTION_OBJECTS (shutter_venetian, climate_floor valve,
ventilation, lighting_dali, multisensor_air, central_*) — GA counts, DPTs,
command/status pairing;
(c) the ventilation domain classifier (airing/проветривание/Lüftung -> hvac);
(d) end-to-end: a house using venetian blinds + central functions re-reads
through the standard loader with 0 errors / 0 warnings, and the wind/frost
safety-lock is an INFO (safety_input_no_status), never a missing-status.
DPT provenance: 3.007 = DPT_Control_Dimming (4-bit relative), 5.001 = Scaling %,
5.010 = 1-byte counter, 9.007 = humidity, 9.008 = CO₂ ppm, 20.102 = HVAC mode —
confirmed against the KNX DPT catalogue (XKNX/xknx via deepwiki).
"""
import os
import tempfile
from types import SimpleNamespace
from nickol_knx_mcp import room_library as rl
from nickol_knx_mcp.repair import _infer_dpt
from nickol_knx_mcp.project import load_project, _domain_from_text
from nickol_knx_mcp.analyze import (validate_naming, detect_missing_status,
detect_dpt_issues)
from nickol_knx_mcp.policy import check_policy, load_policy
# --------------------------------------------------------------------------- #
# (a) repair._infer_dpt — relative vs absolute dimming
# --------------------------------------------------------------------------- #
def _stub(name, kind="command", category="lighting", main=1):
return SimpleNamespace(name=name, kind=kind, category=category, main=main)
def test_infer_dpt_relative_dimming_is_3007():
# relative brighter/darker (EN/DE/RU) -> 3.007 DPT_Control_Dimming (was 1.001)
assert _infer_dpt(_stub("Brighter/ darker Light array 1")) == "3.007"
assert _infer_dpt(_stub("Heller / dunkler Wohnzimmer")) == "3.007"
assert _infer_dpt(_stub("Светлее/темнее гостиная")) == "3.007"
assert _infer_dpt(_stub("Ярче/тусклее спальня")) == "3.007"
def test_infer_dpt_explicit_value_stays_5001():
# explicit absolute value must NOT be flipped to relative — stays 5.001
assert _infer_dpt(_stub("Dimming value % Light array 1")) == "5.001"
assert _infer_dpt(_stub("Brightness value Kitchen")) == "5.001"
assert _infer_dpt(_stub("Значение яркости кухня")) == "5.001"
def test_infer_dpt_defaults_unchanged():
# a plain switch is still the safe 1.001 default (regression guard)
assert _infer_dpt(_stub("Спальня свет - Вкл/выкл")) == "1.001"
assert _infer_dpt(_stub("Hall switch")) == "1.001"
# --------------------------------------------------------------------------- #
# (b) new / extended FUNCTION_OBJECTS
# --------------------------------------------------------------------------- #
def test_function_objects_shutter_venetian():
sv = {o.role: o for o in rl.FUNCTION_OBJECTS["shutter_venetian"]}
assert len(rl.FUNCTION_OBJECTS["shutter_venetian"]) == 7 # shutter(4)+slat+slat_status+lock
assert (sv["slat"].dpt_main, sv["slat"].dpt_sub, sv["slat"].kind) == (5, 1, "command")
assert (sv["slat_status"].dpt_main, sv["slat_status"].dpt_sub, sv["slat_status"].kind) == (5, 1, "status")
assert (sv["safety_lock"].dpt_main, sv["safety_lock"].dpt_sub, sv["safety_lock"].kind) == (1, 1, "command")
# roller shutter is unchanged (backward compat)
assert len(rl.FUNCTION_OBJECTS["shutter"]) == 4
def test_function_objects_climate_floor_valve():
cf = {o.role: o for o in rl.FUNCTION_OBJECTS["climate_floor"]}
assert (cf["actuating_value"].dpt_main, cf["actuating_value"].dpt_sub,
cf["actuating_value"].kind) == (5, 1, "command")
assert (cf["actuating_value_status"].dpt_main, cf["actuating_value_status"].dpt_sub,
cf["actuating_value_status"].kind) == (5, 1, "status")
# the historical on/off + setpoint + mode + statuses + actual-temp remain
assert len(rl.FUNCTION_OBJECTS["climate_floor"]) == 9
def test_function_objects_ventilation():
v = {o.role: o for o in rl.FUNCTION_OBJECTS["ventilation"]}
assert (v["airing_stage"].dpt_main, v["airing_stage"].dpt_sub, v["airing_stage"].kind) == (5, 10, "command")
assert (v["airing_stage_status"].dpt_main, v["airing_stage_status"].dpt_sub) == (5, 10)
assert (v["co2"].dpt_main, v["co2"].dpt_sub, v["co2"].kind) == (9, 8, "sensor")
assert (v["humidity"].dpt_main, v["humidity"].dpt_sub, v["humidity"].kind) == (9, 7, "sensor")
def test_function_objects_dali_is_dimmer_clone():
dali = [(o.role, o.dpt_main, o.dpt_sub, o.kind) for o in rl.FUNCTION_OBJECTS["lighting_dali"]]
dim = [(o.role, o.dpt_main, o.dpt_sub, o.kind) for o in rl.FUNCTION_OBJECTS["lighting_dimmer"]]
assert dali == dim, "lighting_dali must be a 1:1 clone of lighting_dimmer"
assert rl.BOM_RECIPE["lighting_dali"] == "dali_gateway_group"
def test_function_objects_multisensor_and_central():
ms = rl.FUNCTION_OBJECTS["multisensor_air"]
assert {o.kind for o in ms} == {"sensor"} # self-reporting, no command -> no status pair
assert {(o.dpt_main, o.dpt_sub) for o in ms} == {(9, 1), (9, 7), (9, 8)}
assert rl.FUNCTION_OBJECTS["central_scene"][0].dpt_main == 18
assert rl.FUNCTION_OBJECTS["central_all_off"][0].dpt_main == 1
assert rl.FUNCTION_OBJECTS["central_all_on"][0].dpt_main == 1
# --------------------------------------------------------------------------- #
# (c) ventilation domain classifier (F5: "Airing step" was 'unknown')
# --------------------------------------------------------------------------- #
def test_ventilation_domain_classifier():
assert _domain_from_text("Airing step") == "hvac"
assert _domain_from_text("Проветривание кухня") == "hvac"
assert _domain_from_text("Lüftung Stufe 2") == "hvac"
assert _domain_from_text("Bad Lüften") == "hvac"
# regression: an existing lighting name is untouched by the new terms
assert _domain_from_text("Terrace light") == "lighting"
# --------------------------------------------------------------------------- #
# (d) end-to-end: venetian blinds + central functions -> lint-clean, safety-lock
# is INFO not a warning.
# --------------------------------------------------------------------------- #
def _reread(house):
data = rl.build_knxproj_bytes(house)
tf = tempfile.NamedTemporaryFile(suffix=".knxproj", delete=False)
try:
tf.write(data)
tf.close()
return load_project(tf.name)
finally:
os.unlink(tf.name)
def test_venetian_and_central_round_trip_clean():
rooms = [{"template": "living", "preset": "comfort",
"params": {"windows": 1, "venetian_windows": 2}},
{"template": "central", "preset": "comfort"}]
house = rl.resolve_house(rooms, rl.load_builtin_templates(), language="ru")
names = " ".join(o.name for o in house.objects)
assert "ламели" in names and "блокировка" in names, "venetian slat + safety-lock not emitted"
assert "сцена" in names and "всё выкл" in names, "central scene + all-off not emitted"
proj = _reread(house)
assert len(proj.gas) == len(house.objects)
findings = (validate_naming(proj) + detect_missing_status(proj) + detect_dpt_issues(proj)
+ check_policy(proj, load_policy(None))["findings"])
errors = [f for f in findings if f.get("severity") == "error"]
warnings = [f for f in findings if f.get("severity") == "warning"]
assert not errors, [(f["code"], f["address"]) for f in errors]
assert not warnings, [(f["code"], f["address"]) for f in warnings]
# the wind/frost safety-lock is a write-only input: INFO, never missing_status
codes = {(f["code"]) for f in detect_missing_status(proj)}
assert "safety_input_no_status" in codes
assert "missing_status_address" not in codes
def main():
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
fn()
print(f" {name}: OK")
print("test_theben_corrections: OK — relative dimming -> 3.007 (abs value stays "
"5.001); shutter_venetian/valve/ventilation/dali/multisensor/central objects "
"verified; airing -> hvac; venetian+central house is lint-clean; safety-lock "
"is INFO not missing-status.")
if __name__ == "__main__":
main()