#!/usr/bin/env node /** * RuView RF Scan Benchmark * * Collects CSI frames from ESP32 nodes and computes quantitative metrics * for single-channel and multi-channel scanning performance: * * - Frames per second per node per channel * - Null subcarrier count per channel * - Cross-channel null diversity (how many nulls are filled by other channels) * - Subcarrier correlation across channels * - Position accuracy improvement estimate * - Spectrum flatness (lower = more objects) * * Usage: * node scripts/benchmark-rf-scan.js --port 5006 --duration 30 * node scripts/benchmark-rf-scan.js --duration 60 --json * * ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md */ 'use strict'; const dgram = require('dgram'); const { parseArgs } = require('util'); // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const { values: args } = parseArgs({ options: { port: { type: 'string', short: 'p', default: '5006' }, duration: { type: 'string', short: 'd', default: '30' }, json: { type: 'boolean', default: false }, }, strict: true, }); const PORT = parseInt(args.port, 10); const DURATION_S = parseInt(args.duration, 10); const JSON_OUTPUT = args.json; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CSI_MAGIC = 0xC5110001; const HEADER_SIZE = 20; const NULL_THRESHOLD = 2.0; // --------------------------------------------------------------------------- // Data collection // --------------------------------------------------------------------------- /** * Per-channel frame collector. Accumulates amplitude snapshots for analysis. */ class ChannelCollector { constructor(channel) { this.channel = channel; this.freqMhz = 0; this.frames = []; // array of { amplitudes, phases, rssi, timestamp } this.nSubcarriers = 0; } add(amplitudes, phases, rssi, freqMhz) { this.freqMhz = freqMhz; this.nSubcarriers = amplitudes.length; this.frames.push({ amplitudes: Float64Array.from(amplitudes), phases: Float64Array.from(phases), rssi, timestamp: Date.now(), }); } } class NodeCollector { constructor(nodeId) { this.nodeId = nodeId; this.address = null; this.channels = new Map(); // channel -> ChannelCollector this.totalFrames = 0; this.firstFrameMs = 0; this.lastFrameMs = 0; } getOrCreate(channel) { if (!this.channels.has(channel)) { this.channels.set(channel, new ChannelCollector(channel)); } return this.channels.get(channel); } } const nodes = new Map(); let totalFrames = 0; const startTime = Date.now(); // --------------------------------------------------------------------------- // Packet parsing // --------------------------------------------------------------------------- function parseCSIFrame(buf) { if (buf.length < HEADER_SIZE) return null; if (buf.readUInt32LE(0) !== CSI_MAGIC) return null; const nodeId = buf.readUInt8(4); const nAntennas = buf.readUInt8(5) || 1; const nSubcarriers = buf.readUInt16LE(6); const freqMhz = buf.readUInt32LE(8); const rssi = buf.readInt8(16); const iqLen = nSubcarriers * nAntennas * 2; if (buf.length < HEADER_SIZE + iqLen) return null; const amplitudes = new Float64Array(nSubcarriers); const phases = new Float64Array(nSubcarriers); for (let sc = 0; sc < nSubcarriers; sc++) { const offset = HEADER_SIZE + sc * 2; const I = buf.readInt8(offset); const Q = buf.readInt8(offset + 1); amplitudes[sc] = Math.sqrt(I * I + Q * Q); phases[sc] = Math.atan2(Q, I); } let channel = 0; if (freqMhz >= 2412 && freqMhz <= 2484) { channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1; } else if (freqMhz >= 5180) { channel = Math.round((freqMhz - 5000) / 5); } return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, phases, channel }; } function handlePacket(buf, rinfo) { if (buf.length < 4 || buf.readUInt32LE(0) !== CSI_MAGIC) return; const frame = parseCSIFrame(buf); if (!frame) return; totalFrames++; let node = nodes.get(frame.nodeId); if (!node) { node = new NodeCollector(frame.nodeId); nodes.set(frame.nodeId, node); } node.address = rinfo.address; node.totalFrames++; const now = Date.now(); if (node.firstFrameMs === 0) node.firstFrameMs = now; node.lastFrameMs = now; const cc = node.getOrCreate(frame.channel); cc.add(frame.amplitudes, frame.phases, frame.rssi, frame.freqMhz); } // --------------------------------------------------------------------------- // Analysis // --------------------------------------------------------------------------- function computeMetrics() { const results = { duration_s: DURATION_S, totalFrames, nodes: [], crossChannel: null, summary: null, }; for (const node of nodes.values()) { const elapsed = (node.lastFrameMs - node.firstFrameMs) / 1000; const nodeFps = elapsed > 0 ? node.totalFrames / elapsed : 0; const channelMetrics = []; for (const [ch, cc] of node.channels.entries()) { if (cc.frames.length === 0) continue; const n = cc.nSubcarriers; const nFrames = cc.frames.length; // FPS for this channel let chFps = 0; if (nFrames >= 2) { const first = cc.frames[0].timestamp; const last = cc.frames[nFrames - 1].timestamp; const chElapsed = (last - first) / 1000; chFps = chElapsed > 0 ? nFrames / chElapsed : 0; } // Average null count across frames let totalNulls = 0; for (const f of cc.frames) { for (let i = 0; i < n; i++) { if (f.amplitudes[i] < NULL_THRESHOLD) totalNulls++; } } const avgNulls = totalNulls / nFrames; const nullPct = n > 0 ? (avgNulls / n) * 100 : 0; // Mean RSSI const meanRssi = cc.frames.reduce((s, f) => s + f.rssi, 0) / nFrames; // Spectrum flatness: geometric mean / arithmetic mean of last frame const lastFrame = cc.frames[nFrames - 1]; let logSum = 0, ampSum = 0, count = 0; for (let i = 0; i < n; i++) { if (lastFrame.amplitudes[i] > 0) { logSum += Math.log(lastFrame.amplitudes[i]); count++; } ampSum += lastFrame.amplitudes[i]; } const geoMean = count > 0 ? Math.exp(logSum / count) : 0; const ariMean = n > 0 ? ampSum / n : 0; const flatness = ariMean > 0 ? geoMean / ariMean : 0; // Amplitude variance per subcarrier (average across subcarriers) const means = new Float64Array(n); const vars = new Float64Array(n); for (const f of cc.frames) { for (let i = 0; i < n; i++) means[i] += f.amplitudes[i]; } for (let i = 0; i < n; i++) means[i] /= nFrames; for (const f of cc.frames) { for (let i = 0; i < n; i++) { const d = f.amplitudes[i] - means[i]; vars[i] += d * d; } } let avgVar = 0; for (let i = 0; i < n; i++) { vars[i] /= Math.max(1, nFrames - 1); avgVar += vars[i]; } avgVar /= Math.max(1, n); // Null subcarrier indices (from last frame) const nullIndices = []; for (let i = 0; i < n; i++) { if (lastFrame.amplitudes[i] < NULL_THRESHOLD) nullIndices.push(i); } channelMetrics.push({ channel: ch, freqMhz: cc.freqMhz, nSubcarriers: n, frameCount: nFrames, fps: parseFloat(chFps.toFixed(2)), avgNullCount: parseFloat(avgNulls.toFixed(1)), nullPercent: parseFloat(nullPct.toFixed(1)), meanRssi: parseFloat(meanRssi.toFixed(1)), spectrumFlatness: parseFloat(flatness.toFixed(4)), avgAmplitudeVariance: parseFloat(avgVar.toFixed(4)), nullIndices, }); } results.nodes.push({ nodeId: node.nodeId, address: node.address, totalFrames: node.totalFrames, fps: parseFloat(nodeFps.toFixed(2)), channels: channelMetrics, }); } // Cross-channel null diversity const allChannelData = []; for (const node of nodes.values()) { for (const [ch, cc] of node.channels.entries()) { if (cc.frames.length === 0) continue; const n = cc.nSubcarriers; const lastFrame = cc.frames[cc.frames.length - 1]; const nullSet = new Set(); for (let i = 0; i < n; i++) { if (lastFrame.amplitudes[i] < NULL_THRESHOLD) nullSet.add(i); } allChannelData.push({ channel: ch, nodeId: node.nodeId, nullSet, n }); } } if (allChannelData.length >= 2) { // Union and intersection of null sets const allNullSets = allChannelData.map(d => d.nullSet); const union = new Set(); for (const s of allNullSets) for (const idx of s) union.add(idx); let intersectionCount = 0; for (const idx of union) { if (allNullSets.every(s => s.has(idx))) intersectionCount++; } const singleNulls = allNullSets[0].size; const maxSub = Math.max(...allChannelData.map(d => d.n)); // Cross-channel correlation (pairwise) const correlations = []; for (let i = 0; i < allChannelData.length; i++) { for (let j = i + 1; j < allChannelData.length; j++) { const d1 = allChannelData[i]; const d2 = allChannelData[j]; const cc1 = [...nodes.values()].find(n => n.nodeId === d1.nodeId)?.channels.get(d1.channel); const cc2 = [...nodes.values()].find(n => n.nodeId === d2.nodeId)?.channels.get(d2.channel); if (!cc1 || !cc2) continue; const f1 = cc1.frames[cc1.frames.length - 1]; const f2 = cc2.frames[cc2.frames.length - 1]; const len = Math.min(f1.amplitudes.length, f2.amplitudes.length); let sumXY = 0, sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0; for (let k = 0; k < len; k++) { sumX += f1.amplitudes[k]; sumY += f2.amplitudes[k]; sumXY += f1.amplitudes[k] * f2.amplitudes[k]; sumX2 += f1.amplitudes[k] ** 2; sumY2 += f2.amplitudes[k] ** 2; } const denom = Math.sqrt((len * sumX2 - sumX * sumX) * (len * sumY2 - sumY * sumY)); const corr = denom > 0 ? (len * sumXY - sumX * sumY) / denom : 0; correlations.push({ node1: d1.nodeId, ch1: d1.channel, node2: d2.nodeId, ch2: d2.channel, correlation: parseFloat(corr.toFixed(4)), }); } } results.crossChannel = { totalChannels: allChannelData.length, singleChannelNulls: singleNulls, fusedNulls: intersectionCount, unionNulls: union.size, maxSubcarriers: maxSub, singleNullPct: parseFloat(maxSub > 0 ? ((singleNulls / maxSub) * 100).toFixed(1) : '0'), fusedNullPct: parseFloat(maxSub > 0 ? ((intersectionCount / maxSub) * 100).toFixed(1) : '0'), diversityGainPct: parseFloat(singleNulls > 0 ? ((1 - intersectionCount / singleNulls) * 100).toFixed(1) : '0'), correlations, }; } // Position accuracy estimate // With N independent channel observations, accuracy improves by sqrt(N) // Baseline: single channel ~30 cm resolution at 2.4 GHz const nChannels = allChannelData.length; const baselineResolutionCm = 30; const estimatedResolutionCm = nChannels > 0 ? baselineResolutionCm / Math.sqrt(nChannels) : baselineResolutionCm; results.summary = { totalNodes: nodes.size, totalChannels: nChannels, totalFrames, durationS: DURATION_S, avgFps: parseFloat((totalFrames / DURATION_S).toFixed(1)), baselineResolutionCm, estimatedResolutionCm: parseFloat(estimatedResolutionCm.toFixed(1)), resolutionImprovement: nChannels > 1 ? `${Math.sqrt(nChannels).toFixed(2)}x` : '1x (single channel)', totalSubcarriers: allChannelData.reduce((s, d) => s + d.n, 0), subcarrierMultiplier: nChannels > 0 ? parseFloat((allChannelData.reduce((s, d) => s + d.n, 0) / Math.max(1, allChannelData[0]?.n || 1)).toFixed(1)) : 1, }; return results; } // --------------------------------------------------------------------------- // Reporting // --------------------------------------------------------------------------- function printReport(metrics) { console.log(''); console.log('=== RUVIEW RF SCAN BENCHMARK ==='); console.log(`Duration: ${metrics.duration_s}s | Total frames: ${metrics.totalFrames}`); console.log(''); // Per-node per-channel table console.log('--- Frames Per Second ---'); console.log('Node Channel Freq FPS Frames Subcarriers RSSI'); for (const node of metrics.nodes) { for (const ch of node.channels) { console.log(` ${node.nodeId} ch${String(ch.channel).padStart(2)} ${ch.freqMhz} MHz ${String(ch.fps).padStart(5)} ${String(ch.frameCount).padStart(6)} ${String(ch.nSubcarriers).padStart(11)} ${ch.meanRssi} dBm`); } console.log(` ${node.nodeId} TOTAL ${String(node.fps).padStart(5)} ${String(node.totalFrames).padStart(6)}`); } console.log(''); // Null subcarriers console.log('--- Null Subcarriers Per Channel ---'); console.log('Node Channel Nulls Null% Flatness AvgVariance'); for (const node of metrics.nodes) { for (const ch of node.channels) { console.log(` ${node.nodeId} ch${String(ch.channel).padStart(2)} ${String(ch.avgNullCount.toFixed(0)).padStart(5)} ${String(ch.nullPercent.toFixed(1)).padStart(5)}% ${String(ch.spectrumFlatness.toFixed(4)).padStart(8)} ${ch.avgAmplitudeVariance.toFixed(4)}`); } } console.log(''); // Cross-channel diversity if (metrics.crossChannel) { const cc = metrics.crossChannel; console.log('--- Cross-Channel Null Diversity ---'); console.log(` Channels scanned: ${cc.totalChannels}`); console.log(` Single-channel nulls: ${cc.singleChannelNulls} (${cc.singleNullPct}%)`); console.log(` Fused nulls (all ch): ${cc.fusedNulls} (${cc.fusedNullPct}%)`); console.log(` Diversity gain: ${cc.diversityGainPct}%`); console.log(''); if (cc.correlations.length > 0) { console.log('--- Cross-Channel Correlation ---'); for (const c of cc.correlations) { const label = c.node1 === c.node2 ? `node${c.node1} ch${c.ch1}<->ch${c.ch2}` : `node${c.node1}/ch${c.ch1}<->node${c.node2}/ch${c.ch2}`; console.log(` ${label}: ${c.correlation.toFixed(4)}`); } console.log(''); } } // Summary if (metrics.summary) { const s = metrics.summary; console.log('--- Summary ---'); console.log(` Nodes: ${s.totalNodes}`); console.log(` Channels: ${s.totalChannels}`); console.log(` Total subcarriers: ${s.totalSubcarriers} (${s.subcarrierMultiplier}x single-channel)`); console.log(` Average FPS: ${s.avgFps}`); console.log(` Baseline resolution: ${s.baselineResolutionCm} cm (single channel)`); console.log(` Estimated resolution: ${s.estimatedResolutionCm} cm (${s.resolutionImprovement})`); console.log(''); } // Pass/fail targets (from ADR-073) console.log('--- ADR-073 Targets ---'); const s = metrics.summary || {}; const cc = metrics.crossChannel || {}; const targets = [ { name: 'Subcarrier multiplier >= 3x', pass: (s.subcarrierMultiplier || 0) >= 3, actual: `${s.subcarrierMultiplier || 0}x` }, { name: 'Null gap < 5%', pass: (cc.fusedNullPct || 100) < 5, actual: `${cc.fusedNullPct || '?'}%` }, { name: 'Resolution <= 15 cm', pass: (s.estimatedResolutionCm || 999) <= 15, actual: `${s.estimatedResolutionCm || '?'} cm` }, ]; for (const t of targets) { const status = t.pass ? 'PASS' : 'FAIL'; console.log(` [${status}] ${t.name} (actual: ${t.actual})`); } console.log(''); console.log('Note: Targets require multi-channel hopping enabled on both ESP32 nodes.'); console.log('Single-channel mode will show FAIL for multi-channel targets.'); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { const server = dgram.createSocket('udp4'); server.on('error', (err) => { console.error(`UDP error: ${err.message}`); server.close(); process.exit(1); }); server.on('message', (msg, rinfo) => { handlePacket(msg, rinfo); }); server.on('listening', () => { const addr = server.address(); if (!JSON_OUTPUT) { console.log(`RuView RF Scan Benchmark`); console.log(`Listening on ${addr.address}:${addr.port} for ${DURATION_S}s...`); console.log('Collecting CSI frames from ESP32 nodes...\n'); } }); server.bind(PORT); // Progress indicator (non-JSON mode) let progressTimer; if (!JSON_OUTPUT) { let dots = 0; progressTimer = setInterval(() => { dots++; const elapsed = ((Date.now() - startTime) / 1000).toFixed(0); process.stdout.write(`\r ${elapsed}s / ${DURATION_S}s | ${totalFrames} frames | ${nodes.size} nodes ${'.' .repeat(dots % 4)} `); }, 1000); } setTimeout(() => { if (progressTimer) clearInterval(progressTimer); if (!JSON_OUTPUT) process.stdout.write('\r' + ' '.repeat(60) + '\r'); const metrics = computeMetrics(); if (JSON_OUTPUT) { process.stdout.write(JSON.stringify(metrics, null, 2) + '\n'); } else { printReport(metrics); } server.close(); process.exit(0); }, DURATION_S * 1000); process.on('SIGINT', () => { if (progressTimer) clearInterval(progressTimer); if (!JSON_OUTPUT) console.log('\nInterrupted — computing metrics with collected data...\n'); const metrics = computeMetrics(); if (JSON_OUTPUT) { process.stdout.write(JSON.stringify(metrics, null, 2) + '\n'); } else { printReport(metrics); } server.close(); process.exit(0); }); } main();