/** * GigLez Pulse Width Analysis using K-means clustering * JavaScript port of Python pattern_decoder.py pulse analysis * * @module pulse-analyzer */ /** * Simple K-means clustering for 1D data * Used to identify SHORT and LONG pulse widths * * @param {number[]} data - Array of pulse widths * @param {number} k - Number of clusters (default: 2 for SHORT/LONG) * @param {number} maxIterations - Maximum iterations (default: 100) * @returns {number[]} Array of cluster centers */ export function kMeans(data, k = 2, maxIterations = 100) { if (data.length < k) { return data.map(v => v); } // Initialize centroids using min and max values const sorted = [...data].sort((a, b) => a - b); let centroids = k === 2 ? [sorted[0], sorted[sorted.length - 1]] : Array.from({ length: k }, (_, i) => sorted[Math.floor((i / k) * sorted.length)] ); for (let iter = 0; iter < maxIterations; iter++) { // Assign points to nearest centroid const clusters = Array.from({ length: k }, () => []); for (const point of data) { const distances = centroids.map(c => Math.abs(point - c)); const nearestIdx = distances.indexOf(Math.min(...distances)); clusters[nearestIdx].push(point); } // Update centroids (mean of each cluster) const newCentroids = clusters.map(cluster => { if (cluster.length === 0) return centroids[0]; // Handle empty cluster return cluster.reduce((sum, val) => sum + val, 0) / cluster.length; }); // Check convergence const converged = centroids.every((c, i) => Math.abs(c - newCentroids[i]) < 1 ); centroids = newCentroids; if (converged) break; } return centroids.sort((a, b) => a - b); } /** * Identify SHORT and LONG pulse widths from pulse train * Uses K-means clustering to automatically detect the two pulse durations * * @param {number[]} pulses - Raw pulse data (positive = HIGH, negative = LOW) * @returns {Object} { shortPulse, longPulse, shortGap, longGap } */ export function identifyPulseWidths(pulses) { // Separate HIGH pulses (positive) and LOW gaps (negative) const highPulses = pulses.filter(p => p > 0).map(Math.abs); const lowPulses = pulses.filter(p => p < 0).map(Math.abs); // Cluster into SHORT/LONG let shortPulse = 0, longPulse = 0; if (highPulses.length >= 2) { const [short, long] = kMeans(highPulses, 2); shortPulse = short; longPulse = long; } else if (highPulses.length === 1) { shortPulse = longPulse = highPulses[0]; } let shortGap = 0, longGap = 0; if (lowPulses.length >= 2) { const [short, long] = kMeans(lowPulses, 2); shortGap = short; longGap = long; } else if (lowPulses.length === 1) { shortGap = longGap = lowPulses[0]; } return { shortPulse, longPulse, shortGap, longGap }; } /** * Decode pulse train to binary string using PWM encoding * SHORT pulse = 0, LONG pulse = 1 * * @param {number[]} pulses - Raw pulse data * @param {number} shortPulse - SHORT pulse width (microseconds) * @param {number} longPulse - LONG pulse width (microseconds) * @returns {string} Binary string (e.g., "101010110") */ export function decodeToBits(pulses, shortPulse, longPulse) { const threshold = (shortPulse + longPulse) / 2; const bits = []; for (const pulse of pulses) { if (pulse > 0) { // Only decode HIGH pulses const duration = Math.abs(pulse); bits.push(duration < threshold ? '0' : '1'); } } return bits.join(''); } /** * Analyze pulse train and extract timing information * Returns comprehensive timing analysis for debugging * * @param {number[]} pulses - Raw pulse data * @returns {Object} Timing analysis including pulse widths, counts, patterns */ export function analyzePulseTrain(pulses) { const highPulses = pulses.filter(p => p > 0).map(Math.abs); const lowPulses = pulses.filter(p => p < 0).map(Math.abs); const { shortPulse, longPulse, shortGap, longGap } = identifyPulseWidths(pulses); const bitPattern = decodeToBits(pulses, shortPulse, longPulse); return { // Pulse width identification shortPulse, longPulse, shortGap, longGap, // Bit pattern bitPattern, bitCount: bitPattern.length, // Counts totalPulses: pulses.length, highPulseCount: highPulses.length, lowPulseCount: lowPulses.length, // High pulse statistics highPulseStats: { min: Math.min(...highPulses), max: Math.max(...highPulses), mean: highPulses.reduce((a, b) => a + b, 0) / highPulses.length, median: highPulses.sort((a, b) => a - b)[Math.floor(highPulses.length / 2)] }, // Low pulse (gap) statistics lowPulseStats: { min: Math.min(...lowPulses), max: Math.max(...lowPulses), mean: lowPulses.reduce((a, b) => a + b, 0) / lowPulses.length, median: lowPulses.sort((a, b) => a - b)[Math.floor(lowPulses.length / 2)] } }; } /** * Validate pulse data quality * Checks for common issues in RF captures * * @param {number[]} pulses - Raw pulse data * @returns {Object} { valid: boolean, issues: string[] } */ export function validatePulseData(pulses) { const issues = []; if (!pulses || pulses.length === 0) { return { valid: false, issues: ['No pulse data provided'] }; } if (pulses.length < 20) { issues.push(`Very short capture (${pulses.length} pulses) - may not be decodable`); } const allPositive = pulses.every(p => p > 0); const allNegative = pulses.every(p => p < 0); if (allPositive || allNegative) { issues.push('Pulse data is all positive or all negative - should alternate'); } const highPulses = pulses.filter(p => p > 0).map(Math.abs); const lowPulses = pulses.filter(p => p < 0).map(Math.abs); if (highPulses.length < 5) { issues.push('Too few HIGH pulses for reliable analysis'); } if (lowPulses.length < 5) { issues.push('Too few LOW pulses (gaps) for reliable analysis'); } // Check for outliers (pulses > 100ms are suspicious) const outliers = pulses.filter(p => Math.abs(p) > 100000); if (outliers.length > 0) { issues.push(`${outliers.length} suspiciously long pulses (>100ms) detected`); } return { valid: issues.length === 0, issues }; } /** * Calculate Signal-to-Noise Ratio (SNR) estimate * Based on pulse width consistency * * @param {number[]} pulses - Raw pulse data * @returns {number} SNR estimate (0-1, higher is better) */ export function estimateSNR(pulses) { const { shortPulse, longPulse } = identifyPulseWidths(pulses); const highPulses = pulses.filter(p => p > 0).map(Math.abs); if (highPulses.length < 10) return 0; // Count how many pulses are close to SHORT or LONG const threshold = (shortPulse + longPulse) / 2; const shortTolerance = shortPulse * 0.3; const longTolerance = longPulse * 0.3; const consistentPulses = highPulses.filter(p => { const nearShort = Math.abs(p - shortPulse) <= shortTolerance; const nearLong = Math.abs(p - longPulse) <= longTolerance; return nearShort || nearLong; }); return consistentPulses.length / highPulses.length; }