296 lines
11 KiB
Plaintext
296 lines
11 KiB
Plaintext
// ============================================================================
|
|
// 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];
|