/** * 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'] }, // 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 = `
${escapeHtml(result.explanation)}
${result.tags && result.tags.length > 0 ? ` ` : ''} ${result.alternatives && result.alternatives.length > 0 ? `