mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 14:09:33 +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>
414 lines
13 KiB
JavaScript
414 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ADR-077: Stress Monitor — HRV-based emotional state detection
|
|
*
|
|
* Computes RMSSD and LF/HF ratio from heart rate time series to produce
|
|
* a stress score (0-100). Uses 5-minute sliding windows with FFT analysis.
|
|
*
|
|
* DISCLAIMER: This is an informational wellness tool, NOT a medical device.
|
|
* Do not use for clinical diagnosis.
|
|
*
|
|
* Usage:
|
|
* node scripts/stress-monitor.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
|
* node scripts/stress-monitor.js --port 5006
|
|
* node scripts/stress-monitor.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' },
|
|
window: { type: 'string', short: 'w', default: '300' },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
const PORT = parseInt(args.port, 10);
|
|
const JSON_OUTPUT = args.json;
|
|
const INTERVAL_MS = parseInt(args.interval, 10);
|
|
const WINDOW_SEC = parseInt(args.window, 10);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ADR-018 packet constants
|
|
// ---------------------------------------------------------------------------
|
|
const VITALS_MAGIC = 0xC5110002;
|
|
const FUSED_MAGIC = 0xC5110004;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Simple FFT (radix-2 DIT, power-of-2 only)
|
|
// ---------------------------------------------------------------------------
|
|
function fft(re, im) {
|
|
const n = re.length;
|
|
if (n <= 1) return;
|
|
|
|
// Bit-reversal permutation
|
|
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]];
|
|
}
|
|
}
|
|
|
|
// Cooley-Tukey
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HRV analysis engine
|
|
// ---------------------------------------------------------------------------
|
|
class HRVAnalyzer {
|
|
constructor(windowSec) {
|
|
this.windowSec = windowSec;
|
|
this.hrSamples = []; // { timestamp, hr }
|
|
this.history = []; // { timestamp, rmssd, lfhf, stress, motionMean }
|
|
this.maxHistory = 500;
|
|
}
|
|
|
|
push(timestamp, hr, motion) {
|
|
this.hrSamples.push({ timestamp, hr, motion: motion || 0 });
|
|
// Prune old samples
|
|
const cutoff = timestamp - this.windowSec;
|
|
while (this.hrSamples.length > 0 && this.hrSamples[0].timestamp < cutoff) {
|
|
this.hrSamples.shift();
|
|
}
|
|
}
|
|
|
|
analyze(timestamp) {
|
|
const samples = this.hrSamples;
|
|
const n = samples.length;
|
|
if (n < 10) return null;
|
|
|
|
// Compute RR intervals (from HR in BPM -> interval in ms)
|
|
// HR = 60000 / RR_ms, so RR_ms = 60000 / HR
|
|
const rr = [];
|
|
for (const s of samples) {
|
|
if (s.hr > 20 && s.hr < 200) {
|
|
rr.push(60000 / s.hr);
|
|
}
|
|
}
|
|
if (rr.length < 5) return null;
|
|
|
|
// RMSSD: root mean square of successive differences
|
|
let sumSqDiff = 0;
|
|
let diffCount = 0;
|
|
for (let i = 1; i < rr.length; i++) {
|
|
const diff = rr[i] - rr[i - 1];
|
|
sumSqDiff += diff * diff;
|
|
diffCount++;
|
|
}
|
|
const rmssd = diffCount > 0 ? Math.sqrt(sumSqDiff / diffCount) : 0;
|
|
|
|
// FFT-based LF/HF ratio
|
|
// Resample RR series to uniform ~1 Hz for FFT
|
|
const fs = 1.0; // 1 Hz sampling (approximate, given ~1 Hz vitals)
|
|
const nfft = nextPow2(Math.max(rr.length, 64));
|
|
const re = new Float64Array(nfft);
|
|
const im = new Float64Array(nfft);
|
|
|
|
// De-mean and window (Hann)
|
|
const mean = rr.reduce((a, b) => a + b, 0) / rr.length;
|
|
for (let i = 0; i < rr.length; i++) {
|
|
const hann = 0.5 * (1 - Math.cos(2 * Math.PI * i / (rr.length - 1)));
|
|
re[i] = (rr[i] - mean) * hann;
|
|
}
|
|
|
|
fft(re, im);
|
|
|
|
// Compute power spectral density
|
|
const freqRes = fs / nfft;
|
|
let lfPower = 0, hfPower = 0;
|
|
for (let k = 0; k < nfft / 2; k++) {
|
|
const freq = k * freqRes;
|
|
const power = re[k] * re[k] + im[k] * im[k];
|
|
|
|
if (freq >= 0.04 && freq <= 0.15) lfPower += power;
|
|
if (freq >= 0.15 && freq <= 0.40) hfPower += power;
|
|
}
|
|
|
|
const lfhf = hfPower > 0.001 ? lfPower / hfPower : 0;
|
|
|
|
// Stress score (0-100)
|
|
// High RMSSD = relaxed (low stress), high LF/HF = stressed
|
|
const maxRmssd = 100; // typical max RMSSD for WiFi-derived HR
|
|
const rmssdNorm = Math.min(rmssd / maxRmssd, 1.0);
|
|
const lfhfNorm = Math.min(lfhf / 4.0, 1.0);
|
|
const stress = Math.round(50 * (1 - rmssdNorm) + 50 * lfhfNorm);
|
|
|
|
// Average motion in window
|
|
let motionSum = 0;
|
|
for (const s of samples) motionSum += s.motion;
|
|
const motionMean = motionSum / n;
|
|
|
|
// HR stats
|
|
const hrValues = samples.map(s => s.hr).filter(h => h > 20 && h < 200);
|
|
const hrMean = hrValues.reduce((a, b) => a + b, 0) / hrValues.length;
|
|
|
|
const result = {
|
|
timestamp,
|
|
rmssd: +rmssd.toFixed(2),
|
|
lfPower: +lfPower.toFixed(2),
|
|
hfPower: +hfPower.toFixed(2),
|
|
lfhf: +lfhf.toFixed(3),
|
|
stress,
|
|
hrMean: +hrMean.toFixed(1),
|
|
motionMean: +motionMean.toFixed(3),
|
|
samples: n,
|
|
};
|
|
|
|
this.history.push(result);
|
|
if (this.history.length > this.maxHistory) this.history.shift();
|
|
|
|
return result;
|
|
}
|
|
|
|
stressLabel(score) {
|
|
if (score < 20) return 'Very relaxed';
|
|
if (score < 40) return 'Relaxed';
|
|
if (score < 60) return 'Moderate';
|
|
if (score < 80) return 'Stressed';
|
|
return 'Very stressed';
|
|
}
|
|
|
|
renderTrend(width) {
|
|
const w = width || 50;
|
|
if (this.history.length === 0) return 'No data yet.';
|
|
|
|
const step = Math.max(1, Math.floor(this.history.length / w));
|
|
const bars = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
|
|
|
let line = '';
|
|
for (let i = 0; i < this.history.length; i += step) {
|
|
const s = this.history[i].stress;
|
|
const idx = Math.min(7, Math.floor(s / 12.5));
|
|
line += bars[idx];
|
|
}
|
|
return `Stress trend: ${line} (low)\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588(high)`;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Packet parsing
|
|
// ---------------------------------------------------------------------------
|
|
function parseVitalsJsonl(record) {
|
|
if (record.type !== 'vitals') return null;
|
|
return {
|
|
timestamp: record.timestamp,
|
|
nodeId: record.node_id,
|
|
hr: record.heartrate_bpm || 0,
|
|
motion: record.motion_energy || 0,
|
|
};
|
|
}
|
|
|
|
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),
|
|
hr: buf.readUInt32LE(8) / 10000,
|
|
motion: buf.readFloatLE(16),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Replay mode
|
|
// ---------------------------------------------------------------------------
|
|
async function startReplay(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
console.error(`File not found: ${filePath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const analyzer = new HRVAnalyzer(WINDOW_SEC);
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(filePath),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let vitalsCount = 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) continue;
|
|
|
|
analyzer.push(v.timestamp, v.hr, v.motion);
|
|
vitalsCount++;
|
|
|
|
const tsMs = v.timestamp * 1000;
|
|
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
|
|
|
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
|
const result = analyzer.analyze(v.timestamp);
|
|
|
|
if (result) {
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
const ts = new Date(v.timestamp * 1000).toISOString().slice(11, 19);
|
|
const label = analyzer.stressLabel(result.stress);
|
|
const bar = '\u2588'.repeat(Math.round(result.stress / 5));
|
|
console.log(`[${ts}] Stress: ${String(result.stress).padStart(3)}/100 ${bar.padEnd(20)} ${label} | RMSSD ${result.rmssd} | LF/HF ${result.lfhf} | HR ${result.hrMean} | Motion ${result.motionMean}`);
|
|
}
|
|
}
|
|
|
|
lastAnalysisTs = tsMs;
|
|
}
|
|
}
|
|
|
|
// Final summary
|
|
if (!JSON_OUTPUT) {
|
|
console.log('\n' + '='.repeat(70));
|
|
console.log('STRESS ANALYSIS SUMMARY');
|
|
console.log('DISCLAIMER: Informational only. Not a medical device.');
|
|
console.log('='.repeat(70));
|
|
|
|
if (analyzer.history.length > 0) {
|
|
const scores = analyzer.history.map(h => h.stress);
|
|
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
const min = Math.min(...scores);
|
|
const max = Math.max(...scores);
|
|
|
|
console.log(`Average stress: ${avg.toFixed(0)}/100 (${analyzer.stressLabel(avg)})`);
|
|
console.log(`Range: ${min} - ${max}`);
|
|
console.log(`Windows: ${analyzer.history.length}`);
|
|
console.log('');
|
|
console.log(analyzer.renderTrend(60));
|
|
|
|
// Activity correlation
|
|
const highMotion = analyzer.history.filter(h => h.motionMean > 3.0);
|
|
const lowMotion = analyzer.history.filter(h => h.motionMean < 1.0);
|
|
if (highMotion.length > 0 && lowMotion.length > 0) {
|
|
const avgHigh = highMotion.reduce((s, h) => s + h.stress, 0) / highMotion.length;
|
|
const avgLow = lowMotion.reduce((s, h) => s + h.stress, 0) / lowMotion.length;
|
|
console.log('');
|
|
console.log(`Activity correlation:`);
|
|
console.log(` Active periods (motion > 3): avg stress ${avgHigh.toFixed(0)} (${highMotion.length} windows)`);
|
|
console.log(` Rest periods (motion < 1): avg stress ${avgLow.toFixed(0)} (${lowMotion.length} windows)`);
|
|
}
|
|
}
|
|
|
|
console.log(`\nProcessed ${vitalsCount} vitals packets`);
|
|
} else {
|
|
if (analyzer.history.length > 0) {
|
|
const scores = analyzer.history.map(h => h.stress);
|
|
console.log(JSON.stringify({
|
|
type: 'summary',
|
|
avg_stress: +(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1),
|
|
min_stress: Math.min(...scores),
|
|
max_stress: Math.max(...scores),
|
|
windows: analyzer.history.length,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live UDP mode
|
|
// ---------------------------------------------------------------------------
|
|
function startLive() {
|
|
const analyzer = new HRVAnalyzer(WINDOW_SEC);
|
|
const server = dgram.createSocket('udp4');
|
|
|
|
server.on('message', (buf) => {
|
|
const v = parseVitalsUdp(buf);
|
|
if (v) {
|
|
analyzer.push(v.timestamp, v.hr, v.motion);
|
|
}
|
|
});
|
|
|
|
setInterval(() => {
|
|
const result = analyzer.analyze(Date.now() / 1000);
|
|
|
|
if (JSON_OUTPUT) {
|
|
if (result) console.log(JSON.stringify(result));
|
|
} else {
|
|
process.stdout.write('\x1B[2J\x1B[H');
|
|
console.log('=== STRESS MONITOR (ADR-077) ===');
|
|
console.log('DISCLAIMER: Informational only. Not a medical device.');
|
|
console.log('');
|
|
|
|
if (result) {
|
|
const label = analyzer.stressLabel(result.stress);
|
|
const bar = '\u2588'.repeat(Math.round(result.stress / 5));
|
|
console.log(`Stress: ${result.stress}/100 ${bar} ${label}`);
|
|
console.log(`RMSSD: ${result.rmssd} ms | LF/HF: ${result.lfhf}`);
|
|
console.log(`HR: ${result.hrMean} BPM | Motion: ${result.motionMean}`);
|
|
console.log(`Window: ${result.samples} samples`);
|
|
console.log('');
|
|
console.log(analyzer.renderTrend(50));
|
|
} else {
|
|
console.log('Collecting data...');
|
|
}
|
|
}
|
|
}, INTERVAL_MS);
|
|
|
|
server.bind(PORT, () => {
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`Stress Monitor listening on UDP :${PORT} (window ${WINDOW_SEC}s)`);
|
|
}
|
|
});
|
|
|
|
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry
|
|
// ---------------------------------------------------------------------------
|
|
if (args.replay) {
|
|
startReplay(args.replay);
|
|
} else {
|
|
startLive();
|
|
}
|