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:
leetcrypt
2026-02-15 07:38:09 -08:00
parent af9942f822
commit f8042c3dca
4 changed files with 1152 additions and 24 deletions
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
Frequency Fingerprinting for RF Device Identification
Uses center frequency and frequency bands as identification features.
Narrows candidate protocols by frequency before detailed timing analysis.
Key features:
- Frequency band classification (315/433/868/915 MHz)
- Protocol filtering by frequency tolerance
- Frequency-based scoring
- ISM band detection
"""
import numpy as np
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple
from collections import defaultdict
# ISM (Industrial, Scientific, Medical) band definitions
ISM_BANDS = {
'315MHz': (314_000_000, 316_000_000), # North America
'433MHz': (433_050_000, 434_790_000), # Europe (primary ISM)
'868MHz': (868_000_000, 868_600_000), # Europe SRD band
'915MHz': (902_000_000, 928_000_000), # North America ISM
}
@dataclass
class FrequencyFingerprint:
"""
Frequency characteristics of a signal
Attributes:
center_freq: Center frequency in Hz
bandwidth: Estimated bandwidth in Hz (optional)
ism_band: Detected ISM band name ('315MHz', '433MHz', etc.)
frequency_offset: Offset from standard band center
confidence: Detection confidence (0.0-1.0)
"""
center_freq: int
bandwidth: Optional[int]
ism_band: Optional[str]
frequency_offset: int
confidence: float
@dataclass
class FrequencyMatch:
"""
Match result comparing signal frequency to protocol
Attributes:
protocol_name: Name of matched protocol
frequency_error: Absolute error in Hz
frequency_error_pct: Error as percentage
within_tolerance: Whether within protocol's tolerance
score: Match score (0.0-1.0)
details: Additional match details
"""
protocol_name: str
frequency_error: int
frequency_error_pct: float
within_tolerance: bool
score: float
details: Dict
class FrequencyFingerprinter:
"""
Frequency-based protocol filtering and scoring
Workflow:
1. Analyze signal frequency
2. Determine ISM band
3. Filter protocols by frequency band
4. Score remaining protocols by frequency match
"""
def __init__(self):
"""Initialize fingerprinter"""
self.ism_bands = ISM_BANDS
def analyze(self, frequency: int) -> FrequencyFingerprint:
"""
Analyze frequency characteristics
Args:
frequency: Signal center frequency in Hz
Returns:
FrequencyFingerprint with detected characteristics
"""
# Detect ISM band
ism_band = self._detect_ism_band(frequency)
# Calculate offset from band center
offset = 0
if ism_band:
band_low, band_high = self.ism_bands[ism_band]
band_center = (band_low + band_high) // 2
offset = frequency - band_center
# Confidence based on band detection
confidence = 0.9 if ism_band else 0.6
return FrequencyFingerprint(
center_freq=frequency,
bandwidth=None, # Not calculated from single .sub file
ism_band=ism_band,
frequency_offset=offset,
confidence=confidence
)
def filter_protocols_by_frequency(
self,
protocols: List,
signal_frequency: int,
tolerance_hz: int = 200_000 # ±200 kHz default
) -> List:
"""
Filter protocols by frequency match
Args:
protocols: List of ProtocolSignature objects
signal_frequency: Signal frequency in Hz
tolerance_hz: Frequency tolerance in Hz
Returns:
Filtered list of protocols within frequency range
"""
filtered = []
for protocol in protocols:
freq_error = abs(protocol.frequency - signal_frequency)
# Use protocol's own tolerance if available, otherwise use default
protocol_tolerance = getattr(protocol, 'frequency_tolerance', tolerance_hz)
if freq_error <= protocol_tolerance:
filtered.append(protocol)
return filtered
def score_frequency_match(
self,
signal_frequency: int,
protocol_frequency: int,
protocol_tolerance: int = 100_000
) -> FrequencyMatch:
"""
Score frequency match between signal and protocol
Args:
signal_frequency: Signal frequency in Hz
protocol_frequency: Protocol expected frequency in Hz
protocol_tolerance: Protocol's frequency tolerance in Hz
Returns:
FrequencyMatch with score
"""
freq_error = abs(signal_frequency - protocol_frequency)
freq_error_pct = freq_error / protocol_frequency if protocol_frequency > 0 else 1.0
within_tolerance = freq_error <= protocol_tolerance
# Score calculation:
# - Exact match = 1.0
# - Within tolerance = 0.8-1.0 (linear falloff)
# - Outside tolerance = 0.0-0.5 (steep falloff)
if freq_error == 0:
score = 1.0
elif within_tolerance:
# Linear falloff within tolerance
normalized = freq_error / protocol_tolerance
score = 1.0 - (normalized * 0.2) # 1.0 → 0.8
else:
# Steep falloff outside tolerance
excess = freq_error - protocol_tolerance
# Penalty: 50% score at 2x tolerance, 0% at 4x tolerance
if excess < protocol_tolerance:
score = 0.5 * (1.0 - excess / protocol_tolerance)
else:
score = 0.0
return FrequencyMatch(
protocol_name="", # Set by caller
frequency_error=freq_error,
frequency_error_pct=freq_error_pct,
within_tolerance=within_tolerance,
score=score,
details={
'signal_freq_mhz': f"{signal_frequency / 1_000_000:.3f}",
'protocol_freq_mhz': f"{protocol_frequency / 1_000_000:.3f}",
'error_khz': f"{freq_error / 1_000:.1f}",
'tolerance_khz': f"{protocol_tolerance / 1_000:.1f}"
}
)
def group_protocols_by_band(
self,
protocols: List
) -> Dict[str, List]:
"""
Group protocols by ISM frequency band
Args:
protocols: List of ProtocolSignature objects
Returns:
Dict mapping band name to protocols
"""
grouped = defaultdict(list)
for protocol in protocols:
band = self._detect_ism_band(protocol.frequency)
if band:
grouped[band].append(protocol)
else:
grouped['other'].append(protocol)
return dict(grouped)
def get_band_statistics(
self,
protocols: List
) -> Dict[str, int]:
"""
Get protocol count statistics by band
Args:
protocols: List of ProtocolSignature objects
Returns:
Dict mapping band name to protocol count
"""
grouped = self.group_protocols_by_band(protocols)
return {band: len(protos) for band, protos in grouped.items()}
# === Helper Methods ===
def _detect_ism_band(self, frequency: int) -> Optional[str]:
"""
Detect which ISM band a frequency belongs to
Args:
frequency: Frequency in Hz
Returns:
Band name ('315MHz', '433MHz', etc.) or None
"""
for band_name, (low, high) in self.ism_bands.items():
if low <= frequency <= high:
return band_name
return None
def _get_band_center(self, band_name: str) -> int:
"""Get center frequency of an ISM band"""
if band_name not in self.ism_bands:
return 0
low, high = self.ism_bands[band_name]
return (low + high) // 2
# === Integration with Protocol Database ===
class FrequencyFilterStrategy:
"""
Matching strategy that pre-filters by frequency
Reduces search space before expensive timing analysis
"""
def __init__(self, protocol_db):
"""
Initialize strategy
Args:
protocol_db: Protocol database instance
"""
self.protocol_db = protocol_db
self.fingerprinter = FrequencyFingerprinter()
def filter_candidates(
self,
signal_frequency: int,
max_candidates: int = 50
) -> List:
"""
Get candidate protocols filtered by frequency
Args:
signal_frequency: Signal frequency in Hz
max_candidates: Maximum protocols to return
Returns:
List of candidate protocols
"""
# Get all protocols
all_protocols = self.protocol_db.get_all()
# Filter by frequency (±200 kHz default tolerance)
candidates = self.fingerprinter.filter_protocols_by_frequency(
all_protocols,
signal_frequency,
tolerance_hz=200_000
)
# If too many, narrow further
if len(candidates) > max_candidates:
# Sort by frequency error, keep closest
candidates.sort(
key=lambda p: abs(p.frequency - signal_frequency)
)
candidates = candidates[:max_candidates]
return candidates
def get_frequency_score(
self,
signal_frequency: int,
protocol_frequency: int,
protocol_tolerance: int
) -> float:
"""
Get frequency match score
Args:
signal_frequency: Signal frequency in Hz
protocol_frequency: Protocol frequency in Hz
protocol_tolerance: Protocol tolerance in Hz
Returns:
Score between 0.0 and 1.0
"""
match = self.fingerprinter.score_frequency_match(
signal_frequency,
protocol_frequency,
protocol_tolerance
)
return match.score
# Singleton instance
_fingerprinter: Optional[FrequencyFingerprinter] = None
def get_frequency_fingerprinter() -> FrequencyFingerprinter:
"""Get singleton frequency fingerprinter instance"""
global _fingerprinter
if _fingerprinter is None:
_fingerprinter = FrequencyFingerprinter()
return _fingerprinter