151 lines
5.7 KiB
Plaintext
151 lines
5.7 KiB
Plaintext
// ============================================================
|
|
// 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
|