mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
Sleep monitor (hypnogram + efficiency), apnea detector (AHI scoring), stress monitor (HRV + LF/HF via FFT), gait analyzer (cadence + tremor), material detector (null pattern classification), room fingerprint (k-means clustering + anomaly scoring) All validated on overnight data (113K frames). Pure Node.js, zero deps. Co-Authored-By: claude-flow <ruv@ruv.net>
474 lines
16 KiB
JavaScript
474 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ADR-077: Material/Object Change Detection
|
|
*
|
|
* Monitors CSI subcarrier null patterns to detect when objects (metal, water,
|
|
* wood, glass) are introduced, removed, or moved in the sensing area.
|
|
*
|
|
* Usage:
|
|
* node scripts/material-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
|
* node scripts/material-detector.js --port 5006
|
|
* node scripts/material-detector.js --replay FILE --json
|
|
* node scripts/material-detector.js --replay FILE --baseline-time 120
|
|
*
|
|
* ADR: docs/adr/ADR-077-novel-rf-sensing-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' },
|
|
replay: { type: 'string', short: 'r' },
|
|
json: { type: 'boolean', default: false },
|
|
interval: { type: 'string', short: 'i', default: '5000' },
|
|
'baseline-time': { type: 'string', default: '60' },
|
|
'null-threshold': { type: 'string', default: '2.0' },
|
|
'change-threshold': { type: 'string', default: '3' },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
const PORT = parseInt(args.port, 10);
|
|
const JSON_OUTPUT = args.json;
|
|
const INTERVAL_MS = parseInt(args.interval, 10);
|
|
const BASELINE_SEC = parseInt(args['baseline-time'], 10);
|
|
const NULL_THRESHOLD = parseFloat(args['null-threshold']);
|
|
const CHANGE_THRESHOLD = parseInt(args['change-threshold'], 10); // min subcarriers changed
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ADR-018 packet constants
|
|
// ---------------------------------------------------------------------------
|
|
const CSI_MAGIC = 0xC5110001;
|
|
const HEADER_SIZE = 20;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcarrier null pattern tracker
|
|
// ---------------------------------------------------------------------------
|
|
class NullPatternTracker {
|
|
constructor(nSubcarriers) {
|
|
this.nSc = nSubcarriers || 64;
|
|
|
|
// Baseline (Welford mean per subcarrier)
|
|
this.baselineMean = new Float64Array(256);
|
|
this.baselineCount = new Uint32Array(256);
|
|
this.baselineEstablished = false;
|
|
this.baselineNulls = new Set();
|
|
|
|
// Current window state
|
|
this.currentAmps = new Float64Array(256);
|
|
this.currentCount = 0;
|
|
|
|
// Events
|
|
this.events = [];
|
|
this.startTime = null;
|
|
this.lastTime = null;
|
|
}
|
|
|
|
updateBaseline(amplitudes) {
|
|
const n = amplitudes.length;
|
|
this.nSc = n;
|
|
for (let i = 0; i < n; i++) {
|
|
this.baselineCount[i]++;
|
|
const delta = amplitudes[i] - this.baselineMean[i];
|
|
this.baselineMean[i] += delta / this.baselineCount[i];
|
|
}
|
|
}
|
|
|
|
finalizeBaseline() {
|
|
this.baselineNulls = new Set();
|
|
for (let i = 0; i < this.nSc; i++) {
|
|
if (this.baselineMean[i] < NULL_THRESHOLD) {
|
|
this.baselineNulls.add(i);
|
|
}
|
|
}
|
|
this.baselineEstablished = true;
|
|
}
|
|
|
|
updateCurrent(amplitudes) {
|
|
const n = amplitudes.length;
|
|
// Exponential moving average for current window
|
|
const alpha = 0.1;
|
|
if (this.currentCount === 0) {
|
|
for (let i = 0; i < n; i++) this.currentAmps[i] = amplitudes[i];
|
|
} else {
|
|
for (let i = 0; i < n; i++) {
|
|
this.currentAmps[i] = this.currentAmps[i] * (1 - alpha) + amplitudes[i] * alpha;
|
|
}
|
|
}
|
|
this.currentCount++;
|
|
}
|
|
|
|
detectChanges(timestamp) {
|
|
if (!this.baselineEstablished || this.currentCount < 5) return null;
|
|
|
|
const currentNulls = new Set();
|
|
for (let i = 0; i < this.nSc; i++) {
|
|
if (this.currentAmps[i] < NULL_THRESHOLD) {
|
|
currentNulls.add(i);
|
|
}
|
|
}
|
|
|
|
// Find differences
|
|
const newNulls = []; // appeared (something blocking)
|
|
const removedNulls = []; // disappeared (object removed)
|
|
const shiftedNulls = []; // nearby shifts
|
|
|
|
for (const sc of currentNulls) {
|
|
if (!this.baselineNulls.has(sc)) newNulls.push(sc);
|
|
}
|
|
for (const sc of this.baselineNulls) {
|
|
if (!currentNulls.has(sc)) removedNulls.push(sc);
|
|
}
|
|
|
|
// Detect shifts (null moved by 1-3 subcarriers)
|
|
for (const newSc of newNulls) {
|
|
for (const rmSc of removedNulls) {
|
|
if (Math.abs(newSc - rmSc) <= 3) {
|
|
shiftedNulls.push({ from: rmSc, to: newSc });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Amplitude changes (non-null subcarriers with significant amplitude shift)
|
|
const ampChanges = [];
|
|
for (let i = 0; i < this.nSc; i++) {
|
|
if (this.baselineMean[i] > NULL_THRESHOLD && this.currentAmps[i] > NULL_THRESHOLD) {
|
|
const ratio = this.currentAmps[i] / this.baselineMean[i];
|
|
if (ratio < 0.5 || ratio > 2.0) {
|
|
ampChanges.push({ sc: i, baseline: this.baselineMean[i], current: this.currentAmps[i], ratio });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Material classification
|
|
let material = 'unknown';
|
|
if (newNulls.length > 0) {
|
|
// Null pattern indicates metal
|
|
if (newNulls.length <= 5) material = 'metal (small object)';
|
|
else if (newNulls.length <= 15) material = 'metal (medium)';
|
|
else material = 'metal (large)';
|
|
} else if (ampChanges.length > this.nSc * 0.3) {
|
|
// Broad amplitude change = water or human
|
|
const avgRatio = ampChanges.reduce((s, c) => s + c.ratio, 0) / ampChanges.length;
|
|
material = avgRatio < 1 ? 'water/human (absorption)' : 'reflective surface';
|
|
} else if (ampChanges.length > 0 && ampChanges.length <= this.nSc * 0.1) {
|
|
material = 'wood/plastic (minimal)';
|
|
}
|
|
|
|
const totalChanges = newNulls.length + removedNulls.length + ampChanges.length;
|
|
|
|
// Only report if significant changes
|
|
if (totalChanges < CHANGE_THRESHOLD) {
|
|
return {
|
|
timestamp,
|
|
changeDetected: false,
|
|
currentNullCount: currentNulls.size,
|
|
baselineNullCount: this.baselineNulls.size,
|
|
};
|
|
}
|
|
|
|
// Determine event type
|
|
let eventType;
|
|
if (shiftedNulls.length > 0) eventType = 'moved';
|
|
else if (newNulls.length > removedNulls.length) eventType = 'added';
|
|
else if (removedNulls.length > newNulls.length) eventType = 'removed';
|
|
else eventType = 'changed';
|
|
|
|
const event = {
|
|
timestamp,
|
|
changeDetected: true,
|
|
eventType,
|
|
material,
|
|
newNulls: newNulls.length,
|
|
removedNulls: removedNulls.length,
|
|
shiftedNulls: shiftedNulls.length,
|
|
ampChanges: ampChanges.length,
|
|
newNullRange: newNulls.length > 0 ? [Math.min(...newNulls), Math.max(...newNulls)] : null,
|
|
removedNullRange: removedNulls.length > 0 ? [Math.min(...removedNulls), Math.max(...removedNulls)] : null,
|
|
currentNullCount: currentNulls.size,
|
|
baselineNullCount: this.baselineNulls.size,
|
|
nullDelta: currentNulls.size - this.baselineNulls.size,
|
|
};
|
|
|
|
this.events.push(event);
|
|
return event;
|
|
}
|
|
|
|
renderNullMap() {
|
|
const chars = [];
|
|
for (let i = 0; i < this.nSc; i++) {
|
|
if (this.currentAmps[i] < NULL_THRESHOLD) {
|
|
if (this.baselineNulls.has(i)) chars.push('_'); // baseline null
|
|
else chars.push('X'); // new null
|
|
} else if (this.baselineNulls.has(i)) {
|
|
chars.push('O'); // removed null
|
|
} else {
|
|
chars.push('\u2581'); // normal
|
|
}
|
|
}
|
|
return chars.join('');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-node manager
|
|
// ---------------------------------------------------------------------------
|
|
class MaterialDetector {
|
|
constructor() {
|
|
this.trackers = new Map(); // nodeId -> NullPatternTracker
|
|
this.startTime = null;
|
|
this.allEvents = [];
|
|
}
|
|
|
|
ingestCSI(nodeId, timestamp, amplitudes) {
|
|
if (!this.startTime) this.startTime = timestamp;
|
|
|
|
if (!this.trackers.has(nodeId)) {
|
|
this.trackers.set(nodeId, new NullPatternTracker(amplitudes.length));
|
|
}
|
|
const tracker = this.trackers.get(nodeId);
|
|
tracker.lastTime = timestamp;
|
|
if (!tracker.startTime) tracker.startTime = timestamp;
|
|
|
|
// Baseline phase
|
|
const elapsed = timestamp - tracker.startTime;
|
|
if (elapsed < BASELINE_SEC) {
|
|
tracker.updateBaseline(amplitudes);
|
|
return null;
|
|
}
|
|
|
|
// Finalize baseline on transition
|
|
if (!tracker.baselineEstablished) {
|
|
tracker.finalizeBaseline();
|
|
}
|
|
|
|
tracker.updateCurrent(amplitudes);
|
|
return null; // actual detection happens on analyze() call
|
|
}
|
|
|
|
analyze(timestamp) {
|
|
const results = {};
|
|
for (const [nodeId, tracker] of this.trackers) {
|
|
const result = tracker.detectChanges(timestamp);
|
|
if (result) {
|
|
result.nodeId = nodeId;
|
|
results[nodeId] = result;
|
|
if (result.changeDetected) {
|
|
this.allEvents.push(result);
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Packet parsing
|
|
// ---------------------------------------------------------------------------
|
|
function parseCsiJsonl(record) {
|
|
if (record.type !== 'raw_csi' || !record.iq_hex) return null;
|
|
const nSc = record.subcarriers || 64;
|
|
const bytes = Buffer.from(record.iq_hex, 'hex');
|
|
const amplitudes = new Float64Array(nSc);
|
|
|
|
for (let sc = 0; sc < nSc; sc++) {
|
|
const offset = 2 + sc * 2;
|
|
if (offset + 1 >= bytes.length) break;
|
|
let I = bytes[offset]; if (I > 127) I -= 256;
|
|
let Q = bytes[offset + 1]; if (Q > 127) Q -= 256;
|
|
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
|
}
|
|
|
|
return { timestamp: record.timestamp, nodeId: record.node_id, amplitudes };
|
|
}
|
|
|
|
function parseCsiUdp(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 nSc = buf.readUInt16LE(6);
|
|
const amplitudes = new Float64Array(nSc);
|
|
|
|
for (let sc = 0; sc < nSc; 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);
|
|
}
|
|
|
|
return { timestamp: Date.now() / 1000, nodeId, amplitudes };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Replay mode
|
|
// ---------------------------------------------------------------------------
|
|
async function startReplay(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
console.error(`File not found: ${filePath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const detector = new MaterialDetector();
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(filePath),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let frameCount = 0;
|
|
let lastAnalysisTs = 0;
|
|
let baselineReported = new Set();
|
|
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue;
|
|
let record;
|
|
try { record = JSON.parse(line); } catch { continue; }
|
|
|
|
const csi = parseCsiJsonl(record);
|
|
if (!csi) continue;
|
|
|
|
detector.ingestCSI(csi.nodeId, csi.timestamp, csi.amplitudes);
|
|
frameCount++;
|
|
|
|
// Report baseline completion
|
|
for (const [nodeId, tracker] of detector.trackers) {
|
|
if (tracker.baselineEstablished && !baselineReported.has(nodeId)) {
|
|
baselineReported.add(nodeId);
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`Node ${nodeId}: baseline established (${tracker.baselineNulls.size} nulls, ${((tracker.baselineNulls.size / tracker.nSc) * 100).toFixed(0)}%)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const tsMs = csi.timestamp * 1000;
|
|
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
|
|
|
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
|
const results = detector.analyze(csi.timestamp);
|
|
|
|
for (const [nodeId, result] of Object.entries(results)) {
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify(result));
|
|
} else if (result.changeDetected) {
|
|
const ts = new Date(csi.timestamp * 1000).toISOString().slice(11, 19);
|
|
console.log(`[${ts}] Node ${nodeId}: ${result.eventType.toUpperCase()} | ${result.material} | nulls ${result.baselineNullCount} -> ${result.currentNullCount} (delta ${result.nullDelta > 0 ? '+' : ''}${result.nullDelta})`);
|
|
if (result.newNullRange) console.log(` New nulls: sc ${result.newNullRange[0]}-${result.newNullRange[1]} (${result.newNulls} subcarriers)`);
|
|
if (result.removedNullRange) console.log(` Removed nulls: sc ${result.removedNullRange[0]}-${result.removedNullRange[1]} (${result.removedNulls} subcarriers)`);
|
|
if (result.ampChanges > 0) console.log(` Amplitude changes: ${result.ampChanges} subcarriers`);
|
|
}
|
|
}
|
|
|
|
lastAnalysisTs = tsMs;
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
if (!JSON_OUTPUT) {
|
|
console.log('\n' + '='.repeat(60));
|
|
console.log('MATERIAL/OBJECT CHANGE DETECTION SUMMARY');
|
|
console.log('='.repeat(60));
|
|
|
|
for (const [nodeId, tracker] of detector.trackers) {
|
|
console.log(`\nNode ${nodeId}:`);
|
|
console.log(` Baseline nulls: ${tracker.baselineNulls.size} / ${tracker.nSc} (${((tracker.baselineNulls.size / tracker.nSc) * 100).toFixed(0)}%)`);
|
|
console.log(` Current map: ${tracker.renderNullMap()}`);
|
|
console.log(` Legend: _ = baseline null, X = new null, O = removed null, \u2581 = normal`);
|
|
}
|
|
|
|
console.log(`\nTotal change events: ${detector.allEvents.length}`);
|
|
if (detector.allEvents.length > 0) {
|
|
const types = {};
|
|
const materials = {};
|
|
for (const e of detector.allEvents) {
|
|
types[e.eventType] = (types[e.eventType] || 0) + 1;
|
|
materials[e.material] = (materials[e.material] || 0) + 1;
|
|
}
|
|
console.log('Event types:');
|
|
for (const [t, c] of Object.entries(types)) console.log(` ${t}: ${c}`);
|
|
console.log('Materials:');
|
|
for (const [m, c] of Object.entries(materials)) console.log(` ${m}: ${c}`);
|
|
}
|
|
|
|
console.log(`\nProcessed ${frameCount} CSI frames`);
|
|
} else {
|
|
console.log(JSON.stringify({
|
|
type: 'summary',
|
|
events: detector.allEvents.length,
|
|
frames: frameCount,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live UDP mode
|
|
// ---------------------------------------------------------------------------
|
|
function startLive() {
|
|
const detector = new MaterialDetector();
|
|
const server = dgram.createSocket('udp4');
|
|
|
|
server.on('message', (buf) => {
|
|
const csi = parseCsiUdp(buf);
|
|
if (csi) detector.ingestCSI(csi.nodeId, csi.timestamp, csi.amplitudes);
|
|
});
|
|
|
|
setInterval(() => {
|
|
const results = detector.analyze(Date.now() / 1000);
|
|
|
|
if (JSON_OUTPUT) {
|
|
for (const result of Object.values(results)) {
|
|
console.log(JSON.stringify(result));
|
|
}
|
|
} else {
|
|
process.stdout.write('\x1B[2J\x1B[H');
|
|
console.log('=== MATERIAL/OBJECT DETECTOR (ADR-077) ===\n');
|
|
|
|
for (const [nodeId, tracker] of detector.trackers) {
|
|
if (!tracker.baselineEstablished) {
|
|
const elapsed = tracker.lastTime ? tracker.lastTime - tracker.startTime : 0;
|
|
console.log(`Node ${nodeId}: establishing baseline... ${elapsed.toFixed(0)}/${BASELINE_SEC}s`);
|
|
} else {
|
|
console.log(`Node ${nodeId}: ${tracker.renderNullMap()}`);
|
|
console.log(` Baseline: ${tracker.baselineNulls.size} nulls | Current: ${[...Array(tracker.nSc)].filter((_, i) => tracker.currentAmps[i] < NULL_THRESHOLD).length} nulls`);
|
|
}
|
|
}
|
|
|
|
if (detector.allEvents.length > 0) {
|
|
console.log('\nRecent events:');
|
|
for (const e of detector.allEvents.slice(-5)) {
|
|
const ts = new Date(e.timestamp * 1000).toISOString().slice(11, 19);
|
|
console.log(` [${ts}] ${e.eventType} | ${e.material} | delta ${e.nullDelta}`);
|
|
}
|
|
}
|
|
|
|
console.log(`\nTotal events: ${detector.allEvents.length}`);
|
|
}
|
|
}, INTERVAL_MS);
|
|
|
|
server.bind(PORT, () => {
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`Material Detector listening on UDP :${PORT} (baseline: ${BASELINE_SEC}s)`);
|
|
}
|
|
});
|
|
|
|
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry
|
|
// ---------------------------------------------------------------------------
|
|
if (args.replay) {
|
|
startReplay(args.replay);
|
|
} else {
|
|
startLive();
|
|
}
|