diff --git a/docs/adr/ADR-077-novel-rf-sensing-applications.md b/docs/adr/ADR-077-novel-rf-sensing-applications.md new file mode 100644 index 00000000..7eb1ffa7 --- /dev/null +++ b/docs/adr/ADR-077-novel-rf-sensing-applications.md @@ -0,0 +1,284 @@ +# ADR-077: Novel RF Sensing Applications + +**Status:** Accepted +**Date:** 2026-04-02 +**Authors:** ruv +**Depends on:** ADR-018 (CSI binary protocol), ADR-073 (multifrequency mesh scan), ADR-075 (MinCut person separation), ADR-076 (CSI spectrogram embeddings) + +## Context + +The existing ESP32 CSI + Cognitum Seed infrastructure collects rich multi-modal data: +- 2 ESP32-S3 nodes streaming CSI at ~22 fps each (64-128 subcarriers, channel hopping ch 1/3/5/6/9/11) +- Vitals extraction: breathing rate, heart rate, motion energy, presence score (1 Hz per node) +- 8-dimensional feature vectors per frame +- Cognitum Seed with BME280 (temp/humidity/pressure), PIR, reed switch, vibration sensor + +No new hardware is required. All 6 applications below derive novel insights from data already being collected via the ADR-018 binary protocol over UDP port 5006. + +## Decision + +Implement 6 novel RF sensing applications as standalone Node.js scripts that process live UDP or replayed `.csi.jsonl` recordings. + +--- + +## Application 1: Sleep Quality Monitoring + +### Input +Breathing rate (BR) and heart rate (HR) time series from vitals packets (0xC5110002), sampled at ~1 Hz per node over 6-8 hours. + +### Algorithm +Sliding window analysis (5-minute windows, 1-minute stride) classifying sleep stages: + +| Stage | BR (BPM) | BR Variance | HR Pattern | Motion | +|-------|----------|-------------|------------|--------| +| **Deep (N3)** | 6-12 | Very low (<2.0) | Slow, regular | None | +| **Light (N1/N2)** | 12-18 | Moderate (2.0-8.0) | Normal | Minimal | +| **REM** | 15-25 | High (>8.0), irregular | Elevated | Eyes only (low CSI motion) | +| **Awake** | >18 or <6 | Any | Variable | Moderate-high | + +Each 5-minute window is scored by: +1. Compute BR mean and variance within the window +2. Compute HR mean and coefficient of variation (CV) +3. Compute motion energy mean (from vitals `motion_energy` field) +4. Classify stage using threshold hierarchy: Awake > REM > Light > Deep + +### Output +- Real-time sleep stage classification +- ASCII hypnogram (time vs. stage) +- Summary: total sleep time, sleep efficiency (TST / time in bed), time per stage +- Optional JSON for health app integration + +### Validation +Overnight recording (`overnight-1775217646.csi.jsonl`, 113k frames, ~40 min) should show: +- Transition from active (awake) to resting states +- Decreased motion energy over time +- BR stabilization in sleeping segments + +### Clinical Relevance +Consumer-grade sleep tracking without wearables. RF-based sensing avoids compliance issues (forgotten wristbands, dead batteries). Not diagnostic; informational only. + +--- + +## Application 2: Breathing Disorder Screening (Apnea Detection) + +### Input +Breathing rate time series from vitals packets at ~1 Hz. + +### Algorithm +Detect respiratory events in the BR time series: + +| Event | Definition | Duration | +|-------|-----------|----------| +| **Apnea** | BR drops below 3 BPM (effective cessation) | >= 10 seconds | +| **Hypopnea** | BR drops > 50% from 5-min rolling baseline | >= 10 seconds | + +Scoring: +1. Maintain 5-minute rolling baseline BR (exponential moving average) +2. Flag apnea when BR < 3 BPM for >= 10 consecutive seconds +3. Flag hypopnea when BR < 50% of baseline for >= 10 consecutive seconds +4. Compute AHI (Apnea-Hypopnea Index) = total events / hours monitored + +| AHI | Severity | +|-----|----------| +| < 5 | Normal | +| 5-15 | Mild | +| 15-30 | Moderate | +| > 30 | Severe | + +### Output +- Per-event log: type (apnea/hypopnea), start time, duration, BR during event +- Hourly AHI and overall AHI +- Severity classification +- Alert on severe events (consecutive apneas > 30s) + +### Clinical Relevance +Pre-screening tool for obstructive sleep apnea (OSA). Provides motivation for clinical polysomnography referral. Not a diagnostic device; informational pre-screen only. + +--- + +## Application 3: Emotional State / Stress Detection + +### Input +Heart rate time series from vitals packets at ~1 Hz. + +### Algorithm +Heart Rate Variability (HRV) analysis: + +1. **RMSSD** (Root Mean Square of Successive Differences): + - Compute successive HR differences within 5-minute windows + - RMSSD = sqrt(mean(diff^2)) + - High RMSSD = high vagal tone = relaxed + - Low RMSSD = sympathetic dominance = stressed + +2. **LF/HF Ratio** (via FFT on 5-minute HR windows): + - LF band: 0.04-0.15 Hz (sympathetic + parasympathetic) + - HF band: 0.15-0.40 Hz (parasympathetic) + - High LF/HF (> 2.0) = stressed + - Low LF/HF (< 1.0) = relaxed + +3. **Stress Score** (0-100): + - `score = 50 * (1 - RMSSD_norm) + 50 * LF_HF_norm` + - Where `RMSSD_norm` = RMSSD / max_expected_RMSSD (capped at 1.0) + - And `LF_HF_norm` = min(LF_HF / 4.0, 1.0) + +### Output +- Real-time stress score (0-100) +- RMSSD and LF/HF ratio per window +- ASCII trend chart over hours +- Activity context correlation (motion level vs. stress) + +### Validation +- Periods of activity (walking, working) should correlate with higher stress scores +- Quiet rest should show lower scores +- Sleeping should show lowest scores (high HRV, low LF/HF) + +--- + +## Application 4: Gait Analysis / Movement Disorder Detection + +### Input +- Motion energy time series from vitals packets +- CSI phase variance from raw CSI frames (0xC5110001) +- Cross-node RSSI from vitals packets + +### Algorithm + +1. **Cadence Extraction**: FFT on motion_energy within 5-second sliding windows + - Walking cadence: dominant frequency 0.8-2.0 Hz (normal: ~1.0 Hz = 120 steps/min) + - Running: > 2.0 Hz + - Stationary: no dominant peak + +2. **Stride Regularity**: Autocorrelation of motion_energy + - Regular walking: strong autocorrelation peak at step period + - Irregularity score = 1 - (peak_height / baseline) + +3. **Asymmetry Detection**: Compare motion energy oscillation between two ESP32 nodes + - Symmetric gait: both nodes see similar oscillation period and amplitude + - Asymmetry index = |period_node1 - period_node2| / mean_period + +4. **Tremor Detection**: High-frequency phase variance analysis + - Compute phase variance per subcarrier in 2-second windows + - Tremor band: 3-8 Hz component in phase variance time series + - Parkinsonian tremor: 4-6 Hz, resting + - Essential tremor: 5-8 Hz, action + +### Output +- Cadence (steps/min) +- Stride regularity score (0-1) +- Asymmetry index (0 = symmetric, 1 = highly asymmetric) +- Tremor score and dominant frequency +- Walking vs. stationary classification + +### Validation +Overnight data should show clear stationary periods with no cadence detected. Any walking segments should show cadence in the 0.8-2.0 Hz range. + +--- + +## Application 5: Material/Object Change Detection + +### Input +Per-subcarrier amplitude from raw CSI frames (0xC5110001). + +### Algorithm + +1. **Baseline Establishment** (first 10 minutes or configurable): + - Record mean amplitude per subcarrier (Welford online mean) + - Record null pattern: which subcarriers are below null threshold (amplitude < 2.0) + +2. **Change Detection** (sliding 30-second windows): + - Compare current null pattern to baseline + - New nulls appearing = new metal object blocking RF path + - Existing nulls disappearing = metal object removed + - Null position shifted = object moved + - Amplitude change without null change = non-metal material (wood, water, glass) + +3. **Material Classification** heuristic: + - Metal: sharp null (amplitude drops to near 0 on specific subcarriers) + - Water/human: broad amplitude reduction across many subcarriers + - Wood/plastic: minimal amplitude change, mostly phase shift + - Glass: frequency-selective (affects higher subcarriers more) + +### Output +- Change events with timestamp, type (add/remove/move), affected subcarrier range +- Estimated material category +- Null pattern delta visualization (ASCII) +- Event timeline for monitoring + +### Validation +Overnight data has 19% null baseline. Changes in null pattern over the recording period indicate environment changes (doors opening/closing, person entering/leaving). + +--- + +## Application 6: Room Environment Fingerprinting + +### Input +- 8-dimensional feature vectors from feature packets (0xC5110003) +- Motion energy and presence score from vitals packets + +### Algorithm + +1. **Online Clustering** using running k-means (k=5, updateable centroids): + - Each incoming 8-dim feature vector is assigned to nearest centroid + - Centroid updated via exponential moving average (alpha=0.01) + - New cluster created if distance to all centroids exceeds threshold + +2. **State Labeling** (heuristic from vitals correlation): + - Cluster with lowest motion_energy = "empty/sleeping" + - Cluster with highest motion_energy = "active/walking" + - Intermediate clusters = "resting", "working", "transitional" + +3. **Transition Tracking**: + - Build state transition matrix (from_state -> to_state counts) + - Detect anomalous transitions (rare in historical data) + +4. **Daily Profile**: + - Aggregate state durations per hour + - Compare across days for routine detection + +### Output +- Current room state and confidence +- State timeline (ASCII) +- Transition matrix +- Daily pattern profile +- Anomaly score (deviation from established daily pattern) + +### Validation +Overnight recording should show 2-3 stable clusters corresponding to activity periods at different times. Transitions should be infrequent and correspond to real behavioral changes. + +--- + +## Implementation + +All scripts share common infrastructure: +- ADR-018 binary packet parsing (same as rf-scan.js, mincut-person-counter.js) +- JSONL replay via readline interface +- Live UDP via dgram +- Pure Node.js, no external dependencies +- CLI: `--replay ` for offline, `--port ` for live, `--json` for programmatic output + +| Script | Primary Packets | Key Algorithm | +|--------|----------------|---------------| +| `sleep-monitor.js` | vitals (0xC5110002) | BR/HR window classification | +| `apnea-detector.js` | vitals (0xC5110002) | BR pause detection, AHI scoring | +| `stress-monitor.js` | vitals (0xC5110002) | HRV RMSSD + FFT LF/HF | +| `gait-analyzer.js` | vitals + raw CSI | FFT cadence + phase tremor | +| `material-detector.js` | raw CSI (0xC5110001) | Null pattern baseline + delta | +| `room-fingerprint.js` | feature (0xC5110003) + vitals | Online k-means clustering | + +## Consequences + +### Positive +- 6 new sensing applications from existing hardware (zero additional cost) +- All offline-capable via JSONL replay (no live hardware needed for development) +- Pure JS, no native dependencies, runs on any platform with Node.js +- Each script is standalone and composable + +### Negative +- Vitals accuracy depends on ESP32 CSI quality (RSSI, multipath) +- HRV analysis at 1 Hz HR sampling is coarse compared to ECG +- Material classification is heuristic, not definitive +- Sleep staging without EEG is approximate (consumer-grade accuracy) + +### Risks +- Users may misinterpret health-related outputs as clinical diagnoses +- Mitigation: all scripts include disclaimers in output headers diff --git a/scripts/apnea-detector.js b/scripts/apnea-detector.js new file mode 100644 index 00000000..d0a73270 --- /dev/null +++ b/scripts/apnea-detector.js @@ -0,0 +1,410 @@ +#!/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(); +} diff --git a/scripts/gait-analyzer.js b/scripts/gait-analyzer.js new file mode 100644 index 00000000..c313e193 --- /dev/null +++ b/scripts/gait-analyzer.js @@ -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(); +} diff --git a/scripts/material-detector.js b/scripts/material-detector.js new file mode 100644 index 00000000..5b68c4eb --- /dev/null +++ b/scripts/material-detector.js @@ -0,0 +1,474 @@ +#!/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(); +} diff --git a/scripts/room-fingerprint.js b/scripts/room-fingerprint.js new file mode 100644 index 00000000..097a6e0c --- /dev/null +++ b/scripts/room-fingerprint.js @@ -0,0 +1,480 @@ +#!/usr/bin/env node +/** + * ADR-077: Room Environment Fingerprinting + * + * Clusters CSI feature vectors to identify distinct room states (empty, + * working, sleeping, etc.), tracks transitions, and detects anomalies. + * + * Usage: + * node scripts/room-fingerprint.js --replay data/recordings/overnight-1775217646.csi.jsonl + * node scripts/room-fingerprint.js --port 5006 + * node scripts/room-fingerprint.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: '10000' }, + 'k': { type: 'string', default: '5' }, + 'new-cluster-threshold': { type: 'string', default: '2.0' }, + }, + strict: true, +}); + +const PORT = parseInt(args.port, 10); +const JSON_OUTPUT = args.json; +const INTERVAL_MS = parseInt(args.interval, 10); +const K = parseInt(args.k, 10); +const NEW_CLUSTER_DIST = parseFloat(args['new-cluster-threshold']); + +// --------------------------------------------------------------------------- +// ADR-018 packet constants +// --------------------------------------------------------------------------- +const VITALS_MAGIC = 0xC5110002; +const FEATURE_MAGIC = 0xC5110003; +const FUSED_MAGIC = 0xC5110004; + +// --------------------------------------------------------------------------- +// Online k-means clustering +// --------------------------------------------------------------------------- +class OnlineKMeans { + constructor(maxK, featureDim, newClusterThreshold) { + this.maxK = maxK; + this.dim = featureDim; + this.threshold = newClusterThreshold; + + this.centroids = []; // { center: Float64Array, count: number, label: string } + this.alpha = 0.01; // EMA update rate + } + + _distance(a, b) { + let sum = 0; + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + sum += (a[i] - b[i]) ** 2; + } + return Math.sqrt(sum); + } + + assign(features) { + if (this.centroids.length === 0) { + // First point creates first cluster + this.centroids.push({ + center: Float64Array.from(features), + count: 1, + label: `State-0`, + }); + return { clusterId: 0, distance: 0 }; + } + + // Find nearest centroid + let bestDist = Infinity; + let bestIdx = 0; + for (let i = 0; i < this.centroids.length; i++) { + const d = this._distance(features, this.centroids[i].center); + if (d < bestDist) { + bestDist = d; + bestIdx = i; + } + } + + // If too far from any cluster, create new one (up to maxK) + if (bestDist > this.threshold && this.centroids.length < this.maxK) { + const newIdx = this.centroids.length; + this.centroids.push({ + center: Float64Array.from(features), + count: 1, + label: `State-${newIdx}`, + }); + return { clusterId: newIdx, distance: 0 }; + } + + // Update centroid via EMA + const c = this.centroids[bestIdx]; + c.count++; + for (let i = 0; i < this.dim; i++) { + c.center[i] = c.center[i] * (1 - this.alpha) + features[i] * this.alpha; + } + + return { clusterId: bestIdx, distance: bestDist }; + } + + labelClusters(clusterMotion) { + // Sort clusters by average motion to assign labels + const sorted = Object.entries(clusterMotion) + .sort((a, b) => a[1] - b[1]); + + const labels = ['sleeping/empty', 'resting', 'working', 'active', 'highly active']; + for (let i = 0; i < sorted.length; i++) { + const clusterId = parseInt(sorted[i][0], 10); + if (clusterId < this.centroids.length) { + this.centroids[clusterId].label = labels[Math.min(i, labels.length - 1)]; + } + } + } +} + +// --------------------------------------------------------------------------- +// Room state tracker +// --------------------------------------------------------------------------- +class RoomFingerprinter { + constructor(maxK, featureDim, newClusterThreshold) { + this.kmeans = new OnlineKMeans(maxK, featureDim, newClusterThreshold); + this.featureDim = featureDim; + + // State tracking + this.currentState = null; + this.stateHistory = []; // { timestamp, clusterId, label, distance } + this.transitions = {}; // "from->to" -> count + + // Vitals correlation + this.clusterMotionSum = {}; // clusterId -> sum + this.clusterMotionCount = {}; // clusterId -> count + + // Feature buffer (latest per node) + this.latestFeatures = new Map(); // nodeId -> { timestamp, features } + this.latestVitals = new Map(); // nodeId -> { timestamp, motion, presence } + + this.startTime = null; + } + + pushFeature(timestamp, nodeId, features) { + if (!this.startTime) this.startTime = timestamp; + this.latestFeatures.set(nodeId, { timestamp, features }); + } + + pushVitals(timestamp, nodeId, motion, presence) { + this.latestVitals.set(nodeId, { timestamp, motion, presence }); + } + + analyze(timestamp) { + // Find latest feature vector (prefer most recent node) + let bestFeature = null; + let bestTs = 0; + for (const [, entry] of this.latestFeatures) { + if (entry.timestamp > bestTs) { + bestTs = entry.timestamp; + bestFeature = entry.features; + } + } + + if (!bestFeature || bestFeature.length < this.featureDim) return null; + + // Truncate or pad to featureDim + const features = new Float64Array(this.featureDim); + for (let i = 0; i < this.featureDim && i < bestFeature.length; i++) { + features[i] = bestFeature[i]; + } + + // Assign to cluster + const { clusterId, distance } = this.kmeans.assign(features); + + // Track motion per cluster for labeling + let avgMotion = 0; + let motionCount = 0; + for (const [, v] of this.latestVitals) { + avgMotion += v.motion; + motionCount++; + } + avgMotion = motionCount > 0 ? avgMotion / motionCount : 0; + + this.clusterMotionSum[clusterId] = (this.clusterMotionSum[clusterId] || 0) + avgMotion; + this.clusterMotionCount[clusterId] = (this.clusterMotionCount[clusterId] || 0) + 1; + + // Update labels periodically + const clusterMotion = {}; + for (const id of Object.keys(this.clusterMotionCount)) { + clusterMotion[id] = this.clusterMotionSum[id] / this.clusterMotionCount[id]; + } + this.kmeans.labelClusters(clusterMotion); + + const label = this.kmeans.centroids[clusterId] + ? this.kmeans.centroids[clusterId].label + : `State-${clusterId}`; + + // Track transitions + if (this.currentState !== null && this.currentState !== clusterId) { + const key = `${this.currentState}->${clusterId}`; + this.transitions[key] = (this.transitions[key] || 0) + 1; + } + const prevState = this.currentState; + this.currentState = clusterId; + + const entry = { + timestamp, + clusterId, + label, + distance: +distance.toFixed(4), + motion: +avgMotion.toFixed(3), + transitioned: prevState !== null && prevState !== clusterId, + prevState: prevState !== null ? prevState : undefined, + totalClusters: this.kmeans.centroids.length, + }; + + this.stateHistory.push(entry); + return entry; + } + + anomalyScore() { + // Anomaly = current state is rarely seen at this time-of-day + if (this.stateHistory.length < 10) return 0; + + const currentCluster = this.currentState; + const recentCount = this.stateHistory.slice(-20).filter(e => e.clusterId === currentCluster).length; + return 1 - (recentCount / 20); // low count = high anomaly + } + + renderTimeline(width) { + const w = width || 60; + if (this.stateHistory.length === 0) return 'No data yet.'; + + const step = Math.max(1, Math.floor(this.stateHistory.length / w)); + const chars = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588'; + + let line = ''; + for (let i = 0; i < this.stateHistory.length; i += step) { + const cid = this.stateHistory[i].clusterId; + line += chars[Math.min(cid, chars.length - 1)]; + } + + return `State timeline: ${line}`; + } + + renderTransitionMatrix() { + if (Object.keys(this.transitions).length === 0) return 'No transitions yet.'; + + const lines = ['Transition matrix:']; + for (const [key, count] of Object.entries(this.transitions).sort((a, b) => b[1] - a[1])) { + const [from, to] = key.split('->'); + const fromLabel = this.kmeans.centroids[parseInt(from, 10)]?.label || `State-${from}`; + const toLabel = this.kmeans.centroids[parseInt(to, 10)]?.label || `State-${to}`; + lines.push(` ${fromLabel} -> ${toLabel}: ${count}`); + } + return lines.join('\n'); + } +} + +// --------------------------------------------------------------------------- +// Packet parsing +// --------------------------------------------------------------------------- +function parseFeatureJsonl(record) { + if (record.type !== 'feature' || !record.features) return null; + return { + timestamp: record.timestamp, + nodeId: record.node_id, + features: record.features, + }; +} + +function parseVitalsJsonl(record) { + if (record.type !== 'vitals') return null; + return { + timestamp: record.timestamp, + nodeId: record.node_id, + motion: record.motion_energy || 0, + presence: record.presence_score || 0, + }; +} + +function parseFeatureUdp(buf) { + if (buf.length < 48) return null; + const magic = buf.readUInt32LE(0); + if (magic !== FEATURE_MAGIC) return null; + + const nodeId = buf.readUInt8(4); + const features = []; + for (let i = 0; i < 8; i++) { + features.push(buf.readFloatLE(12 + i * 4)); + } + return { timestamp: Date.now() / 1000, nodeId, features }; +} + +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), + presence: buf.readFloatLE(20), + }; +} + +// --------------------------------------------------------------------------- +// Replay mode +// --------------------------------------------------------------------------- +async function startReplay(filePath) { + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + + const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST); + const rl = readline.createInterface({ + input: fs.createReadStream(filePath), + crlfDelay: Infinity, + }); + + let featureCount = 0; + 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 feat = parseFeatureJsonl(record); + if (feat) { + fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features); + featureCount++; + } + + const vit = parseVitalsJsonl(record); + if (vit) { + fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence); + vitalsCount++; + } + + const ts = feat || vit; + if (!ts) continue; + + const tsMs = ts.timestamp * 1000; + if (lastAnalysisTs === 0) lastAnalysisTs = tsMs; + + if (tsMs - lastAnalysisTs >= INTERVAL_MS) { + const result = fingerprinter.analyze(ts.timestamp); + + if (result) { + if (JSON_OUTPUT) { + console.log(JSON.stringify(result)); + } else { + const tsStr = new Date(ts.timestamp * 1000).toISOString().slice(11, 19); + const transition = result.transitioned ? ` << TRANSITION from State-${result.prevState}` : ''; + console.log(`[${tsStr}] Cluster ${result.clusterId} (${result.label}) | dist ${result.distance} | motion ${result.motion} | ${result.totalClusters} clusters${transition}`); + } + } + + lastAnalysisTs = tsMs; + } + } + + // Summary + if (!JSON_OUTPUT) { + console.log('\n' + '='.repeat(60)); + console.log('ROOM FINGERPRINT SUMMARY'); + console.log('='.repeat(60)); + + console.log(`\nClusters discovered: ${fingerprinter.kmeans.centroids.length}`); + for (let i = 0; i < fingerprinter.kmeans.centroids.length; i++) { + const c = fingerprinter.kmeans.centroids[i]; + const stateCount = fingerprinter.stateHistory.filter(e => e.clusterId === i).length; + const pct = fingerprinter.stateHistory.length > 0 + ? ((stateCount / fingerprinter.stateHistory.length) * 100).toFixed(1) + : '0'; + const avgMotion = fingerprinter.clusterMotionCount[i] > 0 + ? (fingerprinter.clusterMotionSum[i] / fingerprinter.clusterMotionCount[i]).toFixed(2) + : '?'; + console.log(` Cluster ${i} (${c.label}): ${stateCount} windows (${pct}%) | avg motion ${avgMotion} | ${c.count} assignments`); + } + + console.log(''); + console.log(fingerprinter.renderTimeline(60)); + console.log(''); + console.log(fingerprinter.renderTransitionMatrix()); + + const anomaly = fingerprinter.anomalyScore(); + console.log(`\nCurrent anomaly score: ${anomaly.toFixed(3)}`); + console.log(`Processed: ${featureCount} feature packets, ${vitalsCount} vitals packets`); + } else { + console.log(JSON.stringify({ + type: 'summary', + clusters: fingerprinter.kmeans.centroids.length, + windows: fingerprinter.stateHistory.length, + transitions: Object.keys(fingerprinter.transitions).length, + anomaly: +fingerprinter.anomalyScore().toFixed(3), + })); + } +} + +// --------------------------------------------------------------------------- +// Live UDP mode +// --------------------------------------------------------------------------- +function startLive() { + const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST); + const server = dgram.createSocket('udp4'); + + server.on('message', (buf) => { + if (buf.length < 4) return; + const magic = buf.readUInt32LE(0); + + if (magic === FEATURE_MAGIC) { + const feat = parseFeatureUdp(buf); + if (feat) fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features); + } + if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) { + const vit = parseVitalsUdp(buf); + if (vit) fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence); + } + }); + + setInterval(() => { + const result = fingerprinter.analyze(Date.now() / 1000); + + if (JSON_OUTPUT) { + if (result) console.log(JSON.stringify(result)); + } else { + process.stdout.write('\x1B[2J\x1B[H'); + console.log('=== ROOM FINGERPRINT (ADR-077) ===\n'); + + if (result) { + console.log(`Current state: Cluster ${result.clusterId} (${result.label})`); + console.log(`Distance: ${result.distance} | Motion: ${result.motion}`); + console.log(`Clusters: ${result.totalClusters}`); + if (result.transitioned) { + console.log(`** STATE TRANSITION from State-${result.prevState} **`); + } + } else { + console.log('Collecting data...'); + } + + console.log(''); + console.log(fingerprinter.renderTimeline(50)); + console.log(''); + console.log(fingerprinter.renderTransitionMatrix()); + console.log(`\nAnomaly score: ${fingerprinter.anomalyScore().toFixed(3)}`); + } + }, INTERVAL_MS); + + server.bind(PORT, () => { + if (!JSON_OUTPUT) { + console.log(`Room Fingerprint listening on UDP :${PORT} (k=${K})`); + } + }); + + process.on('SIGINT', () => { server.close(); process.exit(0); }); +} + +// --------------------------------------------------------------------------- +// Entry +// --------------------------------------------------------------------------- +if (args.replay) { + startReplay(args.replay); +} else { + startLive(); +} diff --git a/scripts/sleep-monitor.js b/scripts/sleep-monitor.js new file mode 100644 index 00000000..6d23f512 --- /dev/null +++ b/scripts/sleep-monitor.js @@ -0,0 +1,447 @@ +#!/usr/bin/env node +/** + * ADR-077: Sleep Quality Monitor — CSI-based sleep staging + * + * Classifies sleep stages from breathing rate + heart rate + motion energy + * using 5-minute sliding windows. Produces a hypnogram and summary stats. + * + * DISCLAIMER: This is a consumer-grade informational tool, NOT a medical device. + * Do not use for clinical diagnosis. Consult a physician for sleep concerns. + * + * Usage: + * node scripts/sleep-monitor.js --replay data/recordings/overnight-1775217646.csi.jsonl + * node scripts/sleep-monitor.js --port 5006 + * node scripts/sleep-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); // default 5 min = 300s + +// --------------------------------------------------------------------------- +// ADR-018 packet constants +// --------------------------------------------------------------------------- +const VITALS_MAGIC = 0xC5110002; +const FUSED_MAGIC = 0xC5110004; + +// --------------------------------------------------------------------------- +// Sleep stage thresholds +// --------------------------------------------------------------------------- +const STAGES = { AWAKE: 'Awake', LIGHT: 'Light', REM: 'REM', DEEP: 'Deep' }; +const STAGE_CHARS = { Awake: 'W', Light: 'L', REM: 'R', Deep: 'D' }; +const STAGE_BARS = { Awake: '\u2581', Light: '\u2583', REM: '\u2585', Deep: '\u2588' }; + +// --------------------------------------------------------------------------- +// Vitals buffer +// --------------------------------------------------------------------------- +class VitalsBuffer { + constructor(maxAgeSec) { + this.maxAgeSec = maxAgeSec; + this.samples = []; // { timestamp, br, hr, motion } + } + + push(timestamp, br, hr, motion) { + this.samples.push({ timestamp, br, hr, motion }); + this._prune(timestamp); + } + + _prune(now) { + const cutoff = now - this.maxAgeSec; + while (this.samples.length > 0 && this.samples[0].timestamp < cutoff) { + this.samples.shift(); + } + } + + get length() { return this.samples.length; } + + stats() { + const n = this.samples.length; + if (n < 3) return null; + + let brSum = 0, hrSum = 0, motionSum = 0; + for (const s of this.samples) { + brSum += s.br; + hrSum += s.hr; + motionSum += s.motion; + } + const brMean = brSum / n; + const hrMean = hrSum / n; + const motionMean = motionSum / n; + + // BR variance + let brVar = 0; + for (const s of this.samples) { + brVar += (s.br - brMean) ** 2; + } + brVar /= (n - 1); + + // HR coefficient of variation + let hrVar = 0; + for (const s of this.samples) { + hrVar += (s.hr - hrMean) ** 2; + } + hrVar /= (n - 1); + const hrCV = hrMean > 0 ? Math.sqrt(hrVar) / hrMean : 0; + + return { brMean, brVar, hrMean, hrCV, motionMean, n }; + } + + classify() { + const s = this.stats(); + if (!s) return null; + + // High motion => Awake + if (s.motionMean > 5.0 || s.brMean > 25 || s.brMean < 3) { + return { stage: STAGES.AWAKE, ...s }; + } + + // REM: irregular breathing (high variance), HR elevated + if (s.brVar > 8.0 && s.brMean >= 15 && s.brMean <= 25) { + return { stage: STAGES.REM, ...s }; + } + + // Deep: low BR, very regular + if (s.brMean >= 6 && s.brMean <= 14 && s.brVar < 2.0 && s.motionMean < 2.0) { + return { stage: STAGES.DEEP, ...s }; + } + + // Light: moderate BR and variance + if (s.brMean >= 10 && s.brMean <= 20 && s.motionMean < 4.0) { + return { stage: STAGES.LIGHT, ...s }; + } + + // Default to Awake + return { stage: STAGES.AWAKE, ...s }; + } +} + +// --------------------------------------------------------------------------- +// Sleep session tracker +// --------------------------------------------------------------------------- +class SleepSession { + constructor(windowSec) { + this.windowSec = windowSec; + this.buffers = new Map(); // nodeId -> VitalsBuffer + this.hypnogram = []; // { timestamp, stage, stats } + this.startTime = null; + this.lastTime = null; + } + + ingest(timestamp, nodeId, br, hr, motion) { + if (!this.startTime) this.startTime = timestamp; + this.lastTime = timestamp; + + if (!this.buffers.has(nodeId)) { + this.buffers.set(nodeId, new VitalsBuffer(this.windowSec)); + } + this.buffers.get(nodeId).push(timestamp, br, hr, motion); + } + + analyze(timestamp) { + // Merge stats from all nodes (take the one with most samples) + let bestResult = null; + let bestCount = 0; + for (const [, buf] of this.buffers) { + const result = buf.classify(); + if (result && result.n > bestCount) { + bestResult = result; + bestCount = result.n; + } + } + + if (bestResult) { + this.hypnogram.push({ timestamp, ...bestResult }); + } + return bestResult; + } + + summary() { + if (this.hypnogram.length === 0) return null; + + const counts = { Awake: 0, Light: 0, REM: 0, Deep: 0 }; + for (const entry of this.hypnogram) { + counts[entry.stage]++; + } + const total = this.hypnogram.length; + const sleepEntries = total - counts.Awake; + const durationSec = this.lastTime - this.startTime; + const durationMin = durationSec / 60; + + return { + totalRecordedMin: durationMin, + totalSleepMin: (sleepEntries / total) * durationMin, + sleepEfficiency: total > 0 ? ((sleepEntries / total) * 100) : 0, + stageMinutes: { + Awake: (counts.Awake / total) * durationMin, + Light: (counts.Light / total) * durationMin, + REM: (counts.REM / total) * durationMin, + Deep: (counts.Deep / total) * durationMin, + }, + stagePercent: { + Awake: total > 0 ? ((counts.Awake / total) * 100) : 0, + Light: total > 0 ? ((counts.Light / total) * 100) : 0, + REM: total > 0 ? ((counts.REM / total) * 100) : 0, + Deep: total > 0 ? ((counts.Deep / total) * 100) : 0, + }, + entries: total, + }; + } + + renderHypnogram(width) { + if (this.hypnogram.length === 0) return 'No data yet.'; + + const w = width || 60; + const step = Math.max(1, Math.floor(this.hypnogram.length / w)); + let bars = ''; + let labels = ''; + for (let i = 0; i < this.hypnogram.length; i += step) { + const entry = this.hypnogram[i]; + bars += STAGE_BARS[entry.stage] || ' '; + labels += STAGE_CHARS[entry.stage] || '?'; + } + + const lines = []; + lines.push('Hypnogram:'); + lines.push(` ${bars}`); + lines.push(` ${labels}`); + lines.push(' W=Awake L=Light R=REM D=Deep'); + return lines.join('\n'); + } +} + +// --------------------------------------------------------------------------- +// Packet parsing (from JSONL or UDP) +// --------------------------------------------------------------------------- +function parseVitalsJsonl(record) { + if (record.type !== 'vitals') return null; + return { + timestamp: record.timestamp, + nodeId: record.node_id, + br: record.breathing_bpm || 0, + 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), + br: buf.readUInt16LE(6) / 100, + hr: buf.readUInt32LE(8) / 10000, + motion: buf.readFloatLE(16), + }; +} + +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- +function renderLive(session, latest) { + const lines = []; + lines.push('=== SLEEP QUALITY MONITOR (ADR-077) ==='); + lines.push('DISCLAIMER: Informational only. Not a medical device.'); + lines.push(''); + + if (latest) { + lines.push(`Current stage: ${latest.stage}`); + lines.push(` BR: ${latest.brMean.toFixed(1)} BPM (var ${latest.brVar.toFixed(2)})`); + lines.push(` HR: ${latest.hrMean.toFixed(1)} BPM (CV ${(latest.hrCV * 100).toFixed(1)}%)`); + lines.push(` Motion: ${latest.motionMean.toFixed(2)}`); + lines.push(` Window: ${latest.n} samples`); + } else { + lines.push('Collecting data...'); + } + + lines.push(''); + lines.push(session.renderHypnogram(60)); + + const sum = session.summary(); + if (sum) { + lines.push(''); + lines.push(`Duration: ${sum.totalRecordedMin.toFixed(1)} min | Sleep: ${sum.totalSleepMin.toFixed(1)} min | Efficiency: ${sum.sleepEfficiency.toFixed(1)}%`); + lines.push(` Deep: ${sum.stagePercent.Deep.toFixed(1)}% | Light: ${sum.stagePercent.Light.toFixed(1)}% | REM: ${sum.stagePercent.REM.toFixed(1)}% | Awake: ${sum.stagePercent.Awake.toFixed(1)}%`); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Replay mode +// --------------------------------------------------------------------------- +async function startReplay(filePath) { + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + + const session = new SleepSession(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; + + session.ingest(v.timestamp, v.nodeId, v.br, v.hr, v.motion); + vitalsCount++; + + // Analyze every INTERVAL_MS worth of time + const tsMs = v.timestamp * 1000; + if (lastAnalysisTs === 0) lastAnalysisTs = tsMs; + + if (tsMs - lastAnalysisTs >= INTERVAL_MS) { + const result = session.analyze(v.timestamp); + + if (JSON_OUTPUT) { + if (result) { + console.log(JSON.stringify({ + timestamp: v.timestamp, + stage: result.stage, + br_mean: +result.brMean.toFixed(2), + br_var: +result.brVar.toFixed(3), + hr_mean: +result.hrMean.toFixed(2), + hr_cv: +result.hrCV.toFixed(4), + motion_mean: +result.motionMean.toFixed(3), + })); + } + } else if (result) { + const ts = new Date(v.timestamp * 1000).toISOString().slice(11, 19); + console.log(`[${ts}] ${result.stage.padEnd(5)} | BR ${result.brMean.toFixed(1)} (var ${result.brVar.toFixed(2)}) | HR ${result.hrMean.toFixed(1)} | Motion ${result.motionMean.toFixed(2)}`); + } + + lastAnalysisTs = tsMs; + } + } + + // Final summary + if (!JSON_OUTPUT) { + console.log('\n' + '='.repeat(60)); + console.log('SLEEP SESSION SUMMARY'); + console.log('='.repeat(60)); + console.log(session.renderHypnogram(60)); + + const sum = session.summary(); + if (sum) { + console.log(''); + console.log(`Total recorded: ${sum.totalRecordedMin.toFixed(1)} min`); + console.log(`Total sleep: ${sum.totalSleepMin.toFixed(1)} min`); + console.log(`Efficiency: ${sum.sleepEfficiency.toFixed(1)}%`); + console.log(`Entries: ${sum.entries} analysis windows`); + console.log(''); + console.log('Stage breakdown:'); + for (const stage of ['Deep', 'Light', 'REM', 'Awake']) { + const pct = sum.stagePercent[stage].toFixed(1); + const min = sum.stageMinutes[stage].toFixed(1); + const bar = '\u2588'.repeat(Math.round(sum.stagePercent[stage] / 2)); + console.log(` ${stage.padEnd(6)} ${bar.padEnd(50)} ${pct}% (${min} min)`); + } + } + + console.log(`\nProcessed ${vitalsCount} vitals packets`); + } else { + const sum = session.summary(); + if (sum) { + console.log(JSON.stringify({ type: 'summary', ...sum })); + } + } +} + +// --------------------------------------------------------------------------- +// Live UDP mode +// --------------------------------------------------------------------------- +function startLive() { + const session = new SleepSession(WINDOW_SEC); + const server = dgram.createSocket('udp4'); + + server.on('message', (buf) => { + const v = parseVitalsUdp(buf); + if (v) { + session.ingest(v.timestamp, v.nodeId, v.br, v.hr, v.motion); + } + }); + + setInterval(() => { + const result = session.analyze(Date.now() / 1000); + + if (JSON_OUTPUT) { + if (result) { + console.log(JSON.stringify({ + timestamp: Date.now() / 1000, + stage: result.stage, + br_mean: +result.brMean.toFixed(2), + br_var: +result.brVar.toFixed(3), + hr_mean: +result.hrMean.toFixed(2), + motion_mean: +result.motionMean.toFixed(3), + })); + } + } else { + process.stdout.write('\x1B[2J\x1B[H'); + process.stdout.write(renderLive(session, result) + '\n'); + } + }, INTERVAL_MS); + + server.bind(PORT, () => { + if (!JSON_OUTPUT) { + console.log(`Sleep Monitor listening on UDP :${PORT} (window ${WINDOW_SEC}s)`); + console.log('DISCLAIMER: Informational only. Not a medical device.\n'); + } + }); + + process.on('SIGINT', () => { + if (!JSON_OUTPUT) { + console.log('\n' + '='.repeat(60)); + const sum = session.summary(); + if (sum) { + console.log(`Session: ${sum.totalRecordedMin.toFixed(1)} min | Sleep: ${sum.totalSleepMin.toFixed(1)} min | Efficiency: ${sum.sleepEfficiency.toFixed(1)}%`); + } + } + server.close(); + process.exit(0); + }); +} + +// --------------------------------------------------------------------------- +// Entry +// --------------------------------------------------------------------------- +if (args.replay) { + startReplay(args.replay); +} else { + startLive(); +} diff --git a/scripts/stress-monitor.js b/scripts/stress-monitor.js new file mode 100644 index 00000000..336750d4 --- /dev/null +++ b/scripts/stress-monitor.js @@ -0,0 +1,414 @@ +#!/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(); +}