Initial commit, 90% there
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// ============================================================
|
||||
// ANOMALY DETECTOR
|
||||
// Detects production anomalies in real-time
|
||||
// ============================================================
|
||||
|
||||
const cycle = msg.cycle || {};
|
||||
const kpis = msg.kpis || {};
|
||||
const activeOrder = global.get("activeWorkOrder") || {};
|
||||
|
||||
// Must have active work order to detect anomalies
|
||||
if (!activeOrder.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const theoreticalCycleTime = Number(activeOrder.cycleTime) || 0;
|
||||
const now = Date.now();
|
||||
|
||||
// Get or initialize anomaly tracking state
|
||||
let anomalyState = global.get("anomalyState") || {
|
||||
lastCycleTime: now,
|
||||
activeStoppageEvent: null
|
||||
};
|
||||
|
||||
const detectedAnomalies = [];
|
||||
|
||||
// ============================================================
|
||||
// 1. SLOW CYCLE DETECTION
|
||||
// Trigger: Actual cycle time > 1.5x theoretical
|
||||
// ============================================================
|
||||
if (theoreticalCycleTime > 0) {
|
||||
const timeSinceLastCycle = now - anomalyState.lastCycleTime;
|
||||
const actualCycleTime = timeSinceLastCycle / 1000; // Convert to seconds
|
||||
const threshold = theoreticalCycleTime * 1.5;
|
||||
|
||||
if (actualCycleTime > threshold && anomalyState.lastCycleTime > 0) {
|
||||
const deltaPercent = ((actualCycleTime - theoreticalCycleTime) / theoreticalCycleTime) * 100;
|
||||
|
||||
// Determine severity
|
||||
let severity = 'warning';
|
||||
if (actualCycleTime > theoreticalCycleTime * 2.0) {
|
||||
severity = 'critical'; // 100%+ slower
|
||||
}
|
||||
|
||||
detectedAnomalies.push({
|
||||
anomaly_type: 'slow-cycle',
|
||||
severity: severity,
|
||||
title: `Slow Cycle Detected`,
|
||||
description: `Cycle took ${actualCycleTime.toFixed(1)}s (${deltaPercent.toFixed(0)}% slower than expected ${theoreticalCycleTime}s)`,
|
||||
data: {
|
||||
actual_cycle_time: actualCycleTime,
|
||||
theoretical_cycle_time: theoreticalCycleTime,
|
||||
delta_percent: Math.round(deltaPercent),
|
||||
threshold_multiplier: actualCycleTime / theoreticalCycleTime
|
||||
},
|
||||
kpi_snapshot: {
|
||||
oee: kpis.oee || 0,
|
||||
availability: kpis.availability || 0,
|
||||
performance: kpis.performance || 0,
|
||||
quality: kpis.quality || 0
|
||||
},
|
||||
work_order_id: activeOrder.id,
|
||||
cycle_count: cycle.cycles || 0,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
node.warn(`[ANOMALY] Slow cycle: ${actualCycleTime.toFixed(1)}s (expected ${theoreticalCycleTime}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. PRODUCTION STOPPAGE DETECTION
|
||||
// Trigger: No cycle in > 3x theoretical cycle time
|
||||
// ============================================================
|
||||
if (theoreticalCycleTime > 0) {
|
||||
const timeSinceLastCycle = now - anomalyState.lastCycleTime;
|
||||
const stoppageThreshold = theoreticalCycleTime * 3 * 1000; // Convert to ms
|
||||
|
||||
// If we have an active stoppage event and a new cycle arrived, resolve it
|
||||
if (anomalyState.activeStoppageEvent) {
|
||||
// Cycle resumed - mark stoppage as resolved
|
||||
anomalyState.activeStoppageEvent.resolved_at = now;
|
||||
anomalyState.activeStoppageEvent.auto_resolved = true;
|
||||
anomalyState.activeStoppageEvent.status = 'resolved';
|
||||
|
||||
const stoppageDuration = (now - anomalyState.activeStoppageEvent.timestamp) / 1000;
|
||||
node.warn(`[ANOMALY] Production resumed after ${stoppageDuration.toFixed(0)}s stoppage`);
|
||||
|
||||
// Send resolution event
|
||||
detectedAnomalies.push(anomalyState.activeStoppageEvent);
|
||||
anomalyState.activeStoppageEvent = null;
|
||||
}
|
||||
|
||||
// Check if production has stopped (only if no active stoppage event)
|
||||
if (!anomalyState.activeStoppageEvent && timeSinceLastCycle > stoppageThreshold && anomalyState.lastCycleTime > 0) {
|
||||
const stoppageSeconds = timeSinceLastCycle / 1000;
|
||||
|
||||
// Determine severity
|
||||
let severity = 'warning';
|
||||
if (stoppageSeconds > theoreticalCycleTime * 5) {
|
||||
severity = 'critical'; // Stopped for 5x+ theoretical time
|
||||
}
|
||||
|
||||
const stoppageEvent = {
|
||||
anomaly_type: 'production-stopped',
|
||||
severity: severity,
|
||||
title: `Production Stoppage`,
|
||||
description: `No cycles detected for ${stoppageSeconds.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,
|
||||
data: {
|
||||
stoppage_duration_seconds: Math.round(stoppageSeconds),
|
||||
theoretical_cycle_time: theoreticalCycleTime,
|
||||
last_cycle_timestamp: anomalyState.lastCycleTime,
|
||||
threshold_multiplier: stoppageSeconds / theoreticalCycleTime
|
||||
},
|
||||
kpi_snapshot: {
|
||||
oee: kpis.oee || 0,
|
||||
availability: kpis.availability || 0,
|
||||
performance: kpis.performance || 0,
|
||||
quality: kpis.quality || 0
|
||||
},
|
||||
work_order_id: activeOrder.id,
|
||||
cycle_count: cycle.cycles || 0,
|
||||
timestamp: now,
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
detectedAnomalies.push(stoppageEvent);
|
||||
anomalyState.activeStoppageEvent = stoppageEvent;
|
||||
|
||||
node.warn(`[ANOMALY] Production stopped: ${stoppageSeconds.toFixed(0)}s since last cycle`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last cycle time for next iteration
|
||||
anomalyState.lastCycleTime = now;
|
||||
global.set("anomalyState", anomalyState);
|
||||
|
||||
// ============================================================
|
||||
// OUTPUT
|
||||
// ============================================================
|
||||
if (detectedAnomalies.length > 0) {
|
||||
node.warn(`[ANOMALY DETECTOR] Detected ${detectedAnomalies.length} anomaly/ies`);
|
||||
|
||||
return {
|
||||
topic: "anomaly-detected",
|
||||
payload: detectedAnomalies,
|
||||
originalMsg: msg // Pass through original message for other flows
|
||||
};
|
||||
}
|
||||
|
||||
return null; // No anomalies detected
|
||||
Reference in New Issue
Block a user