#!/usr/bin/env python3 """ Unit Tests for Robust Timing Analyzer Tests timing extraction, scoring, and noise tolerance capabilities. """ import pytest import numpy as np from src.matcher.timing_analyzer import ( RobustTimingAnalyzer, TimingCharacteristics, TimingScore ) class TestTimingExtraction: """Test timing characteristic extraction""" def test_clean_signal_extraction(self): """Test 1: Extract timing from clean PWM signal""" analyzer = RobustTimingAnalyzer() # Simulated PWM signal: SHORT=500us, LONG=1000us # Pattern: 0101010101 (10 bits) 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 500, -500, # 0 1000, -500, # 1 ] timing = analyzer.extract_timing(pulses) # Verify extracted timings assert 450 <= timing.short_pulse_us <= 550, f"Expected short ~500, got {timing.short_pulse_us}" assert 950 <= timing.long_pulse_us <= 1050, f"Expected long ~1000, got {timing.long_pulse_us}" assert timing.pulse_ratio >= 1.8 and timing.pulse_ratio <= 2.2, f"Expected ratio ~2.0, got {timing.pulse_ratio}" assert timing.confidence > 0.5, f"Expected confidence >0.5, got {timing.confidence}" assert timing.pulse_count == 10 def test_noisy_signal_extraction(self): """Test 2: Extract timing from noisy signal with outliers""" analyzer = RobustTimingAnalyzer() # Signal with noise and outliers pulses = [ 500, -500, 1000, -500, 500, -500, 5000, # OUTLIER: glitch -500, 1000, -500, 500, -500, 1000, -500, 50, # OUTLIER: noise spike -500, 1000, -500, ] timing = analyzer.extract_timing(pulses) # Should still extract reasonable timings despite outliers assert 400 <= timing.short_pulse_us <= 600, f"Short pulse should be ~500, got {timing.short_pulse_us}" assert 900 <= timing.long_pulse_us <= 1100, f"Long pulse should be ~1000, got {timing.long_pulse_us}" assert timing.confidence > 0.3, "Should have some confidence despite noise" def test_multi_level_signal(self): """Test 3: Handle signal with multiple pulse widths""" analyzer = RobustTimingAnalyzer() # Tri-level signal: 250us, 500us, 1000us pulses = [ 250, -250, 500, -250, 1000, -250, 250, -250, 500, -250, 1000, -250, 250, -250, 500, -250, 1000, -250, ] timing = analyzer.extract_timing(pulses) # Should extract shortest and longest assert timing.short_pulse_us < 400, "Should identify shortest pulse" assert timing.long_pulse_us > 800, "Should identify longest pulse" assert timing.pulse_count > 0 def test_empty_signal(self): """Test 4: Handle empty/invalid input gracefully""" analyzer = RobustTimingAnalyzer() # Empty signal timing = analyzer.extract_timing([]) assert timing.confidence == 0.0 assert timing.short_pulse_us == 0 # Too short signal timing = analyzer.extract_timing([500, -500]) assert timing.confidence == 0.0 def test_duty_cycle_calculation(self): """Test 5: Verify duty cycle calculation""" analyzer = RobustTimingAnalyzer() # 50% duty cycle: equal HIGH and LOW time pulses = [1000, -1000, 1000, -1000, 1000, -1000] timing = analyzer.extract_timing(pulses) assert 0.45 <= timing.duty_cycle <= 0.55, f"Expected duty cycle ~0.5, got {timing.duty_cycle}" # 25% duty cycle: SHORT HIGH, long LOW pulses = [500, -1500, 500, -1500, 500, -1500] timing = analyzer.extract_timing(pulses) assert 0.20 <= timing.duty_cycle <= 0.30, f"Expected duty cycle ~0.25, got {timing.duty_cycle}" class TestProtocolScoring: """Test scoring against protocol signatures""" def test_perfect_match_scoring(self): """Test 6: Score perfect timing match""" analyzer = RobustTimingAnalyzer() # Perfect match timing timing = TimingCharacteristics( short_pulse_us=500, long_pulse_us=1000, short_gap_us=500, long_gap_us=500, pulse_ratio=2.0, gap_ratio=1.0, duty_cycle=0.5, pulse_count=20, confidence=1.0, method='kmeans' ) # Score against matching protocol score = analyzer.score_against_protocol( timing=timing, protocol_short_us=500, protocol_long_us=1000, protocol_frequency=433920000, signal_frequency=433920000, timing_tolerance=0.25 ) # Should have very high scores assert score.overall_score > 0.9, f"Expected score >0.9, got {score.overall_score}" assert score.pulse_score > 0.95, f"Expected pulse score >0.95, got {score.pulse_score}" assert score.frequency_score > 0.95, f"Expected frequency score >0.95, got {score.frequency_score}" def test_partial_match_scoring(self): """Test 7: Score partial timing match within tolerance""" analyzer = RobustTimingAnalyzer() # 10% timing error (within 25% tolerance) timing = TimingCharacteristics( short_pulse_us=550, # 10% higher than 500 long_pulse_us=1100, # 10% higher than 1000 short_gap_us=500, long_gap_us=500, pulse_ratio=2.0, gap_ratio=1.0, duty_cycle=0.5, pulse_count=20, confidence=0.9, method='kmeans' ) score = analyzer.score_against_protocol( timing=timing, protocol_short_us=500, protocol_long_us=1000, protocol_frequency=433920000, signal_frequency=433920000, timing_tolerance=0.25 # ±25% ) # Should still score well (within tolerance) assert score.overall_score > 0.7, f"Expected score >0.7 for 10% error, got {score.overall_score}" assert score.pulse_score > 0.6, f"Expected pulse score >0.6, got {score.pulse_score}" def test_mismatch_scoring(self): """Test 8: Score timing mismatch outside tolerance""" analyzer = RobustTimingAnalyzer() # Large timing error (>50%) timing = TimingCharacteristics( short_pulse_us=250, # 50% lower than 500 long_pulse_us=2000, # 100% higher than 1000 short_gap_us=500, long_gap_us=500, pulse_ratio=8.0, # Wrong ratio gap_ratio=1.0, duty_cycle=0.5, pulse_count=20, confidence=0.8, method='kmeans' ) score = analyzer.score_against_protocol( timing=timing, protocol_short_us=500, protocol_long_us=1000, protocol_frequency=433920000, signal_frequency=433920000, timing_tolerance=0.25 ) # Should have low score (outside tolerance) assert score.overall_score < 0.3, f"Expected score <0.3 for large error, got {score.overall_score}" assert score.pulse_score < 0.2, f"Expected pulse score <0.2, got {score.pulse_score}" def test_frequency_mismatch_penalty(self): """Test 9: Frequency mismatch reduces score""" analyzer = RobustTimingAnalyzer() # Perfect timing, wrong frequency timing = TimingCharacteristics( short_pulse_us=500, long_pulse_us=1000, short_gap_us=500, long_gap_us=500, pulse_ratio=2.0, gap_ratio=1.0, duty_cycle=0.5, pulse_count=20, confidence=1.0, method='kmeans' ) score = analyzer.score_against_protocol( timing=timing, protocol_short_us=500, protocol_long_us=1000, protocol_frequency=433920000, signal_frequency=315000000, # Wrong frequency! timing_tolerance=0.25 ) # Frequency mismatch should reduce overall score assert score.frequency_score < 0.2, f"Expected low frequency score, got {score.frequency_score}" assert score.overall_score < 0.9, "Overall score should be reduced by frequency mismatch" def test_confidence_scaling(self): """Test 10: Low extraction confidence scales down score""" analyzer = RobustTimingAnalyzer() # Perfect timing but low confidence (noisy extraction) timing = TimingCharacteristics( short_pulse_us=500, long_pulse_us=1000, short_gap_us=500, long_gap_us=500, pulse_ratio=2.0, gap_ratio=1.0, duty_cycle=0.5, pulse_count=20, confidence=0.4, # Low confidence method='percentile' ) score = analyzer.score_against_protocol( timing=timing, protocol_short_us=500, protocol_long_us=1000, protocol_frequency=433920000, signal_frequency=433920000, timing_tolerance=0.25 ) # Low confidence should scale down overall score assert score.overall_score < 0.5, f"Expected score <0.5 with low confidence, got {score.overall_score}" class TestOutlierRemoval: """Test outlier detection and removal""" def test_iqr_outlier_removal(self): """Test 11: IQR method removes outliers correctly""" analyzer = RobustTimingAnalyzer() # Normal pulses + outliers values = [500] * 10 + [1000] * 10 + [10000] # Large outlier cleaned = analyzer._remove_outliers_from_values(values) # Large outlier should be removed (outside 2.5*IQR) assert 10000 not in cleaned, "Large outlier should be removed" # Should keep most valid data assert len(cleaned) == 20, f"Should have 20 pulses after cleaning, got {len(cleaned)}" def test_small_sample_no_removal(self): """Test 12: Don't remove outliers from small samples""" analyzer = RobustTimingAnalyzer() # Too few samples for outlier detection values = [500, 1000, 5000] cleaned = analyzer._remove_outliers_from_values(values) # Should return all samples assert len(cleaned) == 3, "Small samples should not be filtered" class TestMultiMethodExtraction: """Test multi-method extraction approach""" def test_kmeans_preferred_for_clean(self): """Test 13: K-means chosen for clean bimodal signals""" try: from sklearn.cluster import KMeans has_sklearn = True except ImportError: has_sklearn = False if not has_sklearn: pytest.skip("sklearn not available") analyzer = RobustTimingAnalyzer() # Clean bimodal signal (perfect for K-means) pulses = [500] * 10 + [1000] * 10 timing = analyzer.extract_timing(pulses) # K-means should be selected (highest confidence) assert timing.method == 'kmeans', f"Expected kmeans, got {timing.method}" assert timing.confidence > 0.7, f"Expected high confidence, got {timing.confidence}" def test_fallback_to_percentile(self): """Test 14: Fallback to percentile for difficult signals""" analyzer = RobustTimingAnalyzer() # Challenging signal: gradual variation pulses = list(range(400, 600, 10)) + list(range(900, 1100, 10)) timing = analyzer.extract_timing(pulses) # Should extract reasonable timings using some method assert timing.short_pulse_us > 0, "Should extract short pulse" assert timing.long_pulse_us > timing.short_pulse_us, "Long > short" assert timing.confidence > 0.0, "Should have some confidence" # Integration test def test_real_world_lacrosse_signal(): """Test 15: Process real-world LaCrosse TX141 signal pattern""" analyzer = RobustTimingAnalyzer() # Simulated LaCrosse TX141-BV2 timing (500us/1000us PWM) # 40-bit transmission lacrosse_pulses = [] # Preamble: 4x "10" for _ in range(4): lacrosse_pulses.extend([1000, -500, 500, -500]) # Data: random pattern data_bits = "10110100" * 4 # 32 bits for bit in data_bits: if bit == '1': lacrosse_pulses.extend([1000, -500]) else: lacrosse_pulses.extend([500, -500]) # Add some noise np.random.seed(42) noisy_pulses = [ int(p * np.random.normal(1.0, 0.05)) # ±5% jitter for p in lacrosse_pulses ] timing = analyzer.extract_timing(noisy_pulses) # Should extract LaCrosse-like timing assert 450 <= timing.short_pulse_us <= 550, f"Expected short ~500, got {timing.short_pulse_us}" assert 950 <= timing.long_pulse_us <= 1050, f"Expected long ~1000, got {timing.long_pulse_us}" assert timing.pulse_ratio >= 1.7 and timing.pulse_ratio <= 2.3, "Ratio should be ~2.0" assert timing.confidence > 0.5, "Should have good confidence" # Score against LaCrosse protocol score = analyzer.score_against_protocol( timing=timing, protocol_short_us=500, protocol_long_us=1000, protocol_frequency=433920000, signal_frequency=433920000, timing_tolerance=0.25 ) # Should match well assert score.overall_score > 0.7, f"Expected good match, got {score.overall_score}" if __name__ == '__main__': pytest.main([__file__, '-v'])