feat: ADR-077 — 6 novel RF sensing applications

Sleep monitor (hypnogram + efficiency), apnea detector (AHI scoring),
stress monitor (HRV + LF/HF via FFT), gait analyzer (cadence + tremor),
material detector (null pattern classification), room fingerprint
(k-means clustering + anomaly scoring)

All validated on overnight data (113K frames). Pure Node.js, zero deps.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-04-03 08:50:48 -04:00
parent 085af0c2be
commit 4f6780f884
7 changed files with 3043 additions and 0 deletions

View file

@ -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 <file>` for offline, `--port <N>` 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

410
scripts/apnea-detector.js Normal file
View file

@ -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();
}

534
scripts/gait-analyzer.js Normal file
View file

@ -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();
}

View file

@ -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();
}

480
scripts/room-fingerprint.js Normal file
View file

@ -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();
}

447
scripts/sleep-monitor.js Normal file
View file

@ -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();
}

414
scripts/stress-monitor.js Normal file
View file

@ -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();
}