+ Note: Signal identification is based on frequency allocation patterns and observed characteristics.
+ Results are probabilistic and should not be considered definitive.
+
+
+ `;
+
+ document.body.appendChild(popup);
+
+ // Position near anchor
+ const rect = anchorEl.getBoundingClientRect();
+ popup.style.position = 'fixed';
+ popup.style.top = `${rect.bottom + 5}px`;
+ popup.style.left = `${Math.min(rect.left, window.innerWidth - 320)}px`;
+
+ // Close handlers
+ const closeBtn = popup.querySelector('.signal-guess-popup-close');
+ closeBtn.addEventListener('click', () => popup.remove());
+
+ // Close on outside click
+ setTimeout(() => {
+ document.addEventListener('click', function handler(e) {
+ if (!popup.contains(e.target)) {
+ popup.remove();
+ document.removeEventListener('click', handler);
+ }
+ });
+ }, 0);
+ }
+
+ /**
+ * Escape HTML for safe display.
+ */
+ function escapeHtml(text) {
+ if (text === null || text === undefined) return '';
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+ }
+
+ // ==========================================================================
+ // CSS Styles (inject on load)
+ // ==========================================================================
+
+ function injectStyles() {
+ if (document.getElementById('signal-guess-styles')) return;
+
+ const style = document.createElement('style');
+ style.id = 'signal-guess-styles';
+ style.textContent = `
+ .signal-guess-container {
+ font-size: 13px;
+ line-height: 1.4;
+ }
+ .signal-guess-container.compact {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ }
+ .signal-guess-primary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+ .signal-guess-label {
+ font-weight: 500;
+ color: #e0e0e0;
+ }
+ .signal-guess-confidence {
+ display: inline-block;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+ color: #fff;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+ .signal-guess-confidence-low { background-color: #888 !important; }
+ .signal-guess-confidence-medium { background-color: #f0ad4e !important; }
+ .signal-guess-confidence-high { background-color: #5cb85c !important; }
+ .signal-guess-why {
+ background: transparent;
+ border: 1px solid #555;
+ color: #999;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 11px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ }
+ .signal-guess-why:hover {
+ border-color: #00d4ff;
+ color: #00d4ff;
+ }
+ .signal-guess-tags {
+ display: flex;
+ gap: 4px;
+ margin-top: 6px;
+ flex-wrap: wrap;
+ }
+ .signal-guess-tag {
+ background: #2a2a2a;
+ color: #888;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ }
+ .signal-guess-alternatives {
+ margin-top: 8px;
+ }
+ .signal-guess-alt-toggle {
+ background: transparent;
+ border: none;
+ color: #666;
+ font-size: 11px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0;
+ }
+ .signal-guess-alt-toggle:hover {
+ color: #888;
+ }
+ .signal-guess-alt-toggle svg {
+ transition: transform 0.15s ease;
+ }
+ .signal-guess-alt-toggle.open svg {
+ transform: rotate(180deg);
+ }
+ .signal-guess-alt-list {
+ margin-top: 6px;
+ padding-left: 12px;
+ border-left: 2px solid #333;
+ }
+ .signal-guess-alt-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+ font-size: 12px;
+ color: #999;
+ }
+ .signal-guess-alt-label {
+ flex: 1;
+ }
+ /* Compact badge */
+ .signal-guess-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: #1a1a1a;
+ border: 1px solid #333;
+ border-radius: 4px;
+ padding: 2px 6px;
+ font-size: 11px;
+ }
+ .signal-guess-badge-label {
+ color: #ccc;
+ }
+ .signal-guess-badge-conf {
+ padding: 1px 4px;
+ border-radius: 2px;
+ font-size: 9px;
+ font-weight: 600;
+ color: #fff;
+ }
+ /* Popup */
+ .signal-guess-popup {
+ position: fixed;
+ z-index: 10000;
+ background: #1e1e1e;
+ border: 1px solid #444;
+ border-radius: 6px;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5);
+ width: 300px;
+ max-width: 90vw;
+ }
+ .signal-guess-popup-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ border-bottom: 1px solid #333;
+ color: #e0e0e0;
+ }
+ .signal-guess-popup-close {
+ background: transparent;
+ border: none;
+ color: #666;
+ font-size: 18px;
+ cursor: pointer;
+ line-height: 1;
+ }
+ .signal-guess-popup-close:hover {
+ color: #fff;
+ }
+ .signal-guess-popup-body {
+ padding: 12px;
+ }
+ .signal-guess-popup-primary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+ }
+ .signal-guess-popup-explanation {
+ color: #aaa;
+ font-size: 12px;
+ line-height: 1.5;
+ margin: 0 0 10px 0;
+ }
+ .signal-guess-popup-tags {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+ }
+ .signal-guess-popup-alts {
+ border-top: 1px solid #333;
+ padding-top: 10px;
+ margin-top: 10px;
+ }
+ .signal-guess-popup-alts-title {
+ font-size: 11px;
+ color: #666;
+ margin-bottom: 6px;
+ }
+ .signal-guess-popup-disclaimer {
+ font-size: 10px;
+ color: #555;
+ margin-top: 12px;
+ padding-top: 10px;
+ border-top: 1px solid #333;
+ line-height: 1.4;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ // Inject styles on load
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', injectStyles);
+ } else {
+ injectStyles();
+ }
+
+ // ==========================================================================
+ // Public API
+ // ==========================================================================
+
+ return {
+ // Constants
+ Confidence,
+ CONFIDENCE_COLORS,
+ SIGNAL_TYPES,
+
+ // Core function
+ guessSignalType,
+
+ // UI components
+ createGuessElement,
+ createCompactBadge,
+ showExplanationPopup,
+
+ // Utilities
+ escapeHtml,
+ injectStyles
+ };
+
+})();
+
+// Global export
+window.SignalGuess = SignalGuess;
diff --git a/tests/test_signal_guess.py b/tests/test_signal_guess.py
new file mode 100644
index 0000000..c81e613
--- /dev/null
+++ b/tests/test_signal_guess.py
@@ -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
diff --git a/utils/signal_guess.py b/utils/signal_guess.py
new file mode 100644
index 0000000..8a1d8f9
--- /dev/null
+++ b/utils/signal_guess.py
@@ -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,
+ }