// ============================================================ // 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