diff --git a/static/js/components/signal-guess.js b/static/js/components/signal-guess.js new file mode 100644 index 0000000..9b75e63 --- /dev/null +++ b/static/js/components/signal-guess.js @@ -0,0 +1,1007 @@ +/** + * Signal Guessing Engine - Client-side Implementation + * + * Heuristic-based signal identification that provides plain-English guesses + * for detected signals. Uses hedged language - never claims certainty. + * + * Matching Python implementation in utils/signal_guess.py + */ + +const SignalGuess = (function() { + 'use strict'; + + // ========================================================================== + // Confidence Levels + // ========================================================================== + + const Confidence = { + LOW: 'LOW', + MEDIUM: 'MEDIUM', + HIGH: 'HIGH' + }; + + // Confidence badge colors + const CONFIDENCE_COLORS = { + LOW: '#888888', + MEDIUM: '#f0ad4e', + HIGH: '#5cb85c' + }; + + // ========================================================================== + // Signal Type Definitions + // ========================================================================== + + // All frequencies in Hz + const SIGNAL_TYPES = [ + // FM Broadcast Radio + { + label: 'FM Broadcast Radio', + tags: ['broadcast', 'commercial', 'wideband'], + description: 'Commercial FM radio station transmission', + frequencyRanges: [[87500000, 108000000]], + modulationHints: ['WFM', 'FM', 'WBFM'], + bandwidthRange: [150000, 250000], + baseScore: 15, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // Civil Aviation / Airband + { + label: 'Airband (Civil Aviation Voice)', + tags: ['aviation', 'voice', 'aeronautical'], + description: 'Civil aviation voice communications', + frequencyRanges: [[118000000, 137000000]], + modulationHints: ['AM', 'A3E'], + bandwidthRange: [6000, 10000], + baseScore: 15, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // ISM 433 MHz (EU) + { + label: 'ISM Device (433 MHz)', + tags: ['ism', 'short-range', 'telemetry', 'consumer'], + description: 'Industrial, Scientific, Medical band device', + frequencyRanges: [[433050000, 434790000]], + modulationHints: ['OOK', 'ASK', 'FSK', 'NFM', 'FM'], + bandwidthRange: [10000, 50000], + baseScore: 12, + isBurstType: true, + regions: ['UK/EU'] + }, + + // ISM 315 MHz (US) + { + label: 'ISM Device (315 MHz)', + tags: ['ism', 'short-range', 'telemetry', 'consumer'], + description: 'Industrial, Scientific, Medical band device (US)', + frequencyRanges: [[315000000, 316000000]], + modulationHints: ['OOK', 'ASK', 'FSK'], + bandwidthRange: [10000, 50000], + baseScore: 12, + isBurstType: true, + regions: ['US'] + }, + + // ISM 868 MHz (EU) + { + label: 'ISM Device (868 MHz)', + tags: ['ism', 'short-range', 'telemetry', 'iot'], + description: '868 MHz ISM band device (LoRa, sensors, IoT)', + frequencyRanges: [[868000000, 868600000], [869400000, 869650000]], + modulationHints: ['FSK', 'GFSK', 'LoRa', 'OOK', 'NFM'], + bandwidthRange: [10000, 150000], + baseScore: 12, + isBurstType: true, + regions: ['UK/EU'] + }, + + // ISM 915 MHz (US) + { + label: 'ISM Device (915 MHz)', + tags: ['ism', 'short-range', 'telemetry', 'iot'], + description: '915 MHz ISM band device (US/Americas)', + frequencyRanges: [[902000000, 928000000]], + modulationHints: ['FSK', 'GFSK', 'LoRa', 'OOK', 'NFM', 'FHSS'], + bandwidthRange: [10000, 500000], + baseScore: 12, + isBurstType: true, + regions: ['US'] + }, + + // ISM 2.4 GHz + { + label: 'ISM Device (2.4 GHz)', + tags: ['ism', 'wifi', 'bluetooth', 'wireless'], + description: '2.4 GHz ISM band (WiFi, Bluetooth, wireless devices)', + frequencyRanges: [[2400000000, 2483500000]], + modulationHints: ['OFDM', 'DSSS', 'FHSS', 'GFSK', 'WiFi', 'BT'], + bandwidthRange: [1000000, 40000000], + baseScore: 10, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // ISM 5.8 GHz + { + label: 'ISM Device (5.8 GHz)', + tags: ['ism', 'wifi', 'wireless', 'video'], + description: '5.8 GHz ISM band (WiFi, video links, wireless devices)', + frequencyRanges: [[5725000000, 5875000000]], + modulationHints: ['OFDM', 'WiFi'], + bandwidthRange: [10000000, 80000000], + baseScore: 10, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // TPMS + { + label: 'TPMS / Vehicle Telemetry', + tags: ['telemetry', 'automotive', 'burst', 'tpms'], + description: 'Tire pressure monitoring or similar vehicle telemetry', + frequencyRanges: [[314900000, 315100000], [433800000, 434000000], [433900000, 433940000]], + modulationHints: ['OOK', 'ASK', 'FSK', 'NFM'], + bandwidthRange: [10000, 40000], + baseScore: 10, + isBurstType: true, + regions: ['UK/EU', 'US'] + }, + + // Cellular + { + label: 'Cellular / Mobile Network', + tags: ['cellular', 'lte', 'mobile', 'wideband'], + description: 'Mobile network transmission (2G/3G/4G/5G)', + frequencyRanges: [ + [791000000, 862000000], + [880000000, 960000000], + [1710000000, 1880000000], + [1920000000, 2170000000], + [2500000000, 2690000000], + [698000000, 756000000], + [824000000, 894000000], + [1850000000, 1995000000] + ], + modulationHints: ['OFDM', 'QAM', 'LTE', '4G', '5G', 'GSM', 'UMTS'], + bandwidthRange: [200000, 20000000], + baseScore: 8, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // PMR446 + { + label: 'PMR446 Radio', + tags: ['pmr', 'voice', 'handheld', 'license-free'], + description: 'License-free handheld radio communications', + frequencyRanges: [[446000000, 446200000]], + modulationHints: ['NFM', 'FM', 'DPMR', 'dPMR'], + bandwidthRange: [6250, 12500], + baseScore: 14, + isBurstType: false, + regions: ['UK/EU'] + }, + + // Marine VHF + { + label: 'Marine VHF Radio', + tags: ['marine', 'maritime', 'voice', 'nautical'], + description: 'Marine VHF voice communications', + frequencyRanges: [[156000000, 162025000]], + modulationHints: ['NFM', 'FM'], + bandwidthRange: [12500, 25000], + baseScore: 14, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // Amateur 2m + { + label: 'Amateur Radio (2m)', + tags: ['amateur', 'ham', 'voice', 'vhf'], + description: 'Amateur radio 2-meter band', + frequencyRanges: [[144000000, 148000000]], + modulationHints: ['NFM', 'FM', 'SSB', 'USB', 'LSB', 'CW'], + bandwidthRange: [2400, 15000], + baseScore: 12, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // Amateur 70cm + { + label: 'Amateur Radio (70cm)', + tags: ['amateur', 'ham', 'voice', 'uhf'], + description: 'Amateur radio 70-centimeter band', + frequencyRanges: [[430000000, 440000000]], + modulationHints: ['NFM', 'FM', 'SSB', 'USB', 'LSB', 'CW', 'D-STAR', 'DMR'], + bandwidthRange: [2400, 15000], + baseScore: 12, + isBurstType: false, + regions: ['UK/EU', 'US', 'GLOBAL'] + }, + + // DECT + { + label: 'DECT Cordless Phone', + tags: ['dect', 'cordless', 'telephony', 'consumer'], + description: 'Digital Enhanced Cordless Telecommunications', + frequencyRanges: [[1880000000, 1900000000], [1920000000, 1930000000]], + modulationHints: ['GFSK', 'DECT'], + bandwidthRange: [1728000, 1728000], + baseScore: 12, + isBurstType: false, + regions: ['UK/EU', 'US'] + }, + + // DAB + { + label: 'DAB Digital Radio', + tags: ['broadcast', 'digital', 'dab', 'wideband'], + description: 'Digital Audio Broadcasting radio', + frequencyRanges: [[174000000, 240000000]], + modulationHints: ['OFDM', 'DAB', 'DAB+'], + bandwidthRange: [1500000, 1600000], + baseScore: 14, + isBurstType: false, + regions: ['UK/EU'] + }, + + // Pager + { + label: 'Pager Network', + tags: ['pager', 'pocsag', 'flex', 'messaging'], + description: 'Paging network transmission (POCSAG/FLEX)', + frequencyRanges: [[153000000, 154000000], [466000000, 467000000], [929000000, 932000000]], + modulationHints: ['FSK', 'POCSAG', 'FLEX'], + bandwidthRange: [12500, 25000], + baseScore: 13, + isBurstType: false, + regions: ['UK/EU', 'US'] + }, + + // Weather Satellite + { + label: 'Weather Satellite (NOAA)', + tags: ['satellite', 'weather', 'apt', 'noaa'], + description: 'NOAA weather satellite APT transmission', + frequencyRanges: [[137000000, 138000000]], + modulationHints: ['APT', 'FM', 'NFM'], + bandwidthRange: [34000, 40000], + baseScore: 14, + isBurstType: false, + regions: ['GLOBAL'] + }, + + // ADS-B + { + label: 'ADS-B Aircraft Tracking', + tags: ['aviation', 'adsb', 'surveillance', 'tracking'], + description: 'Automatic Dependent Surveillance-Broadcast', + frequencyRanges: [[1090000000, 1090000000]], + modulationHints: ['PPM', 'ADSB'], + bandwidthRange: [1000000, 2000000], + baseScore: 15, + isBurstType: true, + regions: ['GLOBAL'] + }, + + // LoRaWAN + { + label: 'LoRaWAN / LoRa Device', + tags: ['iot', 'lora', 'lpwan', 'telemetry'], + description: 'LoRa long-range IoT device', + frequencyRanges: [[863000000, 870000000], [902000000, 928000000]], + modulationHints: ['LoRa', 'CSS', 'FSK'], + bandwidthRange: [125000, 500000], + baseScore: 11, + isBurstType: true, + regions: ['UK/EU', 'US'] + }, + + // Key Fob + { + label: 'Remote Control / Key Fob', + tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'], + description: 'Wireless remote control or vehicle key fob', + frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]], + modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'], + bandwidthRange: [10000, 50000], + baseScore: 10, + isBurstType: true, + regions: ['UK/EU', 'US'] + } + ]; + + // ========================================================================== + // Signal Guessing Engine + // ========================================================================== + + /** + * Guess the signal type based on detection parameters. + * + * @param {Object} detection - Detection parameters + * @param {number} detection.frequency_hz - Center frequency in Hz (required) + * @param {string} [detection.modulation] - Modulation type (e.g., "FM", "AM", "NFM") + * @param {number} [detection.bandwidth_hz] - Estimated bandwidth in Hz + * @param {number} [detection.duration_ms] - How long signal observed in ms + * @param {number} [detection.repetition_count] - How many times seen recently + * @param {number} [detection.rssi_dbm] - Signal strength in dBm + * @param {string} [detection.region="UK/EU"] - Region for frequency allocations + * @returns {Object} Result with primary_label, confidence, alternatives, explanation, tags + */ + function guessSignalType(detection) { + const { + frequency_hz, + modulation = null, + bandwidth_hz = null, + duration_ms = null, + repetition_count = null, + rssi_dbm = null, + region = 'UK/EU' + } = detection; + + if (!frequency_hz || typeof frequency_hz !== 'number') { + return { + primary_label: 'Unknown Signal', + confidence: Confidence.LOW, + alternatives: [], + explanation: 'No frequency data provided.', + tags: ['unknown'] + }; + } + + // Score all signal types + const scores = {}; + const matchedTypes = {}; + + for (const signalType of SIGNAL_TYPES) { + const score = scoreSignalType( + signalType, + frequency_hz, + modulation, + bandwidth_hz, + duration_ms, + repetition_count, + region + ); + if (score > 0) { + scores[signalType.label] = score; + matchedTypes[signalType.label] = signalType; + } + } + + // No matches - return unknown + if (Object.keys(scores).length === 0) { + return { + primary_label: 'Unknown Signal', + confidence: Confidence.LOW, + alternatives: [], + explanation: buildUnknownExplanation(frequency_hz, modulation), + tags: ['unknown'] + }; + } + + // Sort by score descending + const sortedLabels = Object.keys(scores).sort((a, b) => scores[b] - scores[a]); + + // Primary guess + const primaryLabel = sortedLabels[0]; + const primaryScore = scores[primaryLabel]; + const primaryType = matchedTypes[primaryLabel]; + + // Calculate confidence + const confidence = calculateConfidence( + primaryScore, + scores, + sortedLabels, + modulation, + bandwidth_hz + ); + + // Build alternatives (up to 3) + const alternatives = sortedLabels.slice(1, 4).map(label => ({ + label: label, + confidence: calculateAlternativeConfidence(scores[label], primaryScore, confidence) + })); + + // Build explanation + const explanation = buildExplanation( + primaryType, + confidence, + frequency_hz, + modulation, + bandwidth_hz, + duration_ms, + repetition_count + ); + + return { + primary_label: primaryLabel, + confidence: confidence, + alternatives: alternatives, + explanation: explanation, + tags: [...primaryType.tags] + }; + } + + /** + * Calculate score for a signal type match. + */ + function scoreSignalType(signalType, frequency_hz, modulation, bandwidth_hz, duration_ms, repetition_count, region) { + // Check region + if (!signalType.regions.includes(region) && !signalType.regions.includes('GLOBAL')) { + return 0; + } + + // Check frequency match + let freqMatch = false; + for (const [freqMin, freqMax] of signalType.frequencyRanges) { + if (frequency_hz >= freqMin && frequency_hz <= freqMax) { + freqMatch = true; + break; + } + } + + if (!freqMatch) return 0; + + // Base score + let score = signalType.baseScore; + + // Modulation bonus + if (modulation) { + const modUpper = modulation.toUpperCase(); + for (const hint of signalType.modulationHints) { + if (modUpper.includes(hint.toUpperCase()) || hint.toUpperCase().includes(modUpper)) { + score += 5; + break; + } + } + } + + // Bandwidth bonus/penalty + if (bandwidth_hz && signalType.bandwidthRange) { + const [bwMin, bwMax] = signalType.bandwidthRange; + if (bandwidth_hz >= bwMin && bandwidth_hz <= bwMax) { + score += 4; + } else if (bandwidth_hz < bwMin * 0.5 || bandwidth_hz > bwMax * 2) { + score -= 3; + } + } + + // Burst behavior bonus + if (signalType.isBurstType) { + if (duration_ms !== null && duration_ms < 1000) { + score += 3; + } + if (repetition_count !== null && repetition_count >= 2) { + score += 2; + } + } + + return Math.max(0, score); + } + + /** + * Calculate confidence level. + */ + function calculateConfidence(primaryScore, allScores, sortedLabels, modulation, bandwidth_hz) { + if (sortedLabels.length === 1) { + if (primaryScore >= 18 && (modulation || bandwidth_hz)) { + return Confidence.HIGH; + } else if (primaryScore >= 14) { + return Confidence.MEDIUM; + } + return Confidence.LOW; + } + + const secondScore = allScores[sortedLabels[1]]; + const margin = primaryScore - secondScore; + + if (primaryScore >= 18 && margin >= 5) { + return Confidence.HIGH; + } else if (primaryScore >= 14 && margin >= 3) { + return Confidence.MEDIUM; + } else if (primaryScore >= 12 && margin >= 2) { + return Confidence.MEDIUM; + } + return Confidence.LOW; + } + + /** + * Calculate confidence for alternative. + */ + function calculateAlternativeConfidence(altScore, primaryScore, primaryConfidence) { + const scoreRatio = primaryScore > 0 ? altScore / primaryScore : 0; + + if (scoreRatio >= 0.9) { + if (primaryConfidence === Confidence.HIGH) return Confidence.MEDIUM; + return primaryConfidence; + } else if (scoreRatio >= 0.7) { + if (primaryConfidence === Confidence.HIGH) return Confidence.MEDIUM; + return Confidence.LOW; + } + return Confidence.LOW; + } + + /** + * Build hedged explanation. + */ + function buildExplanation(signalType, confidence, frequency_hz, modulation, bandwidth_hz, duration_ms, repetition_count) { + const freqMhz = (frequency_hz / 1000000).toFixed(3); + + let explanation; + if (confidence === Confidence.HIGH) { + explanation = `Frequency of ${freqMhz} MHz is consistent with ${signalType.description.toLowerCase()}.`; + } else if (confidence === Confidence.MEDIUM) { + explanation = `Frequency of ${freqMhz} MHz could indicate ${signalType.description.toLowerCase()}.`; + } else { + explanation = `Frequency of ${freqMhz} MHz may be associated with ${signalType.description.toLowerCase()}.`; + } + + // Supporting evidence + const evidence = []; + if (modulation) evidence.push(`${modulation} modulation`); + if (bandwidth_hz) evidence.push(`~${Math.round(bandwidth_hz / 1000)} kHz bandwidth`); + if (duration_ms !== null && duration_ms < 1000) evidence.push('short-burst pattern'); + if (repetition_count !== null && repetition_count >= 3) evidence.push('repeated transmission'); + + if (evidence.length > 0) { + const evidenceStr = evidence.join(', '); + if (confidence === Confidence.HIGH) { + explanation += ` Observed characteristics (${evidenceStr}) support this identification.`; + } else { + explanation += ` Observed ${evidenceStr}.`; + } + } + + return explanation; + } + + /** + * Build unknown explanation. + */ + function buildUnknownExplanation(frequency_hz, modulation) { + const freqMhz = (frequency_hz / 1000000).toFixed(3); + if (modulation) { + return `Signal at ${freqMhz} MHz with ${modulation} modulation does not match common allocations for this region.`; + } + return `Signal at ${freqMhz} MHz does not match common allocations for this region. Additional characteristics may help identification.`; + } + + // ========================================================================== + // UI Components + // ========================================================================== + + /** + * Create a signal guess badge element. + * + * @param {Object} result - Result from guessSignalType + * @param {Object} [options] - Display options + * @param {boolean} [options.showAlternatives=true] - Show alternatives in expandable section + * @param {boolean} [options.compact=false] - Use compact display + * @returns {HTMLElement} The badge element + */ + function createGuessElement(result, options = {}) { + const { showAlternatives = true, compact = false } = options; + + const container = document.createElement('div'); + container.className = `signal-guess-container${compact ? ' compact' : ''}`; + + // Primary label + confidence badge + const primaryRow = document.createElement('div'); + primaryRow.className = 'signal-guess-primary'; + + const label = document.createElement('span'); + label.className = 'signal-guess-label'; + label.textContent = result.primary_label; + primaryRow.appendChild(label); + + const badge = document.createElement('span'); + badge.className = `signal-guess-confidence signal-guess-confidence-${result.confidence.toLowerCase()}`; + badge.textContent = result.confidence; + badge.style.backgroundColor = CONFIDENCE_COLORS[result.confidence]; + primaryRow.appendChild(badge); + + // "Why?" tooltip + const whyBtn = document.createElement('button'); + whyBtn.className = 'signal-guess-why'; + whyBtn.textContent = 'Why?'; + whyBtn.title = result.explanation; + whyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + showExplanationPopup(result, whyBtn); + }); + primaryRow.appendChild(whyBtn); + + container.appendChild(primaryRow); + + // Tags (compact display) + if (!compact && result.tags && result.tags.length > 0) { + const tagsRow = document.createElement('div'); + tagsRow.className = 'signal-guess-tags'; + result.tags.slice(0, 3).forEach(tag => { + const tagEl = document.createElement('span'); + tagEl.className = 'signal-guess-tag'; + tagEl.textContent = tag; + tagsRow.appendChild(tagEl); + }); + container.appendChild(tagsRow); + } + + // Alternatives (expandable) + if (showAlternatives && result.alternatives && result.alternatives.length > 0) { + const altSection = document.createElement('div'); + altSection.className = 'signal-guess-alternatives'; + + const altToggle = document.createElement('button'); + altToggle.className = 'signal-guess-alt-toggle'; + altToggle.innerHTML = ` + + + + ${result.alternatives.length} alternative${result.alternatives.length > 1 ? 's' : ''} + `; + + const altList = document.createElement('div'); + altList.className = 'signal-guess-alt-list'; + altList.style.display = 'none'; + + result.alternatives.forEach(alt => { + const altItem = document.createElement('div'); + altItem.className = 'signal-guess-alt-item'; + altItem.innerHTML = ` + ${escapeHtml(alt.label)} + ${alt.confidence} + `; + altList.appendChild(altItem); + }); + + altToggle.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = altList.style.display !== 'none'; + altList.style.display = isOpen ? 'none' : 'block'; + altToggle.classList.toggle('open', !isOpen); + }); + + altSection.appendChild(altToggle); + altSection.appendChild(altList); + container.appendChild(altSection); + } + + return container; + } + + /** + * Create a compact inline guess badge. + */ + function createCompactBadge(result) { + const badge = document.createElement('span'); + badge.className = `signal-guess-badge signal-guess-badge-${result.confidence.toLowerCase()}`; + badge.title = result.explanation; + badge.innerHTML = ` + ${escapeHtml(result.primary_label)} + ${result.confidence} + `; + return badge; + } + + /** + * Show explanation popup. + */ + function showExplanationPopup(result, anchorEl) { + // Remove existing popup + const existing = document.querySelector('.signal-guess-popup'); + if (existing) existing.remove(); + + const popup = document.createElement('div'); + popup.className = 'signal-guess-popup'; + popup.innerHTML = ` +
+ Signal Identification + +
+
+
+ ${escapeHtml(result.primary_label)} + ${result.confidence} +
+

${escapeHtml(result.explanation)}

+ ${result.tags && result.tags.length > 0 ? ` +
+ ${result.tags.map(t => `${escapeHtml(t)}`).join('')} +
+ ` : ''} + ${result.alternatives && result.alternatives.length > 0 ? ` +
+
Other possibilities:
+ ${result.alternatives.map(a => ` +
+ ${escapeHtml(a.label)} + ${a.confidence} +
+ `).join('')} +
+ ` : ''} +
+ 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, + }