Files
giglez/scripts/benchmark_phase0.py
T
leetcrypt 1b9b33fa3f feat: Phase 0 accuracy fix - category routing + confidence calibration
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>
2026-07-18 13:09:43 -07:00

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()