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>
410 lines
14 KiB
JavaScript
410 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ADR-077: Breathing Disorder Screening — Apnea/Hypopnea Detection
|
|
*
|
|
* Monitors breathing rate time series for respiratory events (pauses > 10s)
|
|
* and computes AHI (Apnea-Hypopnea Index) for pre-screening.
|
|
*
|
|
* DISCLAIMER: This is a pre-screening tool, NOT a clinical diagnostic device.
|
|
* Consult a physician and pursue polysomnography for diagnosis.
|
|
*
|
|
* Usage:
|
|
* node scripts/apnea-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
|
* node scripts/apnea-detector.js --port 5006
|
|
* node scripts/apnea-detector.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' },
|
|
'apnea-threshold': { type: 'string', default: '3.0' },
|
|
'hypopnea-drop': { type: 'string', default: '0.5' },
|
|
'min-duration': { type: 'string', default: '10' },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
const PORT = parseInt(args.port, 10);
|
|
const JSON_OUTPUT = args.json;
|
|
const INTERVAL_MS = parseInt(args.interval, 10);
|
|
const APNEA_THRESH = parseFloat(args['apnea-threshold']); // BR below this = apnea
|
|
const HYPOPNEA_DROP = parseFloat(args['hypopnea-drop']); // 50% drop from baseline
|
|
const MIN_DURATION_SEC = parseInt(args['min-duration'], 10); // min event duration
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ADR-018 packet constants
|
|
// ---------------------------------------------------------------------------
|
|
const VITALS_MAGIC = 0xC5110002;
|
|
const FUSED_MAGIC = 0xC5110004;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Apnea detector engine
|
|
// ---------------------------------------------------------------------------
|
|
class ApneaDetector {
|
|
constructor(opts) {
|
|
this.apneaThresh = opts.apneaThresh;
|
|
this.hypopneaDrop = opts.hypopneaDrop;
|
|
this.minDurationSec = opts.minDurationSec;
|
|
|
|
// Rolling baseline (exponential moving average, 5-min window)
|
|
this.baselineBR = null;
|
|
this.baselineAlpha = 0.005; // slow adaptation
|
|
|
|
// Event tracking
|
|
this.events = []; // { type, startTs, endTs, durationSec, avgBR }
|
|
this.currentEvent = null; // in-progress event
|
|
this.eventSamples = []; // BR samples during current event
|
|
|
|
// Time tracking
|
|
this.startTime = null;
|
|
this.lastTime = null;
|
|
this.totalSamples = 0;
|
|
|
|
// Per-hour tracking
|
|
this.hourlyEvents = new Map(); // hour_index -> count
|
|
}
|
|
|
|
ingest(timestamp, br) {
|
|
if (!this.startTime) this.startTime = timestamp;
|
|
this.lastTime = timestamp;
|
|
this.totalSamples++;
|
|
|
|
// Update baseline (only with "normal" breathing)
|
|
if (br > this.apneaThresh * 2 && (!this.baselineBR || br < this.baselineBR * 2)) {
|
|
if (this.baselineBR === null) {
|
|
this.baselineBR = br;
|
|
} else {
|
|
this.baselineBR = this.baselineBR * (1 - this.baselineAlpha) + br * this.baselineAlpha;
|
|
}
|
|
}
|
|
|
|
// Detect events
|
|
const isApnea = br < this.apneaThresh;
|
|
const isHypopnea = this.baselineBR && br < this.baselineBR * (1 - this.hypopneaDrop) && !isApnea;
|
|
const inEvent = isApnea || isHypopnea;
|
|
|
|
if (inEvent) {
|
|
if (!this.currentEvent) {
|
|
// Start new event
|
|
this.currentEvent = {
|
|
type: isApnea ? 'apnea' : 'hypopnea',
|
|
startTs: timestamp,
|
|
};
|
|
this.eventSamples = [br];
|
|
} else {
|
|
this.eventSamples.push(br);
|
|
// Upgrade hypopnea to apnea if BR drops further
|
|
if (isApnea && this.currentEvent.type === 'hypopnea') {
|
|
this.currentEvent.type = 'apnea';
|
|
}
|
|
}
|
|
} else {
|
|
// Event ended
|
|
if (this.currentEvent) {
|
|
const duration = timestamp - this.currentEvent.startTs;
|
|
if (duration >= this.minDurationSec) {
|
|
const avgBR = this.eventSamples.reduce((a, b) => a + b, 0) / this.eventSamples.length;
|
|
const event = {
|
|
type: this.currentEvent.type,
|
|
startTs: this.currentEvent.startTs,
|
|
endTs: timestamp,
|
|
durationSec: duration,
|
|
avgBR,
|
|
};
|
|
this.events.push(event);
|
|
|
|
// Track hourly
|
|
const hourIdx = Math.floor((this.currentEvent.startTs - this.startTime) / 3600);
|
|
this.hourlyEvents.set(hourIdx, (this.hourlyEvents.get(hourIdx) || 0) + 1);
|
|
}
|
|
this.currentEvent = null;
|
|
this.eventSamples = [];
|
|
}
|
|
}
|
|
|
|
return { isApnea, isHypopnea, baseline: this.baselineBR, br };
|
|
}
|
|
|
|
getAHI() {
|
|
const hours = this.lastTime && this.startTime
|
|
? (this.lastTime - this.startTime) / 3600
|
|
: 0;
|
|
if (hours < 0.01) return { ahi: 0, hours, events: 0, severity: 'Insufficient data' };
|
|
|
|
const totalEvents = this.events.length;
|
|
const ahi = totalEvents / hours;
|
|
|
|
let severity;
|
|
if (ahi < 5) severity = 'Normal';
|
|
else if (ahi < 15) severity = 'Mild';
|
|
else if (ahi < 30) severity = 'Moderate';
|
|
else severity = 'Severe';
|
|
|
|
return { ahi, hours, events: totalEvents, severity };
|
|
}
|
|
|
|
getHourlyAHI() {
|
|
const result = [];
|
|
for (const [hour, count] of [...this.hourlyEvents.entries()].sort((a, b) => a[0] - b[0])) {
|
|
result.push({ hour, events: count, ahi: count }); // events per 1 hour
|
|
}
|
|
return result;
|
|
}
|
|
|
|
getEventSummary() {
|
|
const apneas = this.events.filter(e => e.type === 'apnea');
|
|
const hypopneas = this.events.filter(e => e.type === 'hypopnea');
|
|
|
|
return {
|
|
totalEvents: this.events.length,
|
|
apneas: apneas.length,
|
|
hypopneas: hypopneas.length,
|
|
avgApneaDuration: apneas.length > 0
|
|
? apneas.reduce((s, e) => s + e.durationSec, 0) / apneas.length : 0,
|
|
avgHypopneaDuration: hypopneas.length > 0
|
|
? hypopneas.reduce((s, e) => s + e.durationSec, 0) / hypopneas.length : 0,
|
|
maxDuration: this.events.length > 0
|
|
? Math.max(...this.events.map(e => e.durationSec)) : 0,
|
|
baselineBR: this.baselineBR || 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Packet parsing
|
|
// ---------------------------------------------------------------------------
|
|
function parseVitalsJsonl(record) {
|
|
if (record.type !== 'vitals') return null;
|
|
return { timestamp: record.timestamp, nodeId: record.node_id, br: record.breathing_bpm || 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),
|
|
br: buf.readUInt16LE(6) / 100,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Replay mode
|
|
// ---------------------------------------------------------------------------
|
|
async function startReplay(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
console.error(`File not found: ${filePath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const detector = new ApneaDetector({
|
|
apneaThresh: APNEA_THRESH,
|
|
hypopneaDrop: HYPOPNEA_DROP,
|
|
minDurationSec: MIN_DURATION_SEC,
|
|
});
|
|
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(filePath),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let vitalsCount = 0;
|
|
let lastPrintTs = 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;
|
|
|
|
const state = detector.ingest(v.timestamp, v.br);
|
|
vitalsCount++;
|
|
|
|
// Print new events immediately
|
|
const lastEvent = detector.events.length > 0 ? detector.events[detector.events.length - 1] : null;
|
|
if (lastEvent && lastEvent.endTs === v.timestamp) {
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify({
|
|
type: 'event',
|
|
event_type: lastEvent.type,
|
|
start: lastEvent.startTs,
|
|
end: lastEvent.endTs,
|
|
duration_sec: +lastEvent.durationSec.toFixed(1),
|
|
avg_br: +lastEvent.avgBR.toFixed(2),
|
|
}));
|
|
} else {
|
|
const ts = new Date(lastEvent.startTs * 1000).toISOString().slice(11, 19);
|
|
const tag = lastEvent.type === 'apnea' ? '!! APNEA ' : '~ HYPOPNEA';
|
|
console.log(`[${ts}] ${tag} | ${lastEvent.durationSec.toFixed(1)}s | avg BR ${lastEvent.avgBR.toFixed(1)} BPM`);
|
|
}
|
|
}
|
|
|
|
// Periodic status
|
|
const tsMs = v.timestamp * 1000;
|
|
if (tsMs - lastPrintTs >= INTERVAL_MS * 2) {
|
|
if (!JSON_OUTPUT) {
|
|
const ahi = detector.getAHI();
|
|
const ts = new Date(v.timestamp * 1000).toISOString().slice(11, 19);
|
|
console.log(`[${ts}] BR ${v.br.toFixed(1)} | baseline ${(state.baseline || 0).toFixed(1)} | AHI ${ahi.ahi.toFixed(1)} (${ahi.severity}) | ${ahi.events} events / ${ahi.hours.toFixed(2)} hrs`);
|
|
}
|
|
lastPrintTs = tsMs;
|
|
}
|
|
}
|
|
|
|
// Final summary
|
|
const ahi = detector.getAHI();
|
|
const summary = detector.getEventSummary();
|
|
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify({
|
|
type: 'summary',
|
|
ahi: +ahi.ahi.toFixed(2),
|
|
severity: ahi.severity,
|
|
hours: +ahi.hours.toFixed(3),
|
|
...summary,
|
|
hourly: detector.getHourlyAHI(),
|
|
}));
|
|
} else {
|
|
console.log('\n' + '='.repeat(60));
|
|
console.log('APNEA SCREENING SUMMARY');
|
|
console.log('DISCLAIMER: Pre-screening only. Consult a physician.');
|
|
console.log('='.repeat(60));
|
|
console.log(`Monitored: ${ahi.hours.toFixed(2)} hours (${vitalsCount} samples)`);
|
|
console.log(`AHI: ${ahi.ahi.toFixed(1)} events/hour`);
|
|
console.log(`Severity: ${ahi.severity}`);
|
|
console.log(`Total events: ${summary.totalEvents}`);
|
|
console.log(` Apneas: ${summary.apneas} (avg ${summary.avgApneaDuration.toFixed(1)}s)`);
|
|
console.log(` Hypopneas: ${summary.hypopneas} (avg ${summary.avgHypopneaDuration.toFixed(1)}s)`);
|
|
console.log(` Longest event: ${summary.maxDuration.toFixed(1)}s`);
|
|
console.log(`Baseline BR: ${summary.baselineBR.toFixed(1)} BPM`);
|
|
|
|
const hourly = detector.getHourlyAHI();
|
|
if (hourly.length > 0) {
|
|
console.log('\nHourly breakdown:');
|
|
for (const h of hourly) {
|
|
const bar = '\u2588'.repeat(Math.min(h.events, 40));
|
|
console.log(` Hour ${h.hour}: ${bar} ${h.events} events (AHI ${h.ahi})`);
|
|
}
|
|
}
|
|
|
|
// Event timeline
|
|
if (detector.events.length > 0 && detector.events.length <= 50) {
|
|
console.log('\nEvent timeline:');
|
|
for (const e of detector.events) {
|
|
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
|
const tag = e.type === 'apnea' ? 'APNEA ' : 'HYPOPNEA';
|
|
console.log(` [${ts}] ${tag} ${e.durationSec.toFixed(1)}s (BR ${e.avgBR.toFixed(1)})`);
|
|
}
|
|
} else if (detector.events.length > 50) {
|
|
console.log(`\n(${detector.events.length} events total, showing first/last 5)`);
|
|
for (const e of detector.events.slice(0, 5)) {
|
|
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
|
console.log(` [${ts}] ${e.type.padEnd(8)} ${e.durationSec.toFixed(1)}s`);
|
|
}
|
|
console.log(' ...');
|
|
for (const e of detector.events.slice(-5)) {
|
|
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
|
console.log(` [${ts}] ${e.type.padEnd(8)} ${e.durationSec.toFixed(1)}s`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live UDP mode
|
|
// ---------------------------------------------------------------------------
|
|
function startLive() {
|
|
const detector = new ApneaDetector({
|
|
apneaThresh: APNEA_THRESH,
|
|
hypopneaDrop: HYPOPNEA_DROP,
|
|
minDurationSec: MIN_DURATION_SEC,
|
|
});
|
|
|
|
const server = dgram.createSocket('udp4');
|
|
|
|
server.on('message', (buf) => {
|
|
const v = parseVitalsUdp(buf);
|
|
if (!v) return;
|
|
|
|
const state = detector.ingest(v.timestamp, v.br);
|
|
|
|
// Alert on new events
|
|
const lastEvent = detector.events.length > 0 ? detector.events[detector.events.length - 1] : null;
|
|
if (lastEvent && Math.abs(lastEvent.endTs - v.timestamp) < 2) {
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify({
|
|
type: 'event', event_type: lastEvent.type,
|
|
duration_sec: +lastEvent.durationSec.toFixed(1),
|
|
avg_br: +lastEvent.avgBR.toFixed(2),
|
|
}));
|
|
} else {
|
|
const tag = lastEvent.type === 'apnea' ? '!! APNEA' : '~ HYPOPNEA';
|
|
console.log(`${tag} | ${lastEvent.durationSec.toFixed(1)}s | avg BR ${lastEvent.avgBR.toFixed(1)}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
setInterval(() => {
|
|
if (!JSON_OUTPUT) {
|
|
const ahi = detector.getAHI();
|
|
process.stdout.write('\x1B[2J\x1B[H');
|
|
console.log('=== APNEA SCREENING (ADR-077) ===');
|
|
console.log('DISCLAIMER: Pre-screening only. Not a diagnostic device.');
|
|
console.log('');
|
|
console.log(`AHI: ${ahi.ahi.toFixed(1)} events/hour | Severity: ${ahi.severity}`);
|
|
console.log(`Events: ${ahi.events} in ${ahi.hours.toFixed(2)} hours`);
|
|
console.log(`Baseline BR: ${(detector.baselineBR || 0).toFixed(1)} BPM`);
|
|
|
|
if (detector.events.length > 0) {
|
|
console.log('\nRecent events:');
|
|
for (const e of detector.events.slice(-5)) {
|
|
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
|
console.log(` [${ts}] ${e.type.padEnd(8)} ${e.durationSec.toFixed(1)}s (BR ${e.avgBR.toFixed(1)})`);
|
|
}
|
|
}
|
|
}
|
|
}, INTERVAL_MS);
|
|
|
|
server.bind(PORT, () => {
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`Apnea Detector listening on UDP :${PORT}`);
|
|
console.log('DISCLAIMER: Pre-screening only. Consult a physician.\n');
|
|
}
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
const ahi = detector.getAHI();
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`\nSession AHI: ${ahi.ahi.toFixed(1)} (${ahi.severity}) | ${ahi.events} events / ${ahi.hours.toFixed(2)} hrs`);
|
|
}
|
|
server.close();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry
|
|
// ---------------------------------------------------------------------------
|
|
if (args.replay) {
|
|
startReplay(args.replay);
|
|
} else {
|
|
startLive();
|
|
}
|