mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user