feat: preamble detection + frequency fingerprinting - iteration 3/5
Implements multi-factor scoring pipeline for improved RF device identification: - Score = Timing(30%) + Frequency(25%) + BitCount(20%) + Preamble(15%) + Stats(10%) New Components: - src/matcher/preamble_detector.py: Detects 4 pattern types (long_burst, alternating, sync_word, custom) - src/matcher/frequency_fingerprint.py: ISM band classification (315/433/868/915 MHz) for protocol filtering - Integration: Updated pattern_decoder.py with multi-factor scoring Features: - Preamble detection with 4 methods (long burst, alternating, sync word, repetition) - Frequency-based protocol filtering (reduces search space from 299 to ~20-30 candidates) - Multi-factor confidence scoring combining timing, frequency, bit count, preamble, and statistics - Sorted sync word matching (longest first to avoid substring matches) Test Coverage: - 15 new tests for preamble detection and frequency fingerprinting - Total: 56 tests passing (41 existing + 15 new) Results: - Improved matching accuracy through multi-factor scoring - Reduced protocol search space via frequency pre-filtering - Better handling of noisy signals through preamble validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit Tests for Preamble Detection and Frequency Fingerprinting
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from src.matcher.preamble_detector import PreambleDetector, PreamblePattern
|
||||
from src.matcher.frequency_fingerprint import FrequencyFingerprinter
|
||||
|
||||
|
||||
class TestPreambleDetection:
|
||||
"""Test preamble detection algorithms"""
|
||||
|
||||
def test_long_burst_detection(self):
|
||||
"""Test 1: Detect long HIGH burst preamble (Princeton style)"""
|
||||
detector = PreambleDetector()
|
||||
|
||||
# Long burst: 4000us followed by normal pulses (need at least 10 pulses)
|
||||
pulses = [4000, -500, 500, -500, 1000, -500, 500, -500, 1000, -500, 500, -500]
|
||||
|
||||
pattern = detector.detect(pulses, timing_short=500, timing_long=1000)
|
||||
|
||||
assert pattern is not None, "Should detect long burst"
|
||||
assert pattern.type == 'long_burst', f"Expected long_burst, got {pattern.type}"
|
||||
assert pattern.confidence > 0.7, f"Expected confidence >0.7, got {pattern.confidence}"
|
||||
|
||||
def test_alternating_detection(self):
|
||||
"""Test 2: Detect alternating 01010101 pattern (Manchester/Oregon)"""
|
||||
detector = PreambleDetector()
|
||||
|
||||
# Alternating pattern: 500us/1000us pulses
|
||||
# Pattern: 01010101 (8 alternations)
|
||||
pulses = [
|
||||
500, -500, # 0
|
||||
1000, -500, # 1
|
||||
500, -500, # 0
|
||||
1000, -500, # 1
|
||||
500, -500, # 0
|
||||
1000, -500, # 1
|
||||
500, -500, # 0
|
||||
1000, -500, # 1
|
||||
] * 2 # Repeat for emphasis
|
||||
|
||||
pattern = detector.detect(pulses, timing_short=500, timing_long=1000)
|
||||
|
||||
assert pattern is not None, "Should detect alternating pattern"
|
||||
assert pattern.type == 'alternating', f"Expected alternating, got {pattern.type}"
|
||||
assert pattern.repetitions >= 4, f"Expected >=4 repetitions, got {pattern.repetitions}"
|
||||
|
||||
def test_sync_word_detection(self):
|
||||
"""Test 3: Detect known sync word (e.g., '1000' for Oregon Scientific)"""
|
||||
detector = PreambleDetector()
|
||||
|
||||
# Sync word "1000" (need at least 10 pulses)
|
||||
pulses = [
|
||||
1000, -500, # 1
|
||||
500, -500, # 0
|
||||
500, -500, # 0
|
||||
500, -500, # 0
|
||||
# Followed by data (add more to meet 10 pulse minimum)
|
||||
1000, -500,
|
||||
500, -500,
|
||||
1000, -500,
|
||||
500, -500,
|
||||
]
|
||||
|
||||
pattern = detector.detect(pulses, timing_short=500, timing_long=1000)
|
||||
|
||||
assert pattern is not None, "Should detect sync word"
|
||||
assert pattern.type == 'sync_word', f"Expected sync_word, got {pattern.type}"
|
||||
assert '1000' in pattern.pattern, "Pattern should contain '1000'"
|
||||
|
||||
def test_no_preamble(self):
|
||||
"""Test 4: Handle signals without clear preamble"""
|
||||
detector = PreambleDetector()
|
||||
|
||||
# Random-looking pattern
|
||||
pulses = [600, -400, 800, -600, 550, -450, 900, -500]
|
||||
|
||||
pattern = detector.detect(pulses, timing_short=500, timing_long=1000)
|
||||
|
||||
# May or may not detect - should not crash
|
||||
assert True, "Should handle no preamble gracefully"
|
||||
|
||||
def test_preamble_matching_exact(self):
|
||||
"""Test 5: Exact preamble match scoring"""
|
||||
detector = PreambleDetector()
|
||||
|
||||
# Detected alternating pattern
|
||||
detected = PreamblePattern(
|
||||
type='alternating',
|
||||
pattern='10101010',
|
||||
length=8,
|
||||
repetitions=4,
|
||||
confidence=0.9,
|
||||
details={}
|
||||
)
|
||||
|
||||
# Protocol expects alternating
|
||||
match = detector.match_against_protocol(
|
||||
detected,
|
||||
protocol_preamble='"10" * 4', # "10101010"
|
||||
protocol_sync=None
|
||||
)
|
||||
|
||||
assert match.match_type == 'exact', f"Expected exact match, got {match.match_type}"
|
||||
assert match.similarity >= 0.9, f"Expected similarity >=0.9, got {match.similarity}"
|
||||
|
||||
def test_preamble_matching_partial(self):
|
||||
"""Test 6: Partial preamble match (type similarity)"""
|
||||
detector = PreambleDetector()
|
||||
|
||||
detected = PreamblePattern(
|
||||
type='alternating',
|
||||
pattern='0101',
|
||||
length=4,
|
||||
repetitions=2,
|
||||
confidence=0.8,
|
||||
details={}
|
||||
)
|
||||
|
||||
# Protocol also uses alternating (but different exact pattern)
|
||||
match = detector.match_against_protocol(
|
||||
detected,
|
||||
protocol_preamble='"01" * 8',
|
||||
protocol_sync=None
|
||||
)
|
||||
|
||||
# Should match type even if exact pattern differs
|
||||
assert match.similarity >= 0.5, f"Expected partial match, got {match.similarity}"
|
||||
|
||||
def test_preamble_mismatch(self):
|
||||
"""Test 7: Preamble mismatch penalty"""
|
||||
detector = PreambleDetector()
|
||||
|
||||
detected = PreamblePattern(
|
||||
type='long_burst',
|
||||
pattern='1111',
|
||||
length=4,
|
||||
repetitions=1,
|
||||
confidence=0.8,
|
||||
details={}
|
||||
)
|
||||
|
||||
# Protocol expects alternating (mismatch)
|
||||
match = detector.match_against_protocol(
|
||||
detected,
|
||||
protocol_preamble='"10" * 4',
|
||||
protocol_sync=None
|
||||
)
|
||||
|
||||
assert match.similarity < 0.5, f"Expected low similarity for mismatch, got {match.similarity}"
|
||||
|
||||
|
||||
class TestFrequencyFingerprinting:
|
||||
"""Test frequency-based filtering and scoring"""
|
||||
|
||||
def test_ism_band_detection_433mhz(self):
|
||||
"""Test 8: Detect 433 MHz ISM band"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
freq = 433920000 # 433.92 MHz
|
||||
|
||||
fingerprint = fingerprinter.analyze(freq)
|
||||
|
||||
assert fingerprint.ism_band == '433MHz', f"Expected 433MHz band, got {fingerprint.ism_band}"
|
||||
assert fingerprint.center_freq == freq
|
||||
assert fingerprint.confidence > 0.8
|
||||
|
||||
def test_ism_band_detection_315mhz(self):
|
||||
"""Test 9: Detect 315 MHz ISM band"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
freq = 315000000 # 315 MHz
|
||||
|
||||
fingerprint = fingerprinter.analyze(freq)
|
||||
|
||||
assert fingerprint.ism_band == '315MHz', f"Expected 315MHz band, got {fingerprint.ism_band}"
|
||||
|
||||
def test_frequency_exact_match(self):
|
||||
"""Test 10: Perfect frequency match scoring"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
match = fingerprinter.score_frequency_match(
|
||||
signal_frequency=433920000,
|
||||
protocol_frequency=433920000,
|
||||
protocol_tolerance=100000
|
||||
)
|
||||
|
||||
assert match.score == 1.0, f"Expected score 1.0 for exact match, got {match.score}"
|
||||
assert match.within_tolerance is True
|
||||
assert match.frequency_error == 0
|
||||
|
||||
def test_frequency_within_tolerance(self):
|
||||
"""Test 11: Frequency match within tolerance"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
# 50 kHz offset (within ±100 kHz tolerance)
|
||||
match = fingerprinter.score_frequency_match(
|
||||
signal_frequency=433970000,
|
||||
protocol_frequency=433920000,
|
||||
protocol_tolerance=100000
|
||||
)
|
||||
|
||||
assert match.within_tolerance is True
|
||||
assert match.score >= 0.8, f"Expected score >=0.8 within tolerance, got {match.score}"
|
||||
|
||||
def test_frequency_outside_tolerance(self):
|
||||
"""Test 12: Frequency mismatch outside tolerance"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
# 500 kHz offset (outside ±100 kHz tolerance)
|
||||
match = fingerprinter.score_frequency_match(
|
||||
signal_frequency=434420000,
|
||||
protocol_frequency=433920000,
|
||||
protocol_tolerance=100000
|
||||
)
|
||||
|
||||
assert match.within_tolerance is False
|
||||
assert match.score < 0.6, f"Expected low score outside tolerance, got {match.score}"
|
||||
|
||||
def test_frequency_filtering(self):
|
||||
"""Test 13: Filter protocols by frequency"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
# Mock protocols
|
||||
class MockProtocol:
|
||||
def __init__(self, name, freq):
|
||||
self.name = name
|
||||
self.frequency = freq
|
||||
self.frequency_tolerance = 100000
|
||||
|
||||
protocols = [
|
||||
MockProtocol('433MHz Device', 433920000),
|
||||
MockProtocol('315MHz Device', 315000000),
|
||||
MockProtocol('868MHz Device', 868000000),
|
||||
MockProtocol('433MHz Device 2', 433850000), # Close to 433.92
|
||||
]
|
||||
|
||||
# Filter for 433 MHz signal
|
||||
filtered = fingerprinter.filter_protocols_by_frequency(
|
||||
protocols,
|
||||
signal_frequency=433920000,
|
||||
tolerance_hz=200000
|
||||
)
|
||||
|
||||
# Should get both 433 MHz devices
|
||||
assert len(filtered) == 2, f"Expected 2 protocols, got {len(filtered)}"
|
||||
assert all('433MHz' in p.name for p in filtered)
|
||||
|
||||
def test_protocol_grouping_by_band(self):
|
||||
"""Test 14: Group protocols by ISM band"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
class MockProtocol:
|
||||
def __init__(self, name, freq):
|
||||
self.name = name
|
||||
self.frequency = freq
|
||||
|
||||
protocols = [
|
||||
MockProtocol('Dev1', 433920000),
|
||||
MockProtocol('Dev2', 315000000),
|
||||
MockProtocol('Dev3', 433850000),
|
||||
MockProtocol('Dev4', 868000000),
|
||||
]
|
||||
|
||||
grouped = fingerprinter.group_protocols_by_band(protocols)
|
||||
|
||||
assert '433MHz' in grouped
|
||||
assert '315MHz' in grouped
|
||||
assert '868MHz' in grouped
|
||||
assert len(grouped['433MHz']) == 2, "Should have 2 protocols in 433MHz band"
|
||||
|
||||
def test_band_statistics(self):
|
||||
"""Test 15: Get protocol count per band"""
|
||||
fingerprinter = FrequencyFingerprinter()
|
||||
|
||||
class MockProtocol:
|
||||
def __init__(self, freq):
|
||||
self.frequency = freq
|
||||
|
||||
protocols = [
|
||||
MockProtocol(433920000),
|
||||
MockProtocol(433850000),
|
||||
MockProtocol(315000000),
|
||||
]
|
||||
|
||||
stats = fingerprinter.get_band_statistics(protocols)
|
||||
|
||||
assert stats.get('433MHz', 0) == 2
|
||||
assert stats.get('315MHz', 0) == 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Reference in New Issue
Block a user