// ============================================================================ // ENHANCED "Machine cycles" Function - Complete Implementation // Location: flows.json, node ID: 0d023d87a13bf56f // Outputs: 4 (cycle update, state change, state backup, anomaly detection) // // Features Implemented: // - Time tracking (operating time and downtime) // - State backup to database (every 10 cycles) // - Cycle count capping at 100 // - Cycle anomaly detection (Issue 3) // - Session tracking integration // ============================================================================ const current = Number(msg.payload) || 0; // ============================================================================ // SECTION 1: TIME TRACKING (Must run BEFORE any early returns) // ============================================================================ const now = Date.now(); const trackingEnabled = !!global.get("trackingEnabled"); const lastUpdate = global.get("lastUpdateTime") || now; const deltaMs = now - lastUpdate; const deltaSeconds = deltaMs / 1000; // Track last cycle time for anomaly detection const lastCycleTimestamp = global.get("lastCycleTimestamp") || now; const cycleTimeMs = now - lastCycleTimestamp; const cycleTimeSeconds = cycleTimeMs / 1000; // Sanity check: Protect against clock skew (negative delta or >5 min gap) if (deltaSeconds < 0 || deltaSeconds > 300) { node.warn(`[TIME] Abnormal delta: ${deltaSeconds.toFixed(2)}s - clock skew or restart detected, resetting timer`); global.set("lastUpdateTime", now); // Don't accumulate time, but continue processing } else { // Normal delta, accumulate time if tracking is enabled if (trackingEnabled) { // Initialize timing vars if they don't exist (handles restart scenario) if (!global.get("productionStartTime")) { node.warn("[TIME] Production start time missing, initializing now"); global.set("productionStartTime", now); global.set("operatingTime", 0); global.set("downtime", 0); } // Accumulate operating time when machine is running (state 1) if (current === 1) { const opTime = global.get("operatingTime") || 0; global.set("operatingTime", opTime + deltaSeconds); } // Accumulate downtime when machine is stopped (state 0) while tracking else if (current === 0) { const downTime = global.get("downtime") || 0; global.set("downtime", downTime + deltaSeconds); } } global.set("lastUpdateTime", now); } // ============================================================================ // SECTION 2: EXISTING CYCLE COUNTING LOGIC // ============================================================================ let zeroStreak = flow.get("zeroStreak") || 0; zeroStreak = current === 0 ? zeroStreak + 1 : 0; flow.set("zeroStreak", zeroStreak); const prev = flow.get("lastMachineState") ?? 0; flow.set("lastMachineState", current); global.set("machineOnline", true); // force ONLINE for now let productionRunning = !!global.get("productionStarted"); let stateChanged = false; if (current === 1 && !productionRunning) { productionRunning = true; stateChanged = true; } else if (current === 0 && zeroStreak >= 2 && productionRunning) { productionRunning = false; stateChanged = true; } global.set("productionStarted", productionRunning); const stateMsg = stateChanged ? { _mode: "production-state", machineOnline: true, productionStarted: productionRunning } : null; const activeOrder = global.get("activeWorkOrder"); const cavities = Number(global.get("moldActive") || 0); if (!activeOrder || !activeOrder.id || cavities <= 0) { // We still want to pass along any state change even if there's no active WO. return [null, stateMsg, null, null]; } // Check if tracking is enabled (START button clicked) if (!trackingEnabled) { // Cycles are happening but we're not tracking them yet return [null, stateMsg, null, null]; } // only count rising edges (0 -> 1) for production totals if (prev === 1 || current !== 1) { return [null, stateMsg, null, null]; } // ============================================================================ // SECTION 3: CYCLE COUNT WITH CAPPING (Issue 2) // ============================================================================ let cycles = Number(global.get("cycleCount") || 0); // Check if we've reached the 100 cycle cap if (cycles >= 100) { node.warn("[CYCLE CAP] Maximum 100 cycles reached. Prompting for work order completion."); // Create alert message msg._mode = "cycle-cap-reached"; msg.alert = { id: activeOrder.id, sku: activeOrder.sku || "", cycles: cycles, message: "Maximum 100 cycles reached. Please complete the work order or enter scrap parts." }; return [null, msg, null, null]; } cycles = cycles + 1; global.set("cycleCount", cycles); global.set("lastCycleTimestamp", now); // Show warning when approaching cap if (cycles >= 90 && cycles < 100) { node.warn(`[CYCLE WARNING] Approaching cycle cap: ${cycles}/100 cycles completed`); } // ============================================================================ // SECTION 4: CYCLE ANOMALY DETECTION (Issue 3) // ============================================================================ let anomalyMsg = null; // Get theoretical cycle time from work order const theoreticalCycleTime = Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0); if (theoreticalCycleTime > 0 && cycles > 1) { // Skip first cycle (no baseline) // Calculate rolling average (last 10 cycles) let cycleTimeHistory = flow.get("cycleTimeHistory") || []; cycleTimeHistory.push(cycleTimeSeconds); // Keep only last 10 cycles if (cycleTimeHistory.length > 10) { cycleTimeHistory = cycleTimeHistory.slice(-10); } flow.set("cycleTimeHistory", cycleTimeHistory); // Calculate average const avgCycleTime = cycleTimeHistory.reduce((a, b) => a + b, 0) / cycleTimeHistory.length; // Calculate deviation from theoretical time const deviation = ((cycleTimeSeconds - theoreticalCycleTime) / theoreticalCycleTime) * 100; // Flag if deviation is > 20% if (Math.abs(deviation) > 20) { const anomalyType = deviation > 0 ? "slower" : "faster"; node.warn(`[ANOMALY] Cycle ${cycles}: ${cycleTimeSeconds.toFixed(2)}s (expected: ${theoreticalCycleTime}s, deviation: ${deviation.toFixed(1)}%)`); const sessionId = global.get("currentSessionId") || null; anomalyMsg = { _mode: "cycle-anomaly", topic: ` INSERT INTO cycle_anomalies (work_order_id, session_id, cycle_number, expected_time, actual_time, deviation_percent, anomaly_type, timestamp, notes) VALUES ('${activeOrder.id}', ${sessionId ? `'${sessionId}'` : 'NULL'}, ${cycles}, ${theoreticalCycleTime}, ${cycleTimeSeconds.toFixed(2)}, ${deviation.toFixed(2)}, '${anomalyType}', ${now}, 'Automatic detection: ${deviation.toFixed(1)}% deviation from expected'); ` }; } } // ============================================================================ // SECTION 5: GOOD PARTS CALCULATION // ============================================================================ // Calculate good parts: total produced minus accumulated scrap const scrapTotal = Number(activeOrder.scrap) || 0; const totalProduced = cycles * cavities; const produced = totalProduced - scrapTotal; const target = Number(activeOrder.target) || 0; const progress = target > 0 ? Math.min(100, Math.round((produced / target) * 100)) : 0; activeOrder.good = produced; activeOrder.progressPercent = progress; activeOrder.lastUpdateIso = new Date().toISOString(); global.set("activeWorkOrder", activeOrder); // ============================================================================ // SECTION 6: SCRAP PROMPT (Target Reached) // ============================================================================ const promptIssued = global.get("scrapPromptIssuedFor") || null; if (!promptIssued && target > 0 && produced >= target) { node.warn(`[DEBUG] TRIGGERING PROMPT - Target reached!`); global.set("scrapPromptIssuedFor", activeOrder.id); msg._mode = "scrap-prompt"; msg.scrapPrompt = { id: activeOrder.id, sku: activeOrder.sku || "", target, produced }; return [null, msg, null, anomalyMsg]; // bypass the DB update on this cycle } // ============================================================================ // SECTION 7: DATABASE UPDATE MESSAGE // ============================================================================ const dbMsg = { _mode: "cycle", cycle: { id: activeOrder.id, sku: activeOrder.sku || "", target, good: produced, scrap: Number(activeOrder.scrap) || 0, cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0), progressPercent: progress, lastUpdateIso: activeOrder.lastUpdateIso, machineOnline: true, productionStarted: productionRunning }, topic: ` UPDATE work_orders SET good_parts = ${produced}, progress_percent = ${progress}, updated_at = NOW() WHERE work_order_id = '${activeOrder.id}'; ` }; // ============================================================================ // SECTION 8: STATE BACKUP TO DATABASE (Every 10th cycle - Issue 1) // ============================================================================ let cyclesSinceBackup = flow.get("cyclesSinceBackup") || 0; cyclesSinceBackup++; flow.set("cyclesSinceBackup", cyclesSinceBackup); let stateBackupMsg = null; if (cyclesSinceBackup >= 10) { // Reset counter flow.set("cyclesSinceBackup", 0); // Backup current state to database const productionStartTime = global.get("productionStartTime") || null; const operatingTime = global.get("operatingTime") || 0; const downtime = global.get("downtime") || 0; const lastUpdateTime = global.get("lastUpdateTime") || null; const scrapPromptIssuedFor = global.get("scrapPromptIssuedFor") || null; const currentSessionId = global.get("currentSessionId") || null; stateBackupMsg = { _mode: "state-backup", topic: ` UPDATE session_state SET work_order_id = '${activeOrder.id}', cycle_count = ${cycles}, production_start_time = ${productionStartTime}, operating_time = ${operatingTime.toFixed(2)}, downtime = ${downtime.toFixed(2)}, last_update_time = ${lastUpdateTime}, tracking_enabled = ${trackingEnabled ? 1 : 0}, machine_state = ${current}, scrap_prompt_issued_for = ${scrapPromptIssuedFor ? `'${scrapPromptIssuedFor}'` : 'NULL'}, current_session_id = ${currentSessionId ? `'${currentSessionId}'` : 'NULL'} WHERE session_key = 'current_session'; ` }; node.warn(`[STATE BACKUP] Saved state to database - Cycle ${cycles}`); } // ============================================================================ // OUTPUTS: // Output 1: Database update for work_orders table // Output 2: State message to UI / Scrap prompt / Cycle cap alert // Output 3: State backup to session_state table (every 10 cycles) // Output 4: Anomaly detection to cycle_anomalies table // ============================================================================ return [dbMsg, stateMsg, stateBackupMsg, anomalyMsg];