mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
Sleep monitor (hypnogram + efficiency), apnea detector (AHI scoring), stress monitor (HRV + LF/HF via FFT), gait analyzer (cadence + tremor), material detector (null pattern classification), room fingerprint (k-means clustering + anomaly scoring) All validated on overnight data (113K frames). Pure Node.js, zero deps. Co-Authored-By: claude-flow <ruv@ruv.net>
480 lines
16 KiB
JavaScript
480 lines
16 KiB
JavaScript
#!/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();
|
|
}
|