mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 14:09:33 +00:00
Live RF room scanner with ASCII spectrum visualization: - rf-scan.js: single-channel scanner with null/dynamic/reflector classification, cross-node correlation, phase coherence, Unicode spectrum display - rf-scan-multifreq.js: wideband view merging 6 channels, null diversity, per-channel penetration quality, frequency-dependent scatterer detection - benchmark-rf-scan.js: null diversity gain, spectrum flatness, resolution estimate Validated: 228 frames in 5s, 23 fps/node, 19% nulls detected, 0.993 cross-node correlation, line-of-sight confirmed ADR-073: interleaved channel hopping (Node 1: ch 1/6/11, Node 2: ch 3/5/9) targets 6x subcarrier diversity, <5% null gap, ~15cm resolution Co-Authored-By: claude-flow <ruv@ruv.net>
533 lines
18 KiB
JavaScript
533 lines
18 KiB
JavaScript
#!/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();
|