mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
feat: ADR-077 — 6 novel RF sensing applications
Sleep monitor (hypnogram + efficiency), apnea detector (AHI scoring), stress monitor (HRV + LF/HF via FFT), gait analyzer (cadence + tremor), material detector (null pattern classification), room fingerprint (k-means clustering + anomaly scoring) All validated on overnight data (113K frames). Pure Node.js, zero deps. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
085af0c2be
commit
4f6780f884
7 changed files with 3043 additions and 0 deletions
284
docs/adr/ADR-077-novel-rf-sensing-applications.md
Normal file
284
docs/adr/ADR-077-novel-rf-sensing-applications.md
Normal 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
410
scripts/apnea-detector.js
Normal 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
534
scripts/gait-analyzer.js
Normal 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();
|
||||
}
|
||||
474
scripts/material-detector.js
Normal file
474
scripts/material-detector.js
Normal 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
480
scripts/room-fingerprint.js
Normal 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
447
scripts/sleep-monitor.js
Normal 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
414
scripts/stress-monitor.js
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue