#!/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(); }