mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add signal guessing engine for frequency identification
Implements heuristic-based signal identification that provides plain-English guesses for detected signals based on frequency, modulation, bandwidth, and burst behavior. Features: - Python backend engine (utils/signal_guess.py) - JavaScript client-side engine with UI components - Hedged language output (never claims certainty) - UK/EU and US region support - Confidence levels (LOW/MEDIUM/HIGH) - 50+ unit tests for deterministic verification Supported signal types: FM broadcast, airband, cellular/LTE, ISM bands (433/868/915/2.4GHz), TPMS, amateur radio, marine VHF, DAB, pager networks, weather satellites, ADS-B, and more. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1007
static/js/components/signal-guess.js
Normal file
1007
static/js/components/signal-guess.js
Normal file
File diff suppressed because it is too large
Load Diff
599
tests/test_signal_guess.py
Normal file
599
tests/test_signal_guess.py
Normal file
@@ -0,0 +1,599 @@
|
||||
"""
|
||||
Comprehensive tests for the Signal Guessing Engine.
|
||||
|
||||
Tests cover:
|
||||
- FM broadcast frequency detection
|
||||
- Airband frequency detection
|
||||
- ISM band devices (433 MHz, 868 MHz, 2.4 GHz)
|
||||
- TPMS / short-burst telemetry
|
||||
- Cellular/LTE detection
|
||||
- Modulation and bandwidth scoring
|
||||
- Burst behavior detection
|
||||
- Region-specific allocations
|
||||
- Confidence level calculations
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from utils.signal_guess import (
|
||||
SignalGuessingEngine,
|
||||
SignalGuessResult,
|
||||
SignalAlternative,
|
||||
Confidence,
|
||||
guess_signal_type,
|
||||
guess_signal_type_dict,
|
||||
)
|
||||
|
||||
|
||||
class TestFMBroadcast:
|
||||
"""Tests for FM broadcast radio identification."""
|
||||
|
||||
def test_fm_broadcast_center_frequency(self):
|
||||
"""Test FM broadcast at typical frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=98_500_000, # 98.5 MHz
|
||||
modulation="WFM",
|
||||
bandwidth_hz=200_000,
|
||||
)
|
||||
assert result.primary_label == "FM Broadcast Radio"
|
||||
assert result.confidence == Confidence.HIGH
|
||||
assert "broadcast" in result.tags
|
||||
|
||||
def test_fm_broadcast_edge_frequencies(self):
|
||||
"""Test FM broadcast at band edges."""
|
||||
# Low edge
|
||||
result_low = guess_signal_type(frequency_hz=88_000_000)
|
||||
assert result_low.primary_label == "FM Broadcast Radio"
|
||||
|
||||
# High edge
|
||||
result_high = guess_signal_type(frequency_hz=107_900_000)
|
||||
assert result_high.primary_label == "FM Broadcast Radio"
|
||||
|
||||
def test_fm_broadcast_without_modulation(self):
|
||||
"""Test FM broadcast without modulation hint - lower confidence."""
|
||||
result = guess_signal_type(frequency_hz=100_000_000)
|
||||
assert result.primary_label == "FM Broadcast Radio"
|
||||
# Without modulation hint, confidence should be MEDIUM or lower
|
||||
assert result.confidence in (Confidence.MEDIUM, Confidence.HIGH)
|
||||
|
||||
def test_fm_broadcast_explanation(self):
|
||||
"""Test explanation uses hedged language."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=95_000_000,
|
||||
modulation="FM",
|
||||
)
|
||||
explanation = result.explanation.lower()
|
||||
# Should contain hedged language
|
||||
assert any(word in explanation for word in ["consistent", "could", "may", "indicate"])
|
||||
assert "95.000 mhz" in explanation
|
||||
|
||||
|
||||
class TestAirband:
|
||||
"""Tests for civil aviation airband identification."""
|
||||
|
||||
def test_airband_typical_frequency(self):
|
||||
"""Test airband at typical tower frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=118_750_000, # 118.75 MHz
|
||||
modulation="AM",
|
||||
bandwidth_hz=8_000,
|
||||
)
|
||||
assert result.primary_label == "Airband (Civil Aviation Voice)"
|
||||
assert result.confidence == Confidence.HIGH
|
||||
assert "aviation" in result.tags
|
||||
|
||||
def test_airband_approach_frequency(self):
|
||||
"""Test airband at approach control frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=128_550_000, # 128.55 MHz
|
||||
modulation="AM",
|
||||
)
|
||||
assert result.primary_label == "Airband (Civil Aviation Voice)"
|
||||
assert result.confidence in (Confidence.MEDIUM, Confidence.HIGH)
|
||||
|
||||
def test_airband_guard_frequency(self):
|
||||
"""Test airband at international distress frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=121_500_000, # 121.5 MHz guard
|
||||
modulation="AM",
|
||||
)
|
||||
assert result.primary_label == "Airband (Civil Aviation Voice)"
|
||||
|
||||
def test_airband_wrong_modulation(self):
|
||||
"""Test airband with wrong modulation still matches but lower score."""
|
||||
result_am = guess_signal_type(
|
||||
frequency_hz=125_000_000,
|
||||
modulation="AM",
|
||||
)
|
||||
result_fm = guess_signal_type(
|
||||
frequency_hz=125_000_000,
|
||||
modulation="FM",
|
||||
)
|
||||
# AM should score higher for airband
|
||||
assert result_am._scores.get("Airband (Civil Aviation Voice)", 0) > \
|
||||
result_fm._scores.get("Airband (Civil Aviation Voice)", 0)
|
||||
|
||||
|
||||
class TestISMBands:
|
||||
"""Tests for ISM band device identification."""
|
||||
|
||||
def test_433_mhz_ism_eu(self):
|
||||
"""Test 433 MHz ISM band (EU)."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000, # 433.92 MHz
|
||||
modulation="NFM",
|
||||
region="UK/EU",
|
||||
)
|
||||
assert "ISM" in result.primary_label or "TPMS" in result.primary_label
|
||||
assert any(tag in result.tags for tag in ["ism", "telemetry", "tpms"])
|
||||
|
||||
def test_433_mhz_short_burst(self):
|
||||
"""Test 433 MHz with short burst pattern -> TPMS/telemetry."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
modulation="NFM",
|
||||
duration_ms=50, # 50ms burst
|
||||
repetition_count=3,
|
||||
region="UK/EU",
|
||||
)
|
||||
# Short burst at 433.92 should suggest TPMS or ISM telemetry
|
||||
assert any(word in result.primary_label.lower() for word in ["tpms", "ism", "telemetry"])
|
||||
# Should have medium confidence due to burst behavior match
|
||||
assert result.confidence in (Confidence.MEDIUM, Confidence.HIGH)
|
||||
|
||||
def test_868_mhz_ism_eu(self):
|
||||
"""Test 868 MHz ISM band (EU)."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=868_300_000,
|
||||
modulation="FSK",
|
||||
region="UK/EU",
|
||||
)
|
||||
assert "868" in result.primary_label or "ISM" in result.primary_label
|
||||
assert "ism" in result.tags or "iot" in result.tags
|
||||
|
||||
def test_915_mhz_ism_us(self):
|
||||
"""Test 915 MHz ISM band (US)."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=915_000_000,
|
||||
modulation="FSK",
|
||||
region="US",
|
||||
)
|
||||
assert "915" in result.primary_label or "ISM" in result.primary_label
|
||||
|
||||
def test_24_ghz_ism(self):
|
||||
"""Test 2.4 GHz ISM band."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=2_437_000_000, # WiFi channel 6
|
||||
modulation="OFDM",
|
||||
bandwidth_hz=20_000_000,
|
||||
)
|
||||
assert "2.4" in result.primary_label or "ISM" in result.primary_label
|
||||
assert any(tag in result.tags for tag in ["ism", "wifi", "bluetooth"])
|
||||
|
||||
def test_24_ghz_narrow_bandwidth(self):
|
||||
"""Test 2.4 GHz with narrow bandwidth (Bluetooth-like)."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=2_450_000_000,
|
||||
modulation="GFSK",
|
||||
bandwidth_hz=1_000_000,
|
||||
)
|
||||
assert "2.4" in result.primary_label
|
||||
# Should match ISM 2.4 GHz
|
||||
assert result.confidence in (Confidence.LOW, Confidence.MEDIUM, Confidence.HIGH)
|
||||
|
||||
|
||||
class TestTPMSTelemetry:
|
||||
"""Tests for TPMS and short-burst telemetry."""
|
||||
|
||||
def test_tpms_433_short_burst(self):
|
||||
"""Test TPMS-like signal at 433.92 MHz with short burst."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
modulation="OOK",
|
||||
bandwidth_hz=20_000,
|
||||
duration_ms=100, # Short burst
|
||||
repetition_count=4, # Multiple bursts
|
||||
region="UK/EU",
|
||||
)
|
||||
# Should identify as TPMS or ISM telemetry
|
||||
assert any(word in result.primary_label.lower() for word in ["tpms", "ism", "telemetry", "remote"])
|
||||
|
||||
def test_tpms_315_us(self):
|
||||
"""Test TPMS at 315 MHz (US)."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=315_000_000,
|
||||
modulation="ASK",
|
||||
duration_ms=50,
|
||||
repetition_count=2,
|
||||
region="US",
|
||||
)
|
||||
assert any(word in result.primary_label.lower() for word in ["tpms", "ism", "315", "remote"])
|
||||
|
||||
def test_burst_detection_scoring(self):
|
||||
"""Test that burst behavior increases scores for burst-type signals."""
|
||||
# Without burst behavior
|
||||
result_no_burst = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
modulation="OOK",
|
||||
region="UK/EU",
|
||||
)
|
||||
|
||||
# With burst behavior
|
||||
result_burst = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
modulation="OOK",
|
||||
duration_ms=50,
|
||||
repetition_count=5,
|
||||
region="UK/EU",
|
||||
)
|
||||
|
||||
# Burst scores should be higher for burst-type signals
|
||||
burst_score = result_burst._scores.get("TPMS / Vehicle Telemetry", 0) + \
|
||||
result_burst._scores.get("Remote Control / Key Fob", 0)
|
||||
no_burst_score = result_no_burst._scores.get("TPMS / Vehicle Telemetry", 0) + \
|
||||
result_no_burst._scores.get("Remote Control / Key Fob", 0)
|
||||
assert burst_score > no_burst_score
|
||||
|
||||
|
||||
class TestCellularLTE:
|
||||
"""Tests for cellular/LTE identification."""
|
||||
|
||||
def test_lte_band_20_eu(self):
|
||||
"""Test LTE Band 20 (800 MHz) detection."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=806_000_000,
|
||||
modulation="LTE",
|
||||
bandwidth_hz=10_000_000,
|
||||
region="UK/EU",
|
||||
)
|
||||
assert "Cellular" in result.primary_label or "Mobile" in result.primary_label
|
||||
assert "cellular" in result.tags
|
||||
|
||||
def test_lte_band_3_eu(self):
|
||||
"""Test LTE Band 3 (1800 MHz) detection."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=1_815_000_000,
|
||||
bandwidth_hz=15_000_000,
|
||||
region="UK/EU",
|
||||
)
|
||||
assert "Cellular" in result.primary_label or "Mobile" in result.primary_label
|
||||
|
||||
def test_cellular_wide_bandwidth_boost(self):
|
||||
"""Test that wide bandwidth boosts cellular confidence."""
|
||||
result_wide = guess_signal_type(
|
||||
frequency_hz=850_000_000,
|
||||
bandwidth_hz=10_000_000, # 10 MHz LTE
|
||||
)
|
||||
result_narrow = guess_signal_type(
|
||||
frequency_hz=850_000_000,
|
||||
bandwidth_hz=25_000, # 25 kHz narrowband
|
||||
)
|
||||
# Wide bandwidth should score higher for cellular
|
||||
cell_score_wide = result_wide._scores.get("Cellular / Mobile Network", 0)
|
||||
cell_score_narrow = result_narrow._scores.get("Cellular / Mobile Network", 0)
|
||||
assert cell_score_wide > cell_score_narrow
|
||||
|
||||
|
||||
class TestConfidenceLevels:
|
||||
"""Tests for confidence level calculations."""
|
||||
|
||||
def test_high_confidence_requires_margin(self):
|
||||
"""Test that HIGH confidence requires good margin over alternatives."""
|
||||
# FM broadcast with strong evidence
|
||||
result = guess_signal_type(
|
||||
frequency_hz=100_000_000,
|
||||
modulation="WFM",
|
||||
bandwidth_hz=200_000,
|
||||
)
|
||||
assert result.confidence == Confidence.HIGH
|
||||
|
||||
def test_medium_confidence_with_ambiguity(self):
|
||||
"""Test MEDIUM confidence when alternatives are close."""
|
||||
# Frequency in ISM band with less specific characteristics
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_500_000, # General 433 band
|
||||
region="UK/EU",
|
||||
)
|
||||
# Should have alternatives, potentially MEDIUM confidence
|
||||
assert result.confidence in (Confidence.LOW, Confidence.MEDIUM)
|
||||
assert len(result.alternatives) > 0
|
||||
|
||||
def test_low_confidence_unknown_frequency(self):
|
||||
"""Test LOW confidence for unrecognized frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=50_000_000, # 50 MHz - not in common allocations
|
||||
)
|
||||
assert result.confidence == Confidence.LOW
|
||||
|
||||
def test_alternatives_have_lower_confidence(self):
|
||||
"""Test that alternatives have appropriate confidence levels."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
modulation="OOK",
|
||||
region="UK/EU",
|
||||
)
|
||||
if result.alternatives:
|
||||
for alt in result.alternatives:
|
||||
# Alternatives should generally have same or lower confidence
|
||||
assert isinstance(alt.confidence, Confidence)
|
||||
|
||||
|
||||
class TestRegionSpecific:
|
||||
"""Tests for region-specific frequency allocations."""
|
||||
|
||||
def test_315_mhz_us_only(self):
|
||||
"""Test 315 MHz ISM only matches in US region."""
|
||||
result_us = guess_signal_type(
|
||||
frequency_hz=315_000_000,
|
||||
region="US",
|
||||
)
|
||||
result_eu = guess_signal_type(
|
||||
frequency_hz=315_000_000,
|
||||
region="UK/EU",
|
||||
)
|
||||
# Should match in US
|
||||
assert "315" in result_us.primary_label or "ISM" in result_us.primary_label or "TPMS" in result_us.primary_label
|
||||
# Should not match well in EU
|
||||
assert result_eu.primary_label == "Unknown Signal" or result_eu.confidence == Confidence.LOW
|
||||
|
||||
def test_pmr446_eu_only(self):
|
||||
"""Test PMR446 only matches in EU region."""
|
||||
result_eu = guess_signal_type(
|
||||
frequency_hz=446_100_000,
|
||||
modulation="NFM",
|
||||
region="UK/EU",
|
||||
)
|
||||
result_us = guess_signal_type(
|
||||
frequency_hz=446_100_000,
|
||||
modulation="NFM",
|
||||
region="US",
|
||||
)
|
||||
# Should match PMR446 in EU
|
||||
assert "PMR" in result_eu.primary_label
|
||||
# Should not match PMR446 in US
|
||||
assert "PMR" not in result_us.primary_label
|
||||
|
||||
def test_dab_eu_only(self):
|
||||
"""Test DAB only matches in EU region."""
|
||||
result_eu = guess_signal_type(
|
||||
frequency_hz=225_648_000, # DAB 12C
|
||||
modulation="OFDM",
|
||||
bandwidth_hz=1_500_000,
|
||||
region="UK/EU",
|
||||
)
|
||||
assert "DAB" in result_eu.primary_label
|
||||
|
||||
|
||||
class TestExplanationLanguage:
|
||||
"""Tests for hedged, client-safe explanation language."""
|
||||
|
||||
def test_no_certainty_claims(self):
|
||||
"""Test that explanations never claim certainty."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=100_000_000,
|
||||
modulation="FM",
|
||||
)
|
||||
explanation = result.explanation.lower()
|
||||
# Should NOT contain definitive language
|
||||
forbidden_words = ["definitely", "certainly", "absolutely", "is a", "this is"]
|
||||
for word in forbidden_words:
|
||||
assert word not in explanation, f"Found forbidden word '{word}' in explanation"
|
||||
|
||||
def test_hedged_language_present(self):
|
||||
"""Test that explanations use hedged language."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=118_500_000,
|
||||
modulation="AM",
|
||||
)
|
||||
explanation = result.explanation.lower()
|
||||
# Should contain hedged language
|
||||
hedged_words = ["consistent", "could", "may", "likely", "suggest", "indicate"]
|
||||
assert any(word in explanation for word in hedged_words)
|
||||
|
||||
def test_explanation_includes_frequency(self):
|
||||
"""Test that explanations include the frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
modulation="NFM",
|
||||
region="UK/EU",
|
||||
)
|
||||
assert "433.920" in result.explanation
|
||||
|
||||
|
||||
class TestUnknownSignals:
|
||||
"""Tests for unknown signal handling."""
|
||||
|
||||
def test_completely_unknown_frequency(self):
|
||||
"""Test handling of frequency with no allocations."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=42_000_000, # Random frequency
|
||||
)
|
||||
assert result.primary_label == "Unknown Signal"
|
||||
assert result.confidence == Confidence.LOW
|
||||
assert result.alternatives == []
|
||||
assert "unknown" in result.tags
|
||||
|
||||
def test_unknown_includes_frequency_in_explanation(self):
|
||||
"""Test that unknown signal explanation includes frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=42_000_000,
|
||||
)
|
||||
assert "42.000" in result.explanation
|
||||
|
||||
|
||||
class TestDictOutput:
|
||||
"""Tests for dictionary output format."""
|
||||
|
||||
def test_dict_output_structure(self):
|
||||
"""Test that dict output has correct structure."""
|
||||
result = guess_signal_type_dict(
|
||||
frequency_hz=100_000_000,
|
||||
modulation="FM",
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "primary_label" in result
|
||||
assert "confidence" in result
|
||||
assert "alternatives" in result
|
||||
assert "explanation" in result
|
||||
assert "tags" in result
|
||||
|
||||
def test_dict_confidence_is_string(self):
|
||||
"""Test that confidence in dict is string, not enum."""
|
||||
result = guess_signal_type_dict(
|
||||
frequency_hz=100_000_000,
|
||||
)
|
||||
assert isinstance(result["confidence"], str)
|
||||
assert result["confidence"] in ("LOW", "MEDIUM", "HIGH")
|
||||
|
||||
def test_dict_alternatives_structure(self):
|
||||
"""Test alternatives in dict output."""
|
||||
result = guess_signal_type_dict(
|
||||
frequency_hz=433_920_000,
|
||||
region="UK/EU",
|
||||
)
|
||||
for alt in result["alternatives"]:
|
||||
assert "label" in alt
|
||||
assert "confidence" in alt
|
||||
assert isinstance(alt["confidence"], str)
|
||||
|
||||
|
||||
class TestEngineInstance:
|
||||
"""Tests for SignalGuessingEngine class."""
|
||||
|
||||
def test_engine_default_region(self):
|
||||
"""Test engine uses default region."""
|
||||
engine = SignalGuessingEngine(region="UK/EU")
|
||||
result = engine.guess_signal_type(frequency_hz=433_920_000)
|
||||
assert "ISM" in result.primary_label or "TPMS" in result.primary_label
|
||||
|
||||
def test_engine_override_region(self):
|
||||
"""Test engine allows region override."""
|
||||
engine = SignalGuessingEngine(region="UK/EU")
|
||||
result = engine.guess_signal_type(
|
||||
frequency_hz=315_000_000,
|
||||
region="US", # Override default
|
||||
)
|
||||
# Should match US allocation
|
||||
assert "315" in result.primary_label or "ISM" in result.primary_label or "TPMS" in result.primary_label
|
||||
|
||||
def test_get_frequency_allocations(self):
|
||||
"""Test get_frequency_allocations method."""
|
||||
engine = SignalGuessingEngine(region="UK/EU")
|
||||
allocations = engine.get_frequency_allocations(frequency_hz=433_920_000)
|
||||
assert len(allocations) > 0
|
||||
assert any("ISM" in a or "TPMS" in a for a in allocations)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_exact_band_edge(self):
|
||||
"""Test frequency at exact band edge."""
|
||||
# FM band starts at 87.5 MHz
|
||||
result = guess_signal_type(frequency_hz=87_500_000)
|
||||
assert result.primary_label == "FM Broadcast Radio"
|
||||
|
||||
def test_very_narrow_bandwidth(self):
|
||||
"""Test very narrow bandwidth handling."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
bandwidth_hz=100, # Very narrow
|
||||
region="UK/EU",
|
||||
)
|
||||
# Should still match but may have lower score
|
||||
assert result.primary_label != "Unknown Signal"
|
||||
|
||||
def test_very_wide_bandwidth(self):
|
||||
"""Test very wide bandwidth handling."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=2_450_000_000,
|
||||
bandwidth_hz=100_000_000, # 100 MHz - very wide
|
||||
)
|
||||
# Should still identify ISM but may penalize
|
||||
assert "ISM" in result.primary_label or "Unknown" in result.primary_label
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""Test zero duration handling."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
duration_ms=0,
|
||||
region="UK/EU",
|
||||
)
|
||||
assert result.primary_label != "Unknown Signal"
|
||||
|
||||
def test_high_repetition_count(self):
|
||||
"""Test high repetition count."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=433_920_000,
|
||||
repetition_count=1000,
|
||||
region="UK/EU",
|
||||
)
|
||||
# Should handle gracefully
|
||||
assert result.primary_label != "Unknown Signal"
|
||||
|
||||
def test_all_optional_params_none(self):
|
||||
"""Test with only frequency provided."""
|
||||
result = guess_signal_type(frequency_hz=100_000_000)
|
||||
assert result.primary_label is not None
|
||||
assert result.confidence is not None
|
||||
|
||||
|
||||
class TestSpecificSignalTypes:
|
||||
"""Tests for specific signal type identifications."""
|
||||
|
||||
def test_marine_vhf_channel_16(self):
|
||||
"""Test Marine VHF Channel 16 (distress)."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=156_800_000, # CH 16
|
||||
modulation="NFM",
|
||||
)
|
||||
assert "Marine" in result.primary_label
|
||||
|
||||
def test_amateur_2m_calling(self):
|
||||
"""Test amateur radio 2m calling frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=145_500_000,
|
||||
modulation="FM",
|
||||
)
|
||||
assert "Amateur" in result.primary_label or "2m" in result.primary_label
|
||||
|
||||
def test_amateur_70cm(self):
|
||||
"""Test amateur radio 70cm band."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=438_500_000,
|
||||
modulation="NFM",
|
||||
)
|
||||
# Could be amateur 70cm or ISM 433 (they overlap)
|
||||
assert "Amateur" in result.primary_label or "ISM" in result.primary_label
|
||||
|
||||
def test_noaa_weather_satellite(self):
|
||||
"""Test NOAA weather satellite frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=137_500_000,
|
||||
modulation="FM",
|
||||
bandwidth_hz=38_000,
|
||||
)
|
||||
assert "Weather" in result.primary_label or "NOAA" in result.primary_label or "Satellite" in result.primary_label
|
||||
|
||||
def test_adsb_1090(self):
|
||||
"""Test ADS-B at 1090 MHz."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=1_090_000_000,
|
||||
duration_ms=50, # Short burst
|
||||
)
|
||||
assert "ADS-B" in result.primary_label or "Aircraft" in result.primary_label
|
||||
|
||||
def test_dect_cordless(self):
|
||||
"""Test DECT cordless phone frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=1_890_000_000,
|
||||
modulation="GFSK",
|
||||
)
|
||||
assert "DECT" in result.primary_label
|
||||
|
||||
def test_pager_uk(self):
|
||||
"""Test UK pager frequency."""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=153_350_000,
|
||||
modulation="FSK",
|
||||
)
|
||||
assert "Pager" in result.primary_label
|
||||
810
utils/signal_guess.py
Normal file
810
utils/signal_guess.py
Normal file
@@ -0,0 +1,810 @@
|
||||
"""
|
||||
Signal Guessing Engine
|
||||
|
||||
Heuristic-based signal identification that provides plain-English guesses
|
||||
for detected signals based on frequency, modulation, bandwidth, and behavior.
|
||||
|
||||
All outputs use hedged language - never claims certainty, uses
|
||||
"likely", "consistent with", "could be" phrasing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Confidence Levels
|
||||
# =============================================================================
|
||||
|
||||
class Confidence(Enum):
|
||||
"""Signal identification confidence level."""
|
||||
LOW = "LOW"
|
||||
MEDIUM = "MEDIUM"
|
||||
HIGH = "HIGH"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal Type Definitions
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class SignalTypeDefinition:
|
||||
"""Definition of a known signal type with matching criteria."""
|
||||
label: str
|
||||
tags: list[str]
|
||||
description: str
|
||||
# Frequency ranges in Hz: list of (min_hz, max_hz) tuples
|
||||
frequency_ranges: list[tuple[int, int]]
|
||||
# Optional modulation hints (if provided, boosts confidence)
|
||||
modulation_hints: list[str] = field(default_factory=list)
|
||||
# Optional bandwidth range (min_hz, max_hz) - if provided, used for scoring
|
||||
bandwidth_range: Optional[tuple[int, int]] = None
|
||||
# Base score for frequency match
|
||||
base_score: int = 10
|
||||
# Is this a burst/telemetry type signal?
|
||||
is_burst_type: bool = False
|
||||
# Region applicability
|
||||
regions: list[str] = field(default_factory=lambda: ["UK/EU", "US", "GLOBAL"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Frequency Range Tables (UK/EU focus, with US variants)
|
||||
# =============================================================================
|
||||
|
||||
# All frequencies in Hz
|
||||
SIGNAL_TYPES: list[SignalTypeDefinition] = [
|
||||
# FM Broadcast Radio
|
||||
SignalTypeDefinition(
|
||||
label="FM Broadcast Radio",
|
||||
tags=["broadcast", "commercial", "wideband"],
|
||||
description="Commercial FM radio station transmission",
|
||||
frequency_ranges=[
|
||||
(87_500_000, 108_000_000), # 87.5 - 108 MHz
|
||||
],
|
||||
modulation_hints=["WFM", "FM", "WBFM"],
|
||||
bandwidth_range=(150_000, 250_000), # ~200 kHz typical
|
||||
base_score=15,
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# Civil Aviation / Airband
|
||||
SignalTypeDefinition(
|
||||
label="Airband (Civil Aviation Voice)",
|
||||
tags=["aviation", "voice", "aeronautical"],
|
||||
description="Civil aviation voice communications",
|
||||
frequency_ranges=[
|
||||
(118_000_000, 137_000_000), # 118 - 137 MHz (international)
|
||||
],
|
||||
modulation_hints=["AM", "A3E"],
|
||||
bandwidth_range=(6_000, 10_000), # ~8 kHz AM voice
|
||||
base_score=15,
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# ISM 433 MHz (EU)
|
||||
SignalTypeDefinition(
|
||||
label="ISM Device (433 MHz)",
|
||||
tags=["ism", "short-range", "telemetry", "consumer"],
|
||||
description="Industrial, Scientific, Medical band device",
|
||||
frequency_ranges=[
|
||||
(433_050_000, 434_790_000), # 433.05 - 434.79 MHz (EU ISM)
|
||||
],
|
||||
modulation_hints=["OOK", "ASK", "FSK", "NFM", "FM"],
|
||||
bandwidth_range=(10_000, 50_000),
|
||||
base_score=12,
|
||||
is_burst_type=True,
|
||||
regions=["UK/EU"],
|
||||
),
|
||||
|
||||
# ISM 315 MHz (US)
|
||||
SignalTypeDefinition(
|
||||
label="ISM Device (315 MHz)",
|
||||
tags=["ism", "short-range", "telemetry", "consumer"],
|
||||
description="Industrial, Scientific, Medical band device (US)",
|
||||
frequency_ranges=[
|
||||
(315_000_000, 316_000_000), # 315 MHz US ISM
|
||||
],
|
||||
modulation_hints=["OOK", "ASK", "FSK"],
|
||||
bandwidth_range=(10_000, 50_000),
|
||||
base_score=12,
|
||||
is_burst_type=True,
|
||||
regions=["US"],
|
||||
),
|
||||
|
||||
# ISM 868 MHz (EU)
|
||||
SignalTypeDefinition(
|
||||
label="ISM Device (868 MHz)",
|
||||
tags=["ism", "short-range", "telemetry", "iot"],
|
||||
description="868 MHz ISM band device (LoRa, sensors, IoT)",
|
||||
frequency_ranges=[
|
||||
(868_000_000, 868_600_000), # 868 MHz EU ISM
|
||||
(869_400_000, 869_650_000), # 869 MHz EU ISM (higher power)
|
||||
],
|
||||
modulation_hints=["FSK", "GFSK", "LoRa", "OOK", "NFM"],
|
||||
bandwidth_range=(10_000, 150_000),
|
||||
base_score=12,
|
||||
is_burst_type=True,
|
||||
regions=["UK/EU"],
|
||||
),
|
||||
|
||||
# ISM 915 MHz (US)
|
||||
SignalTypeDefinition(
|
||||
label="ISM Device (915 MHz)",
|
||||
tags=["ism", "short-range", "telemetry", "iot"],
|
||||
description="915 MHz ISM band device (US/Americas)",
|
||||
frequency_ranges=[
|
||||
(902_000_000, 928_000_000), # 902-928 MHz US ISM
|
||||
],
|
||||
modulation_hints=["FSK", "GFSK", "LoRa", "OOK", "NFM", "FHSS"],
|
||||
bandwidth_range=(10_000, 500_000),
|
||||
base_score=12,
|
||||
is_burst_type=True,
|
||||
regions=["US"],
|
||||
),
|
||||
|
||||
# ISM 2.4 GHz (Global)
|
||||
SignalTypeDefinition(
|
||||
label="ISM Device (2.4 GHz)",
|
||||
tags=["ism", "wifi", "bluetooth", "wireless"],
|
||||
description="2.4 GHz ISM band (WiFi, Bluetooth, wireless devices)",
|
||||
frequency_ranges=[
|
||||
(2_400_000_000, 2_483_500_000), # 2.4 GHz ISM
|
||||
],
|
||||
modulation_hints=["OFDM", "DSSS", "FHSS", "GFSK", "WiFi", "BT"],
|
||||
bandwidth_range=(1_000_000, 40_000_000), # 1-40 MHz depending on protocol
|
||||
base_score=10,
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# ISM 5.8 GHz (Global)
|
||||
SignalTypeDefinition(
|
||||
label="ISM Device (5.8 GHz)",
|
||||
tags=["ism", "wifi", "wireless", "video"],
|
||||
description="5.8 GHz ISM band (WiFi, video links, wireless devices)",
|
||||
frequency_ranges=[
|
||||
(5_725_000_000, 5_875_000_000), # 5.8 GHz ISM
|
||||
],
|
||||
modulation_hints=["OFDM", "WiFi"],
|
||||
bandwidth_range=(10_000_000, 80_000_000),
|
||||
base_score=10,
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# TPMS / Tire Pressure Monitoring
|
||||
SignalTypeDefinition(
|
||||
label="TPMS / Vehicle Telemetry",
|
||||
tags=["telemetry", "automotive", "burst", "tpms"],
|
||||
description="Tire pressure monitoring or similar vehicle telemetry",
|
||||
frequency_ranges=[
|
||||
(314_900_000, 315_100_000), # 315 MHz (US TPMS)
|
||||
(433_800_000, 434_000_000), # 433.92 MHz (EU TPMS)
|
||||
(433_900_000, 433_940_000), # Narrow 433.92 MHz
|
||||
],
|
||||
modulation_hints=["OOK", "ASK", "FSK", "NFM"],
|
||||
bandwidth_range=(10_000, 40_000),
|
||||
base_score=10,
|
||||
is_burst_type=True,
|
||||
regions=["UK/EU", "US"],
|
||||
),
|
||||
|
||||
# Cellular / LTE (broad category)
|
||||
SignalTypeDefinition(
|
||||
label="Cellular / Mobile Network",
|
||||
tags=["cellular", "lte", "mobile", "wideband"],
|
||||
description="Mobile network transmission (2G/3G/4G/5G)",
|
||||
frequency_ranges=[
|
||||
# UK/EU common bands
|
||||
(791_000_000, 862_000_000), # Band 20 (800 MHz)
|
||||
(880_000_000, 960_000_000), # Band 8 (900 MHz GSM/UMTS)
|
||||
(1_710_000_000, 1_880_000_000), # Band 3 (1800 MHz)
|
||||
(1_920_000_000, 2_170_000_000), # Band 1 (2100 MHz UMTS)
|
||||
(2_500_000_000, 2_690_000_000), # Band 7 (2600 MHz)
|
||||
# US common bands
|
||||
(698_000_000, 756_000_000), # Band 12/17 (700 MHz)
|
||||
(824_000_000, 894_000_000), # Band 5 (850 MHz)
|
||||
(1_850_000_000, 1_995_000_000), # Band 2/25 (1900 MHz PCS)
|
||||
],
|
||||
modulation_hints=["OFDM", "QAM", "LTE", "4G", "5G", "GSM", "UMTS"],
|
||||
bandwidth_range=(200_000, 20_000_000), # 200 kHz (GSM) to 20 MHz (LTE)
|
||||
base_score=8, # Lower base due to broad category
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# PMR446 (EU license-free)
|
||||
SignalTypeDefinition(
|
||||
label="PMR446 Radio",
|
||||
tags=["pmr", "voice", "handheld", "license-free"],
|
||||
description="License-free handheld radio communications",
|
||||
frequency_ranges=[
|
||||
(446_000_000, 446_200_000), # PMR446 EU
|
||||
],
|
||||
modulation_hints=["NFM", "FM", "DPMR", "dPMR"],
|
||||
bandwidth_range=(6_250, 12_500),
|
||||
base_score=14,
|
||||
regions=["UK/EU"],
|
||||
),
|
||||
|
||||
# Marine VHF
|
||||
SignalTypeDefinition(
|
||||
label="Marine VHF Radio",
|
||||
tags=["marine", "maritime", "voice", "nautical"],
|
||||
description="Marine VHF voice communications",
|
||||
frequency_ranges=[
|
||||
(156_000_000, 162_025_000), # Marine VHF band
|
||||
],
|
||||
modulation_hints=["NFM", "FM"],
|
||||
bandwidth_range=(12_500, 25_000),
|
||||
base_score=14,
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# Amateur Radio 2m
|
||||
SignalTypeDefinition(
|
||||
label="Amateur Radio (2m)",
|
||||
tags=["amateur", "ham", "voice", "vhf"],
|
||||
description="Amateur radio 2-meter band",
|
||||
frequency_ranges=[
|
||||
(144_000_000, 148_000_000), # 2m band (Region 1 & 2 overlap)
|
||||
],
|
||||
modulation_hints=["NFM", "FM", "SSB", "USB", "LSB", "CW"],
|
||||
bandwidth_range=(2_400, 15_000),
|
||||
base_score=12,
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# Amateur Radio 70cm
|
||||
SignalTypeDefinition(
|
||||
label="Amateur Radio (70cm)",
|
||||
tags=["amateur", "ham", "voice", "uhf"],
|
||||
description="Amateur radio 70-centimeter band",
|
||||
frequency_ranges=[
|
||||
(430_000_000, 440_000_000), # 70cm band
|
||||
],
|
||||
modulation_hints=["NFM", "FM", "SSB", "USB", "LSB", "CW", "D-STAR", "DMR"],
|
||||
bandwidth_range=(2_400, 15_000),
|
||||
base_score=12,
|
||||
regions=["UK/EU", "US", "GLOBAL"],
|
||||
),
|
||||
|
||||
# DECT Cordless Phones
|
||||
SignalTypeDefinition(
|
||||
label="DECT Cordless Phone",
|
||||
tags=["dect", "cordless", "telephony", "consumer"],
|
||||
description="Digital Enhanced Cordless Telecommunications",
|
||||
frequency_ranges=[
|
||||
(1_880_000_000, 1_900_000_000), # DECT EU
|
||||
(1_920_000_000, 1_930_000_000), # DECT US
|
||||
],
|
||||
modulation_hints=["GFSK", "DECT"],
|
||||
bandwidth_range=(1_728_000, 1_728_000), # Fixed 1.728 MHz
|
||||
base_score=12,
|
||||
regions=["UK/EU", "US"],
|
||||
),
|
||||
|
||||
# DAB Digital Radio
|
||||
SignalTypeDefinition(
|
||||
label="DAB Digital Radio",
|
||||
tags=["broadcast", "digital", "dab", "wideband"],
|
||||
description="Digital Audio Broadcasting radio",
|
||||
frequency_ranges=[
|
||||
(174_000_000, 240_000_000), # DAB Band III
|
||||
],
|
||||
modulation_hints=["OFDM", "DAB", "DAB+"],
|
||||
bandwidth_range=(1_500_000, 1_600_000), # ~1.5 MHz per multiplex
|
||||
base_score=14,
|
||||
regions=["UK/EU"],
|
||||
),
|
||||
|
||||
# Pager (POCSAG/FLEX)
|
||||
SignalTypeDefinition(
|
||||
label="Pager Network",
|
||||
tags=["pager", "pocsag", "flex", "messaging"],
|
||||
description="Paging network transmission (POCSAG/FLEX)",
|
||||
frequency_ranges=[
|
||||
(153_000_000, 154_000_000), # UK pager frequencies
|
||||
(466_000_000, 467_000_000), # Additional pager band
|
||||
(929_000_000, 932_000_000), # US pager band
|
||||
],
|
||||
modulation_hints=["FSK", "POCSAG", "FLEX"],
|
||||
bandwidth_range=(12_500, 25_000),
|
||||
base_score=13,
|
||||
regions=["UK/EU", "US"],
|
||||
),
|
||||
|
||||
# Weather Satellite (NOAA APT)
|
||||
SignalTypeDefinition(
|
||||
label="Weather Satellite (NOAA)",
|
||||
tags=["satellite", "weather", "apt", "noaa"],
|
||||
description="NOAA weather satellite APT transmission",
|
||||
frequency_ranges=[
|
||||
(137_000_000, 138_000_000), # NOAA APT
|
||||
],
|
||||
modulation_hints=["APT", "FM", "NFM"],
|
||||
bandwidth_range=(34_000, 40_000),
|
||||
base_score=14,
|
||||
regions=["GLOBAL"],
|
||||
),
|
||||
|
||||
# ADS-B
|
||||
SignalTypeDefinition(
|
||||
label="ADS-B Aircraft Tracking",
|
||||
tags=["aviation", "adsb", "surveillance", "tracking"],
|
||||
description="Automatic Dependent Surveillance-Broadcast",
|
||||
frequency_ranges=[
|
||||
(1_090_000_000, 1_090_000_000), # 1090 MHz exactly
|
||||
],
|
||||
modulation_hints=["PPM", "ADSB"],
|
||||
bandwidth_range=(1_000_000, 2_000_000),
|
||||
base_score=15,
|
||||
is_burst_type=True,
|
||||
regions=["GLOBAL"],
|
||||
),
|
||||
|
||||
# LoRaWAN
|
||||
SignalTypeDefinition(
|
||||
label="LoRaWAN / LoRa Device",
|
||||
tags=["iot", "lora", "lpwan", "telemetry"],
|
||||
description="LoRa long-range IoT device",
|
||||
frequency_ranges=[
|
||||
(863_000_000, 870_000_000), # EU868
|
||||
(902_000_000, 928_000_000), # US915
|
||||
],
|
||||
modulation_hints=["LoRa", "CSS", "FSK"],
|
||||
bandwidth_range=(125_000, 500_000), # LoRa spreading bandwidths
|
||||
base_score=11,
|
||||
is_burst_type=True,
|
||||
regions=["UK/EU", "US"],
|
||||
),
|
||||
|
||||
# Key Fob / Remote
|
||||
SignalTypeDefinition(
|
||||
label="Remote Control / Key Fob",
|
||||
tags=["remote", "keyfob", "automotive", "burst", "ism"],
|
||||
description="Wireless remote control or vehicle key fob",
|
||||
frequency_ranges=[
|
||||
(314_900_000, 315_100_000), # 315 MHz (US)
|
||||
(433_050_000, 434_790_000), # 433 MHz (EU)
|
||||
(867_000_000, 869_000_000), # 868 MHz (EU)
|
||||
],
|
||||
modulation_hints=["OOK", "ASK", "FSK", "rolling"],
|
||||
bandwidth_range=(10_000, 50_000),
|
||||
base_score=10,
|
||||
is_burst_type=True,
|
||||
regions=["UK/EU", "US"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal Guess Result
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class SignalAlternative:
|
||||
"""An alternative signal type guess."""
|
||||
label: str
|
||||
confidence: Confidence
|
||||
score: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalGuessResult:
|
||||
"""Complete signal guess result with hedged language."""
|
||||
primary_label: str
|
||||
confidence: Confidence
|
||||
alternatives: list[SignalAlternative]
|
||||
explanation: str
|
||||
tags: list[str]
|
||||
# Internal scoring data (useful for debugging/testing)
|
||||
_scores: dict[str, int] = field(default_factory=dict, repr=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal Guessing Engine
|
||||
# =============================================================================
|
||||
|
||||
class SignalGuessingEngine:
|
||||
"""
|
||||
Heuristic-based signal identification engine.
|
||||
|
||||
Provides plain-English guesses for detected signals based on frequency,
|
||||
modulation, bandwidth, and behavioral characteristics.
|
||||
|
||||
All outputs use hedged language - never claims certainty.
|
||||
"""
|
||||
|
||||
def __init__(self, region: str = "UK/EU"):
|
||||
"""
|
||||
Initialize the guessing engine.
|
||||
|
||||
Args:
|
||||
region: Default region for frequency allocations.
|
||||
Options: "UK/EU", "US", "GLOBAL"
|
||||
"""
|
||||
self.region = region
|
||||
self._signal_types = SIGNAL_TYPES
|
||||
|
||||
def guess_signal_type(
|
||||
self,
|
||||
frequency_hz: int,
|
||||
modulation: Optional[str] = None,
|
||||
bandwidth_hz: Optional[int] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
repetition_count: Optional[int] = None,
|
||||
rssi_dbm: Optional[float] = None,
|
||||
region: Optional[str] = None,
|
||||
) -> SignalGuessResult:
|
||||
"""
|
||||
Guess the signal type based on detection parameters.
|
||||
|
||||
Args:
|
||||
frequency_hz: Center frequency in Hz (required)
|
||||
modulation: Modulation type string (e.g., "FM", "AM", "NFM")
|
||||
bandwidth_hz: Estimated signal bandwidth in Hz
|
||||
duration_ms: How long the signal was observed in milliseconds
|
||||
repetition_count: How many times seen recently
|
||||
rssi_dbm: Signal strength in dBm
|
||||
region: Override default region
|
||||
|
||||
Returns:
|
||||
SignalGuessResult with primary guess, alternatives, and explanation
|
||||
"""
|
||||
effective_region = region or self.region
|
||||
|
||||
# Score all signal types
|
||||
scores: dict[str, int] = {}
|
||||
matched_types: dict[str, SignalTypeDefinition] = {}
|
||||
|
||||
for signal_type in self._signal_types:
|
||||
score = self._score_signal_type(
|
||||
signal_type,
|
||||
frequency_hz,
|
||||
modulation,
|
||||
bandwidth_hz,
|
||||
duration_ms,
|
||||
repetition_count,
|
||||
effective_region,
|
||||
)
|
||||
if score > 0:
|
||||
scores[signal_type.label] = score
|
||||
matched_types[signal_type.label] = signal_type
|
||||
|
||||
# If no matches, return unknown
|
||||
if not scores:
|
||||
return SignalGuessResult(
|
||||
primary_label="Unknown Signal",
|
||||
confidence=Confidence.LOW,
|
||||
alternatives=[],
|
||||
explanation=self._build_unknown_explanation(frequency_hz, modulation),
|
||||
tags=["unknown"],
|
||||
_scores={},
|
||||
)
|
||||
|
||||
# Sort by score descending
|
||||
sorted_labels = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
|
||||
|
||||
# Primary guess
|
||||
primary_label = sorted_labels[0]
|
||||
primary_score = scores[primary_label]
|
||||
primary_type = matched_types[primary_label]
|
||||
|
||||
# Calculate confidence based on score and margin
|
||||
confidence = self._calculate_confidence(
|
||||
primary_score,
|
||||
scores,
|
||||
sorted_labels,
|
||||
modulation,
|
||||
bandwidth_hz,
|
||||
)
|
||||
|
||||
# Build alternatives (up to 3, excluding primary)
|
||||
alternatives = []
|
||||
for label in sorted_labels[1:4]: # Next 3 candidates
|
||||
alt_score = scores[label]
|
||||
# Alternative confidence is always at most one level below primary
|
||||
# unless scores are very close
|
||||
alt_confidence = self._calculate_alternative_confidence(
|
||||
alt_score, primary_score, confidence
|
||||
)
|
||||
alternatives.append(SignalAlternative(
|
||||
label=label,
|
||||
confidence=alt_confidence,
|
||||
score=alt_score,
|
||||
))
|
||||
|
||||
# Build explanation
|
||||
explanation = self._build_explanation(
|
||||
primary_type,
|
||||
confidence,
|
||||
frequency_hz,
|
||||
modulation,
|
||||
bandwidth_hz,
|
||||
duration_ms,
|
||||
repetition_count,
|
||||
)
|
||||
|
||||
return SignalGuessResult(
|
||||
primary_label=primary_label,
|
||||
confidence=confidence,
|
||||
alternatives=alternatives,
|
||||
explanation=explanation,
|
||||
tags=primary_type.tags.copy(),
|
||||
_scores=scores,
|
||||
)
|
||||
|
||||
def _score_signal_type(
|
||||
self,
|
||||
signal_type: SignalTypeDefinition,
|
||||
frequency_hz: int,
|
||||
modulation: Optional[str],
|
||||
bandwidth_hz: Optional[int],
|
||||
duration_ms: Optional[int],
|
||||
repetition_count: Optional[int],
|
||||
region: str,
|
||||
) -> int:
|
||||
"""Calculate score for a signal type match."""
|
||||
score = 0
|
||||
|
||||
# Check region applicability
|
||||
if region not in signal_type.regions and "GLOBAL" not in signal_type.regions:
|
||||
return 0
|
||||
|
||||
# Check frequency match (required)
|
||||
freq_match = False
|
||||
for freq_min, freq_max in signal_type.frequency_ranges:
|
||||
if freq_min <= frequency_hz <= freq_max:
|
||||
freq_match = True
|
||||
break
|
||||
|
||||
if not freq_match:
|
||||
return 0
|
||||
|
||||
# Base score for frequency match
|
||||
score = signal_type.base_score
|
||||
|
||||
# Modulation bonus
|
||||
if modulation:
|
||||
mod_upper = modulation.upper()
|
||||
for hint in signal_type.modulation_hints:
|
||||
if hint.upper() in mod_upper or mod_upper in hint.upper():
|
||||
score += 5
|
||||
break
|
||||
|
||||
# Bandwidth bonus/penalty
|
||||
if bandwidth_hz and signal_type.bandwidth_range:
|
||||
bw_min, bw_max = signal_type.bandwidth_range
|
||||
if bw_min <= bandwidth_hz <= bw_max:
|
||||
score += 4 # Good match
|
||||
elif bandwidth_hz < bw_min * 0.5 or bandwidth_hz > bw_max * 2:
|
||||
score -= 3 # Poor match
|
||||
# Otherwise neutral
|
||||
|
||||
# Burst behavior bonus for burst-type signals
|
||||
if signal_type.is_burst_type:
|
||||
if duration_ms is not None and duration_ms < 1000: # Short burst < 1 second
|
||||
score += 3
|
||||
if repetition_count is not None and repetition_count >= 2:
|
||||
score += 2 # Multiple bursts suggest telemetry/periodic
|
||||
|
||||
return max(0, score)
|
||||
|
||||
def _calculate_confidence(
|
||||
self,
|
||||
primary_score: int,
|
||||
all_scores: dict[str, int],
|
||||
sorted_labels: list[str],
|
||||
modulation: Optional[str],
|
||||
bandwidth_hz: Optional[int],
|
||||
) -> Confidence:
|
||||
"""Calculate confidence level based on scores and data quality."""
|
||||
|
||||
# High confidence requires:
|
||||
# - High absolute score (>= 18)
|
||||
# - Good margin over second place (>= 5 points)
|
||||
# - Some supporting data (modulation or bandwidth)
|
||||
|
||||
if len(sorted_labels) == 1:
|
||||
# Only one candidate
|
||||
if primary_score >= 18 and (modulation or bandwidth_hz):
|
||||
return Confidence.HIGH
|
||||
elif primary_score >= 14:
|
||||
return Confidence.MEDIUM
|
||||
return Confidence.LOW
|
||||
|
||||
second_score = all_scores[sorted_labels[1]]
|
||||
margin = primary_score - second_score
|
||||
|
||||
if primary_score >= 18 and margin >= 5:
|
||||
return Confidence.HIGH
|
||||
elif primary_score >= 14 and margin >= 3:
|
||||
return Confidence.MEDIUM
|
||||
elif primary_score >= 12 and margin >= 2:
|
||||
return Confidence.MEDIUM
|
||||
return Confidence.LOW
|
||||
|
||||
def _calculate_alternative_confidence(
|
||||
self,
|
||||
alt_score: int,
|
||||
primary_score: int,
|
||||
primary_confidence: Confidence,
|
||||
) -> Confidence:
|
||||
"""Calculate confidence for an alternative guess."""
|
||||
score_ratio = alt_score / primary_score if primary_score > 0 else 0
|
||||
|
||||
if score_ratio >= 0.9:
|
||||
# Very close to primary - same confidence or one below
|
||||
if primary_confidence == Confidence.HIGH:
|
||||
return Confidence.MEDIUM
|
||||
return primary_confidence
|
||||
elif score_ratio >= 0.7:
|
||||
# Moderately close
|
||||
if primary_confidence == Confidence.HIGH:
|
||||
return Confidence.MEDIUM
|
||||
return Confidence.LOW
|
||||
else:
|
||||
return Confidence.LOW
|
||||
|
||||
def _build_explanation(
|
||||
self,
|
||||
signal_type: SignalTypeDefinition,
|
||||
confidence: Confidence,
|
||||
frequency_hz: int,
|
||||
modulation: Optional[str],
|
||||
bandwidth_hz: Optional[int],
|
||||
duration_ms: Optional[int],
|
||||
repetition_count: Optional[int],
|
||||
) -> str:
|
||||
"""Build a hedged, client-safe explanation."""
|
||||
freq_mhz = frequency_hz / 1_000_000
|
||||
|
||||
# Start with frequency observation
|
||||
if confidence == Confidence.HIGH:
|
||||
explanation = f"Frequency of {freq_mhz:.3f} MHz is consistent with {signal_type.description.lower()}."
|
||||
elif confidence == Confidence.MEDIUM:
|
||||
explanation = f"Frequency of {freq_mhz:.3f} MHz could indicate {signal_type.description.lower()}."
|
||||
else:
|
||||
explanation = f"Frequency of {freq_mhz:.3f} MHz may be associated with {signal_type.description.lower()}."
|
||||
|
||||
# Add supporting evidence
|
||||
evidence = []
|
||||
if modulation:
|
||||
evidence.append(f"{modulation} modulation")
|
||||
if bandwidth_hz:
|
||||
bw_khz = bandwidth_hz / 1000
|
||||
evidence.append(f"~{bw_khz:.0f} kHz bandwidth")
|
||||
if duration_ms is not None and duration_ms < 1000:
|
||||
evidence.append("short-burst pattern")
|
||||
if repetition_count is not None and repetition_count >= 3:
|
||||
evidence.append("repeated transmission")
|
||||
|
||||
if evidence:
|
||||
evidence_str = ", ".join(evidence)
|
||||
if confidence == Confidence.HIGH:
|
||||
explanation += f" Observed characteristics ({evidence_str}) support this identification."
|
||||
else:
|
||||
explanation += f" Observed {evidence_str}."
|
||||
|
||||
return explanation
|
||||
|
||||
def _build_unknown_explanation(
|
||||
self,
|
||||
frequency_hz: int,
|
||||
modulation: Optional[str],
|
||||
) -> str:
|
||||
"""Build explanation for unknown signal."""
|
||||
freq_mhz = frequency_hz / 1_000_000
|
||||
if modulation:
|
||||
return (
|
||||
f"Signal at {freq_mhz:.3f} MHz with {modulation} modulation "
|
||||
f"does not match common allocations for this region."
|
||||
)
|
||||
return (
|
||||
f"Signal at {freq_mhz:.3f} MHz does not match common allocations "
|
||||
f"for this region. Additional characteristics may help identification."
|
||||
)
|
||||
|
||||
def get_frequency_allocations(
|
||||
self,
|
||||
frequency_hz: int,
|
||||
region: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get all possible allocations for a frequency.
|
||||
|
||||
Useful for displaying what services could operate at a given frequency.
|
||||
"""
|
||||
effective_region = region or self.region
|
||||
allocations = []
|
||||
|
||||
for signal_type in self._signal_types:
|
||||
if effective_region not in signal_type.regions and "GLOBAL" not in signal_type.regions:
|
||||
continue
|
||||
|
||||
for freq_min, freq_max in signal_type.frequency_ranges:
|
||||
if freq_min <= frequency_hz <= freq_max:
|
||||
allocations.append(signal_type.label)
|
||||
break
|
||||
|
||||
return allocations
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience Functions
|
||||
# =============================================================================
|
||||
|
||||
# Default engine instance
|
||||
_default_engine: Optional[SignalGuessingEngine] = None
|
||||
|
||||
|
||||
def get_engine(region: str = "UK/EU") -> SignalGuessingEngine:
|
||||
"""Get or create the default engine instance."""
|
||||
global _default_engine
|
||||
if _default_engine is None or _default_engine.region != region:
|
||||
_default_engine = SignalGuessingEngine(region=region)
|
||||
return _default_engine
|
||||
|
||||
|
||||
def guess_signal_type(
|
||||
frequency_hz: int,
|
||||
modulation: Optional[str] = None,
|
||||
bandwidth_hz: Optional[int] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
repetition_count: Optional[int] = None,
|
||||
rssi_dbm: Optional[float] = None,
|
||||
region: str = "UK/EU",
|
||||
) -> SignalGuessResult:
|
||||
"""
|
||||
Convenience function to guess signal type.
|
||||
|
||||
See SignalGuessingEngine.guess_signal_type for full documentation.
|
||||
"""
|
||||
engine = get_engine(region)
|
||||
return engine.guess_signal_type(
|
||||
frequency_hz=frequency_hz,
|
||||
modulation=modulation,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
duration_ms=duration_ms,
|
||||
repetition_count=repetition_count,
|
||||
rssi_dbm=rssi_dbm,
|
||||
region=region,
|
||||
)
|
||||
|
||||
|
||||
def guess_signal_type_dict(
|
||||
frequency_hz: int,
|
||||
modulation: Optional[str] = None,
|
||||
bandwidth_hz: Optional[int] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
repetition_count: Optional[int] = None,
|
||||
rssi_dbm: Optional[float] = None,
|
||||
region: str = "UK/EU",
|
||||
) -> dict:
|
||||
"""
|
||||
Convenience function returning dict (for JSON serialization).
|
||||
"""
|
||||
result = guess_signal_type(
|
||||
frequency_hz=frequency_hz,
|
||||
modulation=modulation,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
duration_ms=duration_ms,
|
||||
repetition_count=repetition_count,
|
||||
rssi_dbm=rssi_dbm,
|
||||
region=region,
|
||||
)
|
||||
|
||||
return {
|
||||
"primary_label": result.primary_label,
|
||||
"confidence": result.confidence.value,
|
||||
"alternatives": [
|
||||
{
|
||||
"label": alt.label,
|
||||
"confidence": alt.confidence.value,
|
||||
}
|
||||
for alt in result.alternatives
|
||||
],
|
||||
"explanation": result.explanation,
|
||||
"tags": result.tags,
|
||||
}
|
||||
Reference in New Issue
Block a user