feat(param_check): significance layer — a focus list of config-value outliers

Rank findings by significance: config_value (setpoint/hysteresis/time/threshold —
an odd device is usually a real mistake) vs config_flag (mode/type) vs label
(per-room text, often intentional). New focus list surfaces the config-value clear
outliers first. On a real 275-device project this turns 422 raw outliers into a
9-item focus that includes the thermostats whose init-setpoint differs from their
siblings. Multilingual name hints (EN/DE/RU). Test updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay1
2026-07-14 22:28:32 +02:00
co-authored by Claude Opus 4.8
parent 81799169c5
commit 1e924ede6f
3 changed files with 53 additions and 11 deletions
+6 -2
View File
@@ -23,8 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
flags the odd one out: `clear_outliers` (a strong majority with a small minority — e.g. one
thermostat with a different setpoint/hysteresis, one presence detector with a different
detection time) and `split_configs` (balanced 2+ variants — review, often two zones).
Parameter names resolved from the app-program. Read-only; no ETS/bus; encrypted projects are
skipped honestly. Validated on real 42–275-device projects; synthetic `tests/test_param_check.py`.
Parameter names resolved from the app-program. Findings are ranked by **significance**: a
`focus` list of *config-value* outliers (setpoint/hysteresis/time/threshold — where an odd device
is usually a real mistake), separated from mode/type flags and per-room text labels — on a real
275-device project this turns 422 raw outliers into a 9-item focus (incl. the thermostats whose
init-setpoint differs from their siblings). Read-only; no ETS/bus; encrypted projects are skipped
honestly. Validated on real 42–275-device projects; synthetic `tests/test_param_check.py`.
Community-driven (asked for in Discussions). Tool count 25 → 26.
- **`skills/ha-git-backup`** — an ops-companion skill for the engineer package: a two-circuit
+43 -9
View File
@@ -28,6 +28,25 @@ from typing import Any, Optional
_NUMERIC_RE = re.compile(r"^-?\d+$")
# Parameter-name hints for a meaningful numeric CONFIG VALUE — the kind of setting
# where an odd value is usually a real mistake (a different hysteresis / setpoint /
# timeout on one device). Multilingual (EN / DE / RU). Everything numeric but not a
# value is a "config_flag" (mode/type/enable); non-numeric is a "label" (often a
# deliberate per-room name, lowest priority).
_VALUE_HINTS = (
"setpoint", "hyster", "temperatur", "threshold", "delay", "duration", "timeout",
"time", "period", "level", "speed", "offset", "value", "brightness", "scaling",
"уставк", "гистерез", "температур", "порог", "задержк", "время", "период",
"длительн", "уровень", "скорост", "яркост", "значени",
)
def _significance(name: str, numeric: bool) -> str:
if not numeric:
return "label"
low = (name or "").lower()
return "config_value" if any(h in low for h in _VALUE_HINTS) else "config_flag"
def _localname(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
@@ -150,16 +169,26 @@ def check_device_parameters(path: str, password: Optional[str] = None,
names = _resolve_names(zf, refids_needed)
_SIG_RANK = {"config_value": 0, "config_flag": 1, "label": 2}
def _decorate(rec: dict[str, Any]) -> dict[str, Any]:
rec["parameter"] = names.get(rec["refid"], rec["refid"])
rec["name_resolved"] = rec["refid"] in names
rec["significance"] = _significance(rec["parameter"], rec["numeric"])
return rec
# numeric config outliers first (time/setpoint/hysteresis-like), then the rest
# most meaningful first: config_value (setpoint/hysteresis/time…) > flag > label
clear = [_decorate(r) for r in clear]
splits = [_decorate(r) for r in splits]
clear.sort(key=lambda r: (not r["numeric"], len(r["odd_devices"]), -r["total"]))
splits.sort(key=lambda r: (not r["numeric"], -r["total"]))
clear.sort(key=lambda r: (_SIG_RANK[r["significance"]], len(r["odd_devices"]), -r["total"]))
splits.sort(key=lambda r: (_SIG_RANK[r["significance"]], -r["total"]))
def _by_sig(items: list[dict[str, Any]]) -> dict[str, int]:
c = Counter(r["significance"] for r in items)
return {k: c.get(k, 0) for k in ("config_value", "config_flag", "label")}
# the short list an integrator should actually look at first
focus = [r for r in clear if r["significance"] == "config_value"]
grp_sizes = sorted((len(v) for v in groups.values() if len(v) >= min_group), reverse=True)
return {
@@ -168,13 +197,18 @@ def check_device_parameters(path: str, password: Optional[str] = None,
"largest_groups": grp_sizes[:8],
"clear_outliers_count": len(clear),
"split_configs_count": len(splits),
"focus_count": len(focus),
"focus": focus[:max_findings],
"clear_outliers_by_significance": _by_sig(clear),
"split_configs_by_significance": _by_sig(splits),
"clear_outliers": clear[:max_findings],
"split_configs": splits[:max_findings],
"names_unresolved": sum(1 for r in (clear + splits) if not r["name_resolved"]),
"note": "clear_outliers = a device whose value differs from its N identical "
"siblings (likely a mistake). split_configs = the group splits into "
"balanced variants (review — often two zones/roles, not an error). "
"Numeric config parameters (times/setpoints/hysteresis) are listed first. "
"Read-only; parameter values come from P-*/0.xml (xknxproject does not "
"expose them). Some module-definition parameter names may stay as RefIds.",
"note": "START WITH `focus` — clear outliers on a config VALUE parameter "
"(setpoint/hysteresis/time/threshold…), where an odd device is usually a "
"real mistake. `clear_outliers` also includes config_flag (mode/type) and "
"label (per-room text, often intentional) — see *_by_significance. "
"split_configs = the group splits into balanced variants (review — often two "
"zones, not an error). Read-only; values come from P-*/0.xml (xknxproject does "
"not expose them). Some module-definition parameter names may stay as RefIds.",
}
+4
View File
@@ -75,6 +75,10 @@ def main():
assert h["majority_value"] == "5"
assert [d["value"] for d in h["odd_devices"]] == ["10"], h["odd_devices"]
assert h["odd_devices"][0]["address"] == "10"
# significance: "Hysteresis (K)" is a config VALUE -> shows up in focus
assert h["significance"] == "config_value", h["significance"]
assert r["focus_count"] >= 1
assert any(o["refid"].endswith("R-1") for o in r["focus"]), r["focus"]
# uniform param (mode, all "2") must NOT appear anywhere
assert not any(o["refid"].endswith("R-2") for o in r["clear_outliers"] + r["split_configs"])