mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 14:09:33 +00:00
RF tomography (2D backprojection imaging), passive bistatic radar (neighbor APs as illuminators), frequency-selective material classification (metal/water/wood/glass), through-wall motion detection (per-channel penetration weighting), device fingerprinting (RF emission signatures per SSID) All impossible with single-channel WiFi — require 6-channel hopping. Co-Authored-By: claude-flow <ruv@ruv.net>
613 lines
20 KiB
JavaScript
613 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Frequency-Selective Material Classification — Multi-Frequency Mesh Application
|
|
*
|
|
* Compares CSI null/attenuation patterns across 6 WiFi channels to classify
|
|
* materials in the room. Different materials absorb WiFi at different rates
|
|
* depending on frequency:
|
|
*
|
|
* Metal: blocks all frequencies equally (frequency-flat null)
|
|
* Water: absorbs strongly, increasing with frequency (dielectric loss)
|
|
* Wood: mild attenuation, increases with frequency (moisture)
|
|
* Glass: low attenuation, nearly frequency-flat
|
|
* Human: 60-70% water, strong frequency-dependent absorption
|
|
*
|
|
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
|
|
* across channels 1, 3, 5, 6, 9, 11.
|
|
*
|
|
* Usage:
|
|
* node scripts/material-classifier.js
|
|
* node scripts/material-classifier.js --port 5006 --duration 60
|
|
* node scripts/material-classifier.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
|
*
|
|
* ADR: docs/adr/ADR-078-multifreq-mesh-applications.md
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const dgram = require('dgram');
|
|
const fs = require('fs');
|
|
const readline = require('readline');
|
|
const { parseArgs } = require('util');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
const { values: args } = parseArgs({
|
|
options: {
|
|
port: { type: 'string', short: 'p', default: '5006' },
|
|
duration: { type: 'string', short: 'd' },
|
|
replay: { type: 'string', short: 'r' },
|
|
interval: { type: 'string', short: 'i', default: '5000' },
|
|
json: { type: 'boolean', default: false },
|
|
window: { type: 'string', short: 'w', default: '20' },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
const PORT = parseInt(args.port, 10);
|
|
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
|
const INTERVAL_MS = parseInt(args.interval, 10);
|
|
const JSON_OUTPUT = args.json;
|
|
const WINDOW_FRAMES = parseInt(args.window, 10);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
const CSI_MAGIC = 0xC5110001;
|
|
const HEADER_SIZE = 20;
|
|
|
|
const CHANNEL_FREQ = {};
|
|
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
|
|
|
const NODE1_CHANNELS = [1, 6, 11];
|
|
const NODE2_CHANNELS = [3, 5, 9];
|
|
|
|
// Material classification thresholds
|
|
const NULL_THRESHOLD = 2.0;
|
|
|
|
// Material types
|
|
const MATERIAL = {
|
|
METAL: { name: 'Metal', char: '#', desc: 'Total block, frequency-flat' },
|
|
WATER: { name: 'Water', char: '~', desc: 'Strong absorption, freq-dependent' },
|
|
HUMAN: { name: 'Human', char: '@', desc: '60-70% water, strong freq-dependent' },
|
|
WOOD: { name: 'Wood', char: '|', desc: 'Mild attenuation, freq-increasing' },
|
|
GLASS: { name: 'Glass', char: ':', desc: 'Low attenuation, frequency-flat' },
|
|
AIR: { name: 'Air', char: '.', desc: 'Minimal attenuation' },
|
|
COMPLEX: { name: 'Complex', char: '?', desc: 'Mixed/unclassifiable' },
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-channel amplitude accumulator
|
|
// ---------------------------------------------------------------------------
|
|
class ChannelAccumulator {
|
|
constructor() {
|
|
// channel -> { amplitudes: Float64Array[], count: number }
|
|
this.channels = new Map();
|
|
}
|
|
|
|
ingest(channel, amplitudes) {
|
|
if (!this.channels.has(channel)) {
|
|
this.channels.set(channel, {
|
|
sum: new Float64Array(amplitudes.length),
|
|
sumSq: new Float64Array(amplitudes.length),
|
|
count: 0,
|
|
nSub: amplitudes.length,
|
|
});
|
|
}
|
|
|
|
const ch = this.channels.get(channel);
|
|
ch.count++;
|
|
for (let i = 0; i < amplitudes.length && i < ch.nSub; i++) {
|
|
ch.sum[i] += amplitudes[i];
|
|
ch.sumSq[i] += amplitudes[i] * amplitudes[i];
|
|
}
|
|
}
|
|
|
|
/** Get mean amplitude per subcarrier per channel */
|
|
getMeans() {
|
|
const means = new Map();
|
|
for (const [channel, ch] of this.channels) {
|
|
if (ch.count === 0) continue;
|
|
const mean = new Float64Array(ch.nSub);
|
|
for (let i = 0; i < ch.nSub; i++) {
|
|
mean[i] = ch.sum[i] / ch.count;
|
|
}
|
|
means.set(channel, { mean, count: ch.count, nSub: ch.nSub });
|
|
}
|
|
return means;
|
|
}
|
|
|
|
/** Get variance per subcarrier per channel */
|
|
getVariances() {
|
|
const variances = new Map();
|
|
for (const [channel, ch] of this.channels) {
|
|
if (ch.count < 2) continue;
|
|
const variance = new Float64Array(ch.nSub);
|
|
for (let i = 0; i < ch.nSub; i++) {
|
|
const mean = ch.sum[i] / ch.count;
|
|
variance[i] = (ch.sumSq[i] / ch.count) - (mean * mean);
|
|
}
|
|
variances.set(channel, variance);
|
|
}
|
|
return variances;
|
|
}
|
|
|
|
/** Get active channel list sorted by frequency */
|
|
getActiveChannels() {
|
|
return [...this.channels.keys()]
|
|
.filter(ch => this.channels.get(ch).count > 0)
|
|
.sort((a, b) => a - b);
|
|
}
|
|
|
|
reset() {
|
|
this.channels.clear();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Material classifier
|
|
// ---------------------------------------------------------------------------
|
|
class MaterialClassifier {
|
|
constructor() {
|
|
this.accumulator = new ChannelAccumulator();
|
|
this.frameCount = 0;
|
|
this.classifications = [];
|
|
}
|
|
|
|
ingestFrame(channel, amplitudes) {
|
|
this.accumulator.ingest(channel, amplitudes);
|
|
this.frameCount++;
|
|
}
|
|
|
|
/**
|
|
* Classify each subcarrier group by comparing attenuation across channels.
|
|
*
|
|
* For each subcarrier index:
|
|
* 1. Collect mean amplitude on each channel
|
|
* 2. Compute frequency selectivity metrics:
|
|
* - Flat ratio = std / mean (low = frequency-flat)
|
|
* - Slope = linear regression of amplitude vs frequency
|
|
* - Mean level = overall attenuation (high = strong absorber)
|
|
* 3. Decision tree:
|
|
* - All channels null -> Metal (frequency-flat total block)
|
|
* - Flat ratio < 0.15 AND mean < 3.0 -> Metal
|
|
* - Flat ratio < 0.15 AND mean > 8.0 -> Glass/Air
|
|
* - Negative slope (amp decreases with freq) AND mean < 6.0 -> Water/Human
|
|
* - Negative slope AND mean 6.0-8.0 -> Wood
|
|
* - High variance across channels -> Complex
|
|
*/
|
|
classify() {
|
|
const means = this.accumulator.getMeans();
|
|
const channels = this.accumulator.getActiveChannels();
|
|
|
|
if (channels.length < 2) {
|
|
return { error: 'Need at least 2 channels for material classification', channels: channels.length };
|
|
}
|
|
|
|
const nSub = Math.min(...[...means.values()].map(m => m.nSub));
|
|
const freqs = channels.map(ch => CHANNEL_FREQ[ch] || 2432);
|
|
|
|
const results = [];
|
|
const materialCounts = {};
|
|
for (const m of Object.values(MATERIAL)) materialCounts[m.name] = 0;
|
|
|
|
for (let sc = 0; sc < nSub; sc++) {
|
|
// Collect amplitudes across channels for this subcarrier
|
|
const amps = channels.map(ch => means.get(ch).mean[sc]);
|
|
|
|
// Is this a null on all channels?
|
|
const allNull = amps.every(a => a < NULL_THRESHOLD);
|
|
const anyNull = amps.some(a => a < NULL_THRESHOLD);
|
|
|
|
// Mean amplitude
|
|
const meanAmp = amps.reduce((a, b) => a + b, 0) / amps.length;
|
|
|
|
// Standard deviation
|
|
const variance = amps.reduce((a, b) => a + (b - meanAmp) ** 2, 0) / amps.length;
|
|
const stdAmp = Math.sqrt(variance);
|
|
|
|
// Flat ratio (coefficient of variation)
|
|
const flatRatio = meanAmp > 0.01 ? stdAmp / meanAmp : 0;
|
|
|
|
// Frequency slope: linear regression of amplitude vs frequency
|
|
let sumF = 0, sumA = 0, sumFF = 0, sumFA = 0;
|
|
for (let i = 0; i < channels.length; i++) {
|
|
sumF += freqs[i];
|
|
sumA += amps[i];
|
|
sumFF += freqs[i] * freqs[i];
|
|
sumFA += freqs[i] * amps[i];
|
|
}
|
|
const nCh = channels.length;
|
|
const meanF = sumF / nCh;
|
|
const denomF = sumFF - sumF * meanF;
|
|
const slope = Math.abs(denomF) > 1e-6
|
|
? (sumFA - sumF * (sumA / nCh)) / denomF
|
|
: 0;
|
|
|
|
// Normalized slope (per MHz)
|
|
const slopePerMHz = slope;
|
|
|
|
// Classification decision tree
|
|
let material;
|
|
if (allNull) {
|
|
material = MATERIAL.METAL;
|
|
} else if (flatRatio < 0.15 && meanAmp < 3.0) {
|
|
material = MATERIAL.METAL;
|
|
} else if (flatRatio < 0.15 && meanAmp > 10.0) {
|
|
material = MATERIAL.AIR;
|
|
} else if (flatRatio < 0.15 && meanAmp > 6.0) {
|
|
material = MATERIAL.GLASS;
|
|
} else if (slopePerMHz < -0.005 && meanAmp < 5.0) {
|
|
// Amplitude decreases with frequency = frequency-dependent absorption
|
|
material = MATERIAL.HUMAN;
|
|
} else if (slopePerMHz < -0.003 && meanAmp < 8.0) {
|
|
material = MATERIAL.WATER;
|
|
} else if (slopePerMHz < -0.001 && meanAmp >= 5.0) {
|
|
material = MATERIAL.WOOD;
|
|
} else if (flatRatio > 0.5) {
|
|
material = MATERIAL.COMPLEX;
|
|
} else {
|
|
material = MATERIAL.AIR;
|
|
}
|
|
|
|
materialCounts[material.name]++;
|
|
results.push({
|
|
subcarrier: sc,
|
|
material: material.name,
|
|
char: material.char,
|
|
meanAmp: meanAmp.toFixed(1),
|
|
flatRatio: flatRatio.toFixed(3),
|
|
slopePerMHz: slopePerMHz.toFixed(5),
|
|
amps: amps.map(a => a.toFixed(1)),
|
|
});
|
|
}
|
|
|
|
this.classifications = results;
|
|
|
|
return {
|
|
channels,
|
|
nSubcarriers: nSub,
|
|
frameCount: this.frameCount,
|
|
materialCounts,
|
|
classifications: results,
|
|
};
|
|
}
|
|
|
|
reset() {
|
|
this.accumulator.reset();
|
|
this.frameCount = 0;
|
|
this.classifications = [];
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSI parsing
|
|
// ---------------------------------------------------------------------------
|
|
function parseIqHex(iqHex, nSubcarriers) {
|
|
const bytes = Buffer.from(iqHex, 'hex');
|
|
const amplitudes = new Float64Array(nSubcarriers);
|
|
|
|
for (let sc = 0; sc < nSubcarriers; sc++) {
|
|
const offset = 2 + sc * 2;
|
|
if (offset + 1 >= bytes.length) break;
|
|
let I = bytes[offset];
|
|
let Q = bytes[offset + 1];
|
|
if (I > 127) I -= 256;
|
|
if (Q > 127) Q -= 256;
|
|
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
|
}
|
|
|
|
return amplitudes;
|
|
}
|
|
|
|
function parseCSIFrame(buf) {
|
|
if (buf.length < HEADER_SIZE) return null;
|
|
const magic = buf.readUInt32LE(0);
|
|
if (magic !== CSI_MAGIC) return null;
|
|
|
|
const nodeId = buf.readUInt8(4);
|
|
const nSubcarriers = buf.readUInt16LE(6);
|
|
const freqMhz = buf.readUInt32LE(8);
|
|
|
|
const amplitudes = new Float64Array(nSubcarriers);
|
|
for (let sc = 0; sc < nSubcarriers; sc++) {
|
|
const offset = HEADER_SIZE + sc * 2;
|
|
if (offset + 1 >= buf.length) break;
|
|
const I = buf.readInt8(offset);
|
|
const Q = buf.readInt8(offset + 1);
|
|
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
|
}
|
|
|
|
let channel = 0;
|
|
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
|
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
|
}
|
|
|
|
return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
|
|
}
|
|
|
|
const nodeChannelIdx = { 1: 0, 2: 0 };
|
|
function assignChannel(nodeId) {
|
|
const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
|
|
const ch = channels[nodeChannelIdx[nodeId] % channels.length];
|
|
nodeChannelIdx[nodeId]++;
|
|
return ch;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Visualization
|
|
// ---------------------------------------------------------------------------
|
|
function renderMaterialMap(result) {
|
|
const { classifications, channels, nSubcarriers, materialCounts } = result;
|
|
if (!classifications || classifications.length === 0) return ' No classifications available';
|
|
|
|
const lines = [];
|
|
lines.push('');
|
|
lines.push(' FREQUENCY-SELECTIVE MATERIAL CLASSIFICATION');
|
|
lines.push(' ' + '='.repeat(55));
|
|
lines.push('');
|
|
|
|
// Material map: one char per subcarrier
|
|
lines.push(' Subcarrier Material Map (1 char = 1 subcarrier):');
|
|
let mapRow = ' ';
|
|
for (let i = 0; i < classifications.length; i++) {
|
|
mapRow += classifications[i].char;
|
|
if ((i + 1) % 64 === 0) {
|
|
lines.push(mapRow);
|
|
mapRow = ' ';
|
|
}
|
|
}
|
|
if (mapRow.trim()) lines.push(mapRow);
|
|
|
|
lines.push('');
|
|
lines.push(' Legend:');
|
|
for (const m of Object.values(MATERIAL)) {
|
|
const count = materialCounts[m.name] || 0;
|
|
const pct = nSubcarriers > 0 ? (count / nSubcarriers * 100).toFixed(1) : '0.0';
|
|
lines.push(` ${m.char} = ${m.name.padEnd(8)} (${pct}%) ${m.desc}`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function renderFrequencyProfile(result) {
|
|
const { classifications, channels } = result;
|
|
if (!classifications || channels.length < 2) return '';
|
|
|
|
const lines = [];
|
|
lines.push('');
|
|
lines.push(' Frequency Profile (mean amplitude per channel):');
|
|
lines.push(' ' + '-'.repeat(50));
|
|
|
|
// Compute mean per channel across all subcarriers
|
|
const channelMeans = {};
|
|
for (const ch of channels) channelMeans[ch] = { sum: 0, count: 0 };
|
|
|
|
for (const cls of classifications) {
|
|
for (let i = 0; i < channels.length && i < cls.amps.length; i++) {
|
|
channelMeans[channels[i]].sum += parseFloat(cls.amps[i]);
|
|
channelMeans[channels[i]].count++;
|
|
}
|
|
}
|
|
|
|
const BARS = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
|
let maxMean = 0;
|
|
for (const ch of channels) {
|
|
const m = channelMeans[ch].count > 0 ? channelMeans[ch].sum / channelMeans[ch].count : 0;
|
|
if (m > maxMean) maxMean = m;
|
|
}
|
|
if (maxMean === 0) maxMean = 1;
|
|
|
|
for (const ch of channels) {
|
|
const mean = channelMeans[ch].count > 0 ? channelMeans[ch].sum / channelMeans[ch].count : 0;
|
|
const freq = CHANNEL_FREQ[ch] || 0;
|
|
const barLen = Math.floor((mean / maxMean) * 30);
|
|
const bar = BARS[7].repeat(barLen);
|
|
lines.push(` ch${String(ch).padStart(2)} (${freq} MHz): ${bar} ${mean.toFixed(1)}`);
|
|
}
|
|
|
|
// Slope analysis
|
|
const freqs = channels.map(ch => CHANNEL_FREQ[ch]);
|
|
const means = channels.map(ch => {
|
|
const c = channelMeans[ch];
|
|
return c.count > 0 ? c.sum / c.count : 0;
|
|
});
|
|
|
|
let sumF = 0, sumA = 0, sumFF = 0, sumFA = 0;
|
|
for (let i = 0; i < channels.length; i++) {
|
|
sumF += freqs[i]; sumA += means[i];
|
|
sumFF += freqs[i] * freqs[i]; sumFA += freqs[i] * means[i];
|
|
}
|
|
const nCh = channels.length;
|
|
const meanF = sumF / nCh;
|
|
const denomF = sumFF - sumF * meanF;
|
|
const slope = Math.abs(denomF) > 1e-6 ? (sumFA - sumF * (sumA / nCh)) / denomF : 0;
|
|
|
|
lines.push('');
|
|
if (slope < -0.003) {
|
|
lines.push(' Overall trend: DECREASING with frequency (water/organic absorption)');
|
|
} else if (slope > 0.003) {
|
|
lines.push(' Overall trend: INCREASING with frequency (unusual, possible reflection)');
|
|
} else {
|
|
lines.push(' Overall trend: FLAT across frequency (metal or air dominant)');
|
|
}
|
|
lines.push(` Slope: ${(slope * 1000).toFixed(3)} amplitude/GHz`);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function renderDetailedSubcarriers(result) {
|
|
const { classifications, channels } = result;
|
|
if (!classifications) return '';
|
|
|
|
const lines = [];
|
|
lines.push('');
|
|
lines.push(' Notable Subcarriers (high frequency selectivity):');
|
|
lines.push(' ' + '-'.repeat(60));
|
|
lines.push(' SC# Material Mean Flat Slope/MHz Per-channel amps');
|
|
|
|
// Find most interesting subcarriers (high flat ratio or steep slope)
|
|
const interesting = classifications
|
|
.filter(c => parseFloat(c.flatRatio) > 0.3 || Math.abs(parseFloat(c.slopePerMHz)) > 0.005)
|
|
.sort((a, b) => parseFloat(b.flatRatio) - parseFloat(a.flatRatio))
|
|
.slice(0, 15);
|
|
|
|
for (const cls of interesting) {
|
|
const amps = cls.amps.join(' ');
|
|
lines.push(` ${String(cls.subcarrier).padStart(3)} ${cls.material.padEnd(8)} ` +
|
|
`${cls.meanAmp.padStart(5)} ${cls.flatRatio} ${cls.slopePerMHz.padStart(9)} [${amps}]`);
|
|
}
|
|
|
|
if (interesting.length === 0) {
|
|
lines.push(' (no highly frequency-selective subcarriers detected)');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global state
|
|
// ---------------------------------------------------------------------------
|
|
const classifier = new MaterialClassifier();
|
|
let lastDisplayMs = 0;
|
|
|
|
function processFrame(channel, amplitudes) {
|
|
classifier.ingestFrame(channel, amplitudes);
|
|
}
|
|
|
|
function displayUpdate() {
|
|
const result = classifier.classify();
|
|
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify({
|
|
timestamp: Date.now() / 1000,
|
|
channels: result.channels,
|
|
frameCount: result.frameCount,
|
|
materialCounts: result.materialCounts,
|
|
topClassifications: (result.classifications || [])
|
|
.filter(c => c.material !== 'Air')
|
|
.slice(0, 20)
|
|
.map(c => ({ sc: c.subcarrier, material: c.material, meanAmp: c.meanAmp })),
|
|
}));
|
|
} else {
|
|
process.stdout.write('\x1B[2J\x1B[H');
|
|
console.log(renderMaterialMap(result));
|
|
console.log(renderFrequencyProfile(result));
|
|
console.log(renderDetailedSubcarriers(result));
|
|
console.log('');
|
|
console.log(` Frames: ${result.frameCount} | Channels: ${(result.channels || []).length}`);
|
|
console.log(' Press Ctrl+C to exit');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live mode
|
|
// ---------------------------------------------------------------------------
|
|
function startLive() {
|
|
const sock = dgram.createSocket('udp4');
|
|
|
|
sock.on('message', (buf) => {
|
|
if (buf.length < 4) return;
|
|
const magic = buf.readUInt32LE(0);
|
|
if (magic !== CSI_MAGIC) return;
|
|
|
|
const frame = parseCSIFrame(buf);
|
|
if (!frame) return;
|
|
|
|
processFrame(frame.channel, frame.amplitudes);
|
|
|
|
const now = Date.now();
|
|
if (now - lastDisplayMs >= INTERVAL_MS) {
|
|
displayUpdate();
|
|
lastDisplayMs = now;
|
|
}
|
|
});
|
|
|
|
sock.bind(PORT, () => {
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`Material Classifier listening on UDP port ${PORT}`);
|
|
console.log('Waiting for multi-channel CSI frames...');
|
|
}
|
|
});
|
|
|
|
if (DURATION_MS) {
|
|
setTimeout(() => { displayUpdate(); sock.close(); process.exit(0); }, DURATION_MS);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Replay mode
|
|
// ---------------------------------------------------------------------------
|
|
async function startReplay(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
console.error(`File not found: ${filePath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(filePath),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let frameCount = 0;
|
|
let lastAnalysisTs = 0;
|
|
let windowCount = 0;
|
|
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue;
|
|
|
|
let record;
|
|
try { record = JSON.parse(line); } catch { continue; }
|
|
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
|
|
|
|
const amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64);
|
|
const channel = record.channel || assignChannel(record.node_id);
|
|
|
|
processFrame(channel, amplitudes);
|
|
frameCount++;
|
|
|
|
const tsMs = record.timestamp * 1000;
|
|
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
|
|
|
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
|
windowCount++;
|
|
const result = classifier.classify();
|
|
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify({
|
|
window: windowCount, timestamp: record.timestamp,
|
|
materialCounts: result.materialCounts,
|
|
}));
|
|
} else {
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log(`Window ${windowCount} | t=${record.timestamp.toFixed(1)}s | frames=${frameCount}`);
|
|
console.log('='.repeat(60));
|
|
console.log(renderMaterialMap(result));
|
|
console.log(renderFrequencyProfile(result));
|
|
}
|
|
lastAnalysisTs = tsMs;
|
|
}
|
|
}
|
|
|
|
// Final
|
|
if (!JSON_OUTPUT) {
|
|
const result = classifier.classify();
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log('FINAL MATERIAL CLASSIFICATION');
|
|
console.log('='.repeat(60));
|
|
console.log(renderMaterialMap(result));
|
|
console.log(renderFrequencyProfile(result));
|
|
console.log(renderDetailedSubcarriers(result));
|
|
console.log(`\nProcessed ${frameCount} frames in ${windowCount} windows`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry point
|
|
// ---------------------------------------------------------------------------
|
|
if (args.replay) {
|
|
startReplay(args.replay);
|
|
} else {
|
|
startLive();
|
|
}
|