mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
feat: ADR-077 — 6 novel RF sensing applications
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>
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ADR-077: Gait Analysis / Movement Disorder Detection
|
||||
*
|
||||
* Extracts walking cadence, stride regularity, asymmetry, and tremor indicators
|
||||
* from CSI motion energy and phase variance time series.
|
||||
*
|
||||
* DISCLAIMER: This is an informational tool, NOT a medical device.
|
||||
* Do not use for clinical diagnosis of movement disorders.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/gait-analyzer.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/gait-analyzer.js --port 5006
|
||||
* node scripts/gait-analyzer.js --replay FILE --json
|
||||
*
|
||||
* 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' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple FFT (radix-2 DIT)
|
||||
// ---------------------------------------------------------------------------
|
||||
function fft(re, im) {
|
||||
const n = re.length;
|
||||
if (n <= 1) return;
|
||||
|
||||
for (let i = 1, j = 0; i < n; i++) {
|
||||
let bit = n >> 1;
|
||||
for (; j & bit; bit >>= 1) j ^= bit;
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
[re[i], re[j]] = [re[j], re[i]];
|
||||
[im[i], im[j]] = [im[j], im[i]];
|
||||
}
|
||||
}
|
||||
|
||||
for (let len = 2; len <= n; len *= 2) {
|
||||
const half = len / 2;
|
||||
const angle = -2 * Math.PI / len;
|
||||
const wRe = Math.cos(angle);
|
||||
const wIm = Math.sin(angle);
|
||||
|
||||
for (let i = 0; i < n; i += len) {
|
||||
let curRe = 1, curIm = 0;
|
||||
for (let j = 0; j < half; j++) {
|
||||
const tRe = curRe * re[i + j + half] - curIm * im[i + j + half];
|
||||
const tIm = curRe * im[i + j + half] + curIm * re[i + j + half];
|
||||
re[i + j + half] = re[i + j] - tRe;
|
||||
im[i + j + half] = im[i + j] - tIm;
|
||||
re[i + j] += tRe;
|
||||
im[i + j] += tIm;
|
||||
const newCurRe = curRe * wRe - curIm * wIm;
|
||||
curIm = curRe * wIm + curIm * wRe;
|
||||
curRe = newCurRe;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nextPow2(n) {
|
||||
let p = 1;
|
||||
while (p < n) p *= 2;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gait analysis engine
|
||||
// ---------------------------------------------------------------------------
|
||||
class GaitAnalyzer {
|
||||
constructor() {
|
||||
// Per-node time series buffers (5-second windows at ~22 fps or ~1 Hz vitals)
|
||||
this.motionBuffers = new Map(); // nodeId -> [{ timestamp, motion }]
|
||||
this.phaseVarBuffers = new Map(); // nodeId -> [{ timestamp, phaseVar }]
|
||||
this.maxAge = 5.0; // seconds
|
||||
this.results = [];
|
||||
}
|
||||
|
||||
pushMotion(nodeId, timestamp, motion) {
|
||||
if (!this.motionBuffers.has(nodeId)) this.motionBuffers.set(nodeId, []);
|
||||
const buf = this.motionBuffers.get(nodeId);
|
||||
buf.push({ timestamp, motion });
|
||||
const cutoff = timestamp - this.maxAge;
|
||||
while (buf.length > 0 && buf[0].timestamp < cutoff) buf.shift();
|
||||
}
|
||||
|
||||
pushPhaseVar(nodeId, timestamp, phaseVar) {
|
||||
if (!this.phaseVarBuffers.has(nodeId)) this.phaseVarBuffers.set(nodeId, []);
|
||||
const buf = this.phaseVarBuffers.get(nodeId);
|
||||
buf.push({ timestamp, phaseVar });
|
||||
const cutoff = timestamp - this.maxAge;
|
||||
while (buf.length > 0 && buf[0].timestamp < cutoff) buf.shift();
|
||||
}
|
||||
|
||||
analyze(timestamp) {
|
||||
const perNode = {};
|
||||
let bestCadence = 0;
|
||||
let bestRegularity = 0;
|
||||
const cadences = [];
|
||||
|
||||
for (const [nodeId, buf] of this.motionBuffers) {
|
||||
if (buf.length < 5) {
|
||||
perNode[nodeId] = { cadence: 0, regularity: 0, state: 'insufficient data' };
|
||||
continue;
|
||||
}
|
||||
|
||||
const motionValues = buf.map(b => b.motion);
|
||||
|
||||
// Estimate sampling rate
|
||||
const duration = buf[buf.length - 1].timestamp - buf[0].timestamp;
|
||||
const fs = duration > 0 ? buf.length / duration : 1;
|
||||
|
||||
// FFT for cadence
|
||||
const nfft = nextPow2(Math.max(motionValues.length, 32));
|
||||
const re = new Float64Array(nfft);
|
||||
const im = new Float64Array(nfft);
|
||||
|
||||
const mean = motionValues.reduce((a, b) => a + b, 0) / motionValues.length;
|
||||
for (let i = 0; i < motionValues.length; i++) {
|
||||
const hann = 0.5 * (1 - Math.cos(2 * Math.PI * i / (motionValues.length - 1)));
|
||||
re[i] = (motionValues[i] - mean) * hann;
|
||||
}
|
||||
|
||||
fft(re, im);
|
||||
|
||||
// Find dominant frequency in walking range (0.8 - 2.5 Hz)
|
||||
const freqRes = fs / nfft;
|
||||
let peakPower = 0, peakFreq = 0;
|
||||
let totalPower = 0;
|
||||
|
||||
for (let k = 1; k < nfft / 2; k++) {
|
||||
const freq = k * freqRes;
|
||||
const power = re[k] * re[k] + im[k] * im[k];
|
||||
totalPower += power;
|
||||
|
||||
if (freq >= 0.8 && freq <= 2.5 && power > peakPower) {
|
||||
peakPower = power;
|
||||
peakFreq = freq;
|
||||
}
|
||||
}
|
||||
|
||||
const cadence = peakFreq * 60; // steps per minute (each leg cycle)
|
||||
const regularity = totalPower > 0 ? peakPower / totalPower : 0;
|
||||
|
||||
// Autocorrelation for stride regularity
|
||||
const autoCorr = this._autocorrelation(motionValues);
|
||||
const strideRegularity = autoCorr > 0 ? autoCorr : 0;
|
||||
|
||||
// State classification
|
||||
let state;
|
||||
if (mean < 1.0) state = 'stationary';
|
||||
else if (peakFreq >= 0.8 && peakFreq <= 2.0 && regularity > 0.1) state = 'walking';
|
||||
else if (peakFreq > 2.0 && regularity > 0.1) state = 'running';
|
||||
else state = 'moving (irregular)';
|
||||
|
||||
perNode[nodeId] = {
|
||||
cadence: +cadence.toFixed(1),
|
||||
cadenceHz: +peakFreq.toFixed(3),
|
||||
regularity: +regularity.toFixed(3),
|
||||
strideRegularity: +strideRegularity.toFixed(3),
|
||||
meanMotion: +mean.toFixed(3),
|
||||
state,
|
||||
samples: buf.length,
|
||||
fps: +fs.toFixed(1),
|
||||
};
|
||||
|
||||
if (cadence > bestCadence) bestCadence = cadence;
|
||||
if (regularity > bestRegularity) bestRegularity = regularity;
|
||||
if (peakFreq > 0) cadences.push(cadence);
|
||||
}
|
||||
|
||||
// Cross-node asymmetry (if 2+ nodes)
|
||||
let asymmetry = 0;
|
||||
const nodeKeys = Object.keys(perNode);
|
||||
if (nodeKeys.length >= 2) {
|
||||
const c0 = perNode[nodeKeys[0]].cadenceHz;
|
||||
const c1 = perNode[nodeKeys[1]].cadenceHz;
|
||||
const meanC = (c0 + c1) / 2;
|
||||
asymmetry = meanC > 0 ? Math.abs(c0 - c1) / meanC : 0;
|
||||
}
|
||||
|
||||
// Tremor detection from phase variance
|
||||
let tremorScore = 0;
|
||||
let tremorFreq = 0;
|
||||
for (const [, buf] of this.phaseVarBuffers) {
|
||||
if (buf.length < 10) continue;
|
||||
|
||||
const values = buf.map(b => b.phaseVar);
|
||||
const duration = buf[buf.length - 1].timestamp - buf[0].timestamp;
|
||||
const fs = duration > 0 ? buf.length / duration : 1;
|
||||
|
||||
const nfft = nextPow2(Math.max(values.length, 32));
|
||||
const re = new Float64Array(nfft);
|
||||
const im = new Float64Array(nfft);
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
for (let i = 0; i < values.length; i++) re[i] = values[i] - mean;
|
||||
fft(re, im);
|
||||
|
||||
const freqRes = fs / nfft;
|
||||
let tPeak = 0, tFreq = 0;
|
||||
for (let k = 1; k < nfft / 2; k++) {
|
||||
const freq = k * freqRes;
|
||||
const power = re[k] * re[k] + im[k] * im[k];
|
||||
if (freq >= 3.0 && freq <= 8.0 && power > tPeak) {
|
||||
tPeak = power;
|
||||
tFreq = freq;
|
||||
}
|
||||
}
|
||||
if (tPeak > tremorScore) {
|
||||
tremorScore = tPeak;
|
||||
tremorFreq = tFreq;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize tremor score to 0-1 range (heuristic)
|
||||
const tremorNorm = Math.min(tremorScore / 100, 1.0);
|
||||
|
||||
const result = {
|
||||
timestamp,
|
||||
cadence: +bestCadence.toFixed(1),
|
||||
regularity: +bestRegularity.toFixed(3),
|
||||
asymmetry: +asymmetry.toFixed(3),
|
||||
tremorScore: +tremorNorm.toFixed(3),
|
||||
tremorFreqHz: +tremorFreq.toFixed(2),
|
||||
perNode,
|
||||
overallState: this._overallState(perNode),
|
||||
};
|
||||
|
||||
this.results.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
_autocorrelation(values) {
|
||||
const n = values.length;
|
||||
if (n < 4) return 0;
|
||||
|
||||
const mean = values.reduce((a, b) => a + b, 0) / n;
|
||||
let denom = 0;
|
||||
for (let i = 0; i < n; i++) denom += (values[i] - mean) ** 2;
|
||||
if (denom < 0.001) return 0;
|
||||
|
||||
// Check autocorrelation at lag = n/4 to n/2 (typical stride period range)
|
||||
let bestCorr = 0;
|
||||
const minLag = Math.max(2, Math.floor(n / 4));
|
||||
const maxLag = Math.floor(n / 2);
|
||||
|
||||
for (let lag = minLag; lag <= maxLag; lag++) {
|
||||
let num = 0;
|
||||
for (let i = 0; i < n - lag; i++) {
|
||||
num += (values[i] - mean) * (values[i + lag] - mean);
|
||||
}
|
||||
const corr = num / denom;
|
||||
if (corr > bestCorr) bestCorr = corr;
|
||||
}
|
||||
|
||||
return bestCorr;
|
||||
}
|
||||
|
||||
_overallState(perNode) {
|
||||
const states = Object.values(perNode).map(n => n.state);
|
||||
if (states.includes('walking')) return 'walking';
|
||||
if (states.includes('running')) return 'running';
|
||||
if (states.includes('moving (irregular)')) return 'moving';
|
||||
return 'stationary';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseVitalsJsonl(record) {
|
||||
if (record.type !== 'vitals') return null;
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
motion: record.motion_energy || 0,
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// Compute phase variance across subcarriers
|
||||
let phaseSum = 0, phaseSqSum = 0, count = 0;
|
||||
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;
|
||||
const phase = Math.atan2(Q, I);
|
||||
phaseSum += phase;
|
||||
phaseSqSum += phase * phase;
|
||||
count++;
|
||||
}
|
||||
|
||||
const phaseMean = count > 0 ? phaseSum / count : 0;
|
||||
const phaseVar = count > 1 ? (phaseSqSum / count - phaseMean * phaseMean) : 0;
|
||||
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
phaseVar: Math.abs(phaseVar),
|
||||
};
|
||||
}
|
||||
|
||||
function parseVitalsUdp(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
return {
|
||||
timestamp: Date.now() / 1000,
|
||||
nodeId: buf.readUInt8(4),
|
||||
motion: buf.readFloatLE(16),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let phaseSum = 0, phaseSqSum = 0, count = 0;
|
||||
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);
|
||||
const phase = Math.atan2(Q, I);
|
||||
phaseSum += phase;
|
||||
phaseSqSum += phase * phase;
|
||||
count++;
|
||||
}
|
||||
|
||||
const phaseMean = count > 0 ? phaseSum / count : 0;
|
||||
const phaseVar = count > 1 ? (phaseSqSum / count - phaseMean * phaseMean) : 0;
|
||||
|
||||
return { timestamp: Date.now() / 1000, nodeId, phaseVar: Math.abs(phaseVar) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
function formatResult(result) {
|
||||
const lines = [];
|
||||
const ts = new Date(result.timestamp * 1000).toISOString().slice(11, 19);
|
||||
lines.push(`[${ts}] ${result.overallState.toUpperCase()}`);
|
||||
lines.push(` Cadence: ${result.cadence} steps/min`);
|
||||
lines.push(` Regularity: ${result.regularity}`);
|
||||
lines.push(` Asymmetry: ${result.asymmetry}`);
|
||||
lines.push(` Tremor: ${result.tremorScore} (${result.tremorFreqHz} Hz)`);
|
||||
|
||||
for (const [nodeId, node] of Object.entries(result.perNode)) {
|
||||
lines.push(` Node ${nodeId}: ${node.state} | ${node.cadence} spm | regularity ${node.regularity} | ${node.samples} samples @ ${node.fps} fps`);
|
||||
}
|
||||
|
||||
// Flags
|
||||
const flags = [];
|
||||
if (result.asymmetry > 0.3) flags.push('HIGH ASYMMETRY');
|
||||
if (result.tremorScore > 0.3) flags.push(`TREMOR DETECTED (${result.tremorFreqHz} Hz)`);
|
||||
if (result.cadence > 0 && result.cadence < 50) flags.push('SLOW CADENCE');
|
||||
if (flags.length > 0) lines.push(` ** ${flags.join(' | ')} **`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const analyzer = new GaitAnalyzer();
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
|
||||
const v = parseVitalsJsonl(record);
|
||||
if (v) {
|
||||
analyzer.pushMotion(v.nodeId, v.timestamp, v.motion);
|
||||
frameCount++;
|
||||
}
|
||||
|
||||
const csi = parseCsiJsonl(record);
|
||||
if (csi) {
|
||||
analyzer.pushPhaseVar(csi.nodeId, csi.timestamp, csi.phaseVar);
|
||||
}
|
||||
|
||||
const ts = (v || csi);
|
||||
if (!ts) continue;
|
||||
|
||||
const tsMs = ts.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
const result = analyzer.analyze(ts.timestamp);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
console.log(formatResult(result));
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (!JSON_OUTPUT && analyzer.results.length > 0) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('GAIT ANALYSIS SUMMARY');
|
||||
console.log('DISCLAIMER: Informational only. Not a medical device.');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const states = {};
|
||||
let totalCadence = 0, cadenceCount = 0;
|
||||
let maxTremor = 0;
|
||||
|
||||
for (const r of analyzer.results) {
|
||||
states[r.overallState] = (states[r.overallState] || 0) + 1;
|
||||
if (r.cadence > 0) {
|
||||
totalCadence += r.cadence;
|
||||
cadenceCount++;
|
||||
}
|
||||
if (r.tremorScore > maxTremor) maxTremor = r.tremorScore;
|
||||
}
|
||||
|
||||
console.log('Activity distribution:');
|
||||
for (const [state, count] of Object.entries(states)) {
|
||||
const pct = ((count / analyzer.results.length) * 100).toFixed(1);
|
||||
const bar = '\u2588'.repeat(Math.round(pct / 2));
|
||||
console.log(` ${state.padEnd(15)} ${bar.padEnd(50)} ${pct}%`);
|
||||
}
|
||||
|
||||
if (cadenceCount > 0) {
|
||||
console.log(`\nAverage walking cadence: ${(totalCadence / cadenceCount).toFixed(1)} steps/min`);
|
||||
}
|
||||
console.log(`Max tremor score: ${maxTremor.toFixed(3)}`);
|
||||
console.log(`Analysis windows: ${analyzer.results.length}`);
|
||||
console.log(`Processed ${frameCount} vitals packets`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live UDP mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const analyzer = new GaitAnalyzer();
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf) => {
|
||||
const v = parseVitalsUdp(buf);
|
||||
if (v) analyzer.pushMotion(v.nodeId, v.timestamp, v.motion);
|
||||
|
||||
const csi = parseCsiUdp(buf);
|
||||
if (csi) analyzer.pushPhaseVar(csi.nodeId, csi.timestamp, csi.phaseVar);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const result = analyzer.analyze(Date.now() / 1000);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log('=== GAIT ANALYZER (ADR-077) ===');
|
||||
console.log('DISCLAIMER: Informational only. Not a medical device.\n');
|
||||
console.log(formatResult(result));
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
server.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Gait Analyzer listening on UDP :${PORT}`);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
Reference in New Issue
Block a user