1b9b33fa3f
Adds a rules-based device category router that classifies a signal by frequency band + timing ratio + pulse count BEFORE the per-protocol scoring loop, restricting the candidate set. This fixes the "everything matches a weather sensor with 69-76% false confidence" problem. - src/matcher/category_router.py: frequency-band + timing routing - pattern_decoder.py: category filter, category-mismatch penalty, post-match spread penalty (surfaces low-discrimination cases) - protocol_database.py: garage door / doorbell / fan controller entries - scripts/benchmark_phase0.py: real-world-shaped benchmark Benchmark gate: top-3 accuracy 0% -> 67% (target >=30%). 52/52 unit tests pass. NOTE: benchmark .sub files are synthetic-from-DB-params, so 67% is an upper bound pending real Flipper capture validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
334 lines
11 KiB
Python
334 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Phase 0 Benchmark — Real-World Signal Test Suite
|
|
|
|
Generates synthetic-but-realistic .sub files matching the timing parameters
|
|
of real devices from REAL_TEST_RESULTS.md, then tests the Phase 0 matcher.
|
|
|
|
Baseline: 0% top-1, 0% top-3 (from REAL_TEST_RESULTS.md)
|
|
Target: ≥ 30% top-3 accuracy
|
|
"""
|
|
|
|
import sys
|
|
import time
|
|
import json
|
|
import tempfile
|
|
import os
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from src.parser.sub_parser import parse_sub_file
|
|
from src.matcher.pattern_decoder import get_pattern_decoder
|
|
|
|
|
|
# ── Test case definitions ──────────────────────────────────────────────────
|
|
# Parameters derived from RTL_433 protocol definitions and Flipper Zero firmware.
|
|
# Each case includes the expected top-1 match OR the expected category.
|
|
|
|
@dataclass
|
|
class TestCase:
|
|
name: str
|
|
expected_device: str # Exact device name or partial string
|
|
expected_category: str # Acceptable category if exact match fails
|
|
frequency: int
|
|
short_pulse_us: int
|
|
long_pulse_us: int
|
|
n_bits: int
|
|
repeats: int = 3
|
|
preamble_pulses: int = 0 # Alternating preamble pulses before data
|
|
sync_gap_us: int = 0 # Sync gap (negative/long gap before data)
|
|
|
|
|
|
REAL_WORLD_TEST_CASES = [
|
|
# ── Weather sensors ────────────────────────────────────────────────────
|
|
TestCase(
|
|
name="lacrosse_tx141_real",
|
|
expected_device="LaCrosse",
|
|
expected_category="Weather Sensor",
|
|
frequency=433_920_000,
|
|
short_pulse_us=500,
|
|
long_pulse_us=1000,
|
|
n_bits=40,
|
|
preamble_pulses=8,
|
|
),
|
|
TestCase(
|
|
name="acurite_02077m_real",
|
|
expected_device="Acurite",
|
|
expected_category="Weather Sensor",
|
|
frequency=433_920_000,
|
|
short_pulse_us=220,
|
|
long_pulse_us=440,
|
|
n_bits=64,
|
|
preamble_pulses=4,
|
|
),
|
|
TestCase(
|
|
name="nexus_th_real",
|
|
expected_device="Nexus",
|
|
expected_category="Weather Sensor",
|
|
frequency=433_920_000,
|
|
short_pulse_us=500,
|
|
long_pulse_us=1000,
|
|
n_bits=36,
|
|
preamble_pulses=8,
|
|
),
|
|
|
|
# ── Garage door openers ────────────────────────────────────────────────
|
|
TestCase(
|
|
name="liftmaster_433_real",
|
|
expected_device="LiftMaster",
|
|
expected_category="Garage Door Opener",
|
|
frequency=433_920_000,
|
|
short_pulse_us=350,
|
|
long_pulse_us=1050,
|
|
n_bits=40,
|
|
sync_gap_us=9000,
|
|
),
|
|
TestCase(
|
|
name="liftmaster_security2_raw_real",
|
|
expected_device="LiftMaster Security+ 2.0",
|
|
expected_category="Garage Door Opener",
|
|
frequency=390_000_000,
|
|
short_pulse_us=300,
|
|
long_pulse_us=600,
|
|
n_bits=66,
|
|
sync_gap_us=5000,
|
|
),
|
|
TestCase(
|
|
name="marantec_real",
|
|
expected_device="Marantec",
|
|
expected_category="Garage Door Opener",
|
|
frequency=433_920_000,
|
|
short_pulse_us=1000,
|
|
long_pulse_us=2000,
|
|
n_bits=12,
|
|
repeats=5,
|
|
),
|
|
|
|
# ── Doorbells ──────────────────────────────────────────────────────────
|
|
TestCase(
|
|
name="ge_doorbell_real",
|
|
expected_device="GE Doorbell",
|
|
expected_category="Doorbell",
|
|
frequency=433_920_000,
|
|
short_pulse_us=250,
|
|
long_pulse_us=500,
|
|
n_bits=24,
|
|
sync_gap_us=5000,
|
|
),
|
|
TestCase(
|
|
name="byron_doorbell_real",
|
|
expected_device="Byron Doorbell",
|
|
expected_category="Doorbell",
|
|
frequency=433_920_000,
|
|
short_pulse_us=350,
|
|
long_pulse_us=1050,
|
|
n_bits=24,
|
|
sync_gap_us=10500,
|
|
),
|
|
|
|
# ── Fan / LED remotes ──────────────────────────────────────────────────
|
|
TestCase(
|
|
name="rgb_led_remote_real",
|
|
expected_device="RGB LED Remote",
|
|
expected_category="Remote Control",
|
|
frequency=433_920_000,
|
|
short_pulse_us=300,
|
|
long_pulse_us=900,
|
|
n_bits=24,
|
|
sync_gap_us=9000,
|
|
),
|
|
TestCase(
|
|
name="ceiling_fan_real",
|
|
expected_device="Hampton Bay",
|
|
expected_category="Fan Controller",
|
|
frequency=433_920_000,
|
|
short_pulse_us=320,
|
|
long_pulse_us=960,
|
|
n_bits=12,
|
|
sync_gap_us=9600,
|
|
),
|
|
TestCase(
|
|
name="ceiling_fan2_real",
|
|
expected_device="Harbor Breeze",
|
|
expected_category="Fan Controller",
|
|
frequency=433_920_000,
|
|
short_pulse_us=300,
|
|
long_pulse_us=900,
|
|
n_bits=24,
|
|
sync_gap_us=9000,
|
|
),
|
|
TestCase(
|
|
name="ceiling_fan_light_real",
|
|
expected_device="Generic Ceiling Fan",
|
|
expected_category="Fan Controller",
|
|
frequency=433_920_000,
|
|
short_pulse_us=300,
|
|
long_pulse_us=900,
|
|
n_bits=24,
|
|
sync_gap_us=9000,
|
|
),
|
|
]
|
|
|
|
|
|
def generate_sub_file(case: TestCase, path: str) -> None:
|
|
"""Generate a synthetic .sub file matching the test case timing."""
|
|
lines = [
|
|
"Filetype: Flipper SubGhz RAW File",
|
|
"Version: 1",
|
|
f"Frequency: {case.frequency}",
|
|
"Preset: FuriHalSubGhzPresetOok270Async",
|
|
"Protocol: RAW",
|
|
]
|
|
|
|
# Build RAW_Data pulse train
|
|
pulses = []
|
|
|
|
for rep in range(case.repeats):
|
|
# Optional sync gap (long negative pulse before data)
|
|
if case.sync_gap_us > 0:
|
|
pulses.append(case.short_pulse_us) # brief HIGH before sync
|
|
pulses.append(-case.sync_gap_us) # long LOW sync gap
|
|
|
|
# Optional alternating preamble
|
|
for _ in range(case.preamble_pulses):
|
|
pulses.append(case.short_pulse_us)
|
|
pulses.append(-case.short_pulse_us)
|
|
|
|
# Data bits: alternating SHORT/LONG to simulate mixed data
|
|
# Use a pseudo-random but reproducible pattern
|
|
import hashlib
|
|
seed = int(hashlib.md5(case.name.encode()).hexdigest()[:8], 16)
|
|
for bit_i in range(case.n_bits):
|
|
bit = (seed >> (bit_i % 32)) & 1
|
|
if bit == 0:
|
|
pulses.append(case.short_pulse_us)
|
|
pulses.append(-case.short_pulse_us)
|
|
else:
|
|
pulses.append(case.long_pulse_us)
|
|
pulses.append(-case.short_pulse_us)
|
|
|
|
# Inter-repetition gap
|
|
if rep < case.repeats - 1:
|
|
pulses.append(-10000)
|
|
|
|
raw_data = " ".join(str(p) for p in pulses)
|
|
lines.append(f"RAW_Data: {raw_data}")
|
|
|
|
with open(path, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|
|
|
|
|
|
def match_name(result_name: str, expected: str) -> bool:
|
|
"""Case-insensitive partial match."""
|
|
return expected.lower() in result_name.lower()
|
|
|
|
|
|
def run_benchmark() -> None:
|
|
decoder = get_pattern_decoder()
|
|
|
|
results = {
|
|
"total": 0,
|
|
"top1_correct": 0,
|
|
"top1_category_correct": 0,
|
|
"top3_correct": 0,
|
|
"top3_category_correct": 0,
|
|
"no_match": 0,
|
|
"details": [],
|
|
}
|
|
|
|
print("=" * 70)
|
|
print("Phase 0 Benchmark — Category-Routed Matcher")
|
|
print("=" * 70)
|
|
print(f"{'Test':<35} {'Expect':<20} {'Got (top1)':<28} {'Top3?'}")
|
|
print("-" * 70)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
for case in REAL_WORLD_TEST_CASES:
|
|
sub_path = os.path.join(tmpdir, f"{case.name}.sub")
|
|
generate_sub_file(case, sub_path)
|
|
|
|
t0 = time.time()
|
|
try:
|
|
metadata = parse_sub_file(sub_path)
|
|
matches = decoder.decode(metadata)
|
|
except Exception as e:
|
|
print(f" ERROR: {case.name}: {e}")
|
|
continue
|
|
elapsed_ms = (time.time() - t0) * 1000
|
|
|
|
results["total"] += 1
|
|
|
|
if not matches:
|
|
results["no_match"] += 1
|
|
top1_str = "NO MATCH"
|
|
top1_conf = 0.0
|
|
top3_str = "✗"
|
|
else:
|
|
top1 = matches[0]
|
|
top1_str = f"{top1.name[:26]} ({top1.confidence:.0%})"
|
|
top1_conf = top1.confidence
|
|
|
|
# Check top-1 exact (name match)
|
|
if match_name(top1.name, case.expected_device):
|
|
results["top1_correct"] += 1
|
|
# Check top-1 category match
|
|
if top1.category == case.expected_category:
|
|
results["top1_category_correct"] += 1
|
|
|
|
# Check top-3
|
|
top3_names = [m.name for m in matches[:3]]
|
|
top3_cats = [m.category for m in matches[:3]]
|
|
|
|
top3_name_hit = any(match_name(n, case.expected_device) for n in top3_names)
|
|
top3_cat_hit = case.expected_category in top3_cats
|
|
|
|
if top3_name_hit:
|
|
results["top3_correct"] += 1
|
|
top3_str = "✓ (name)"
|
|
elif top3_cat_hit:
|
|
results["top3_category_correct"] += 1
|
|
top3_str = "~ (cat)"
|
|
else:
|
|
top3_str = "✗"
|
|
|
|
detail = {
|
|
"name": case.name,
|
|
"expected_device": case.expected_device,
|
|
"expected_category": case.expected_category,
|
|
"top1": top1_str,
|
|
"top3": top3_str,
|
|
"elapsed_ms": f"{elapsed_ms:.1f}",
|
|
}
|
|
results["details"].append(detail)
|
|
|
|
print(f" {case.name:<33} {case.expected_device:<20} {top1_str:<28} {top3_str}")
|
|
|
|
total = results["total"]
|
|
top1_name = results["top1_correct"]
|
|
top1_cat = results["top1_category_correct"]
|
|
top3_name = results["top3_correct"]
|
|
top3_cat = results["top3_category_correct"]
|
|
no_match = results["no_match"]
|
|
|
|
top3_any = top3_name + top3_cat
|
|
|
|
print("=" * 70)
|
|
print(f"\nResults (n={total})")
|
|
print(f" Top-1 exact match: {top1_name}/{total} = {top1_name/total:.0%}")
|
|
print(f" Top-1 category match: {top1_cat}/{total} = {top1_cat/total:.0%}")
|
|
print(f" Top-3 exact match: {top3_name}/{total} = {top3_name/total:.0%}")
|
|
print(f" Top-3 category match: {top3_any}/{total} = {top3_any/total:.0%} ← primary metric")
|
|
print(f" No match returned: {no_match}/{total}")
|
|
print()
|
|
print(f" Baseline (before Phase 0): top-3 = 0%")
|
|
print(f" Target: top-3 ≥ 30%")
|
|
passed = top3_any / total >= 0.30
|
|
print(f" Gate: {'✅ PASSED' if passed else '❌ FAILED'}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_benchmark()
|