/** * Station specifications and per-station physics. * * A "spec" is static metadata: identity, nominal cycle time, and the signal * definitions (units, ranges, alarm thresholds) that the UI renders generically. * A "station" is the mutable runtime object created from a spec. * * Physics here is deliberately first-order: lag responses, a PID on the oven, * and accumulating wear. It is not a CFD model. What matters for the demo is * that signals move the way an engineer expects them to move, and that they are * coupled - vibration drives tool wear drives reject rate drives OEE. */ /** First-order lag toward a target. tau in seconds. */ export function lag(current, target, tau, dt) { return current + (target - current) * (1 - Math.exp(-dt / tau)); } export function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } /** Deterministic PRNG so every demo run is reproducible. */ export function makeRng(seed = 0x5eed) { let a = seed >>> 0; return function rng() { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Zero-centred noise within +/- amp. */ function noise(rng, amp) { return (rng() * 2 - 1) * amp; } export const STATE = { RUNNING: 'running', STARVED: 'starved', BLOCKED: 'blocked', /** Brief stall of seconds. Conventionally an OEE *performance* loss. */ MICROSTOP: 'microstop', /** Unplanned stop of tens of seconds. An OEE *availability* loss. */ DOWN: 'down', /** Operator-injected fault from the what-if panel. */ FAULT: 'fault', IDLE: 'idle', }; /** States in which the station is not producing. */ export const STOPPED_STATES = new Set([STATE.MICROSTOP, STATE.DOWN, STATE.FAULT, STATE.IDLE]); /** States that count against Availability rather than Performance. */ export const DOWNTIME_STATES = new Set([STATE.DOWN, STATE.FAULT]); /** Buffer capacity between consecutive stations. Small enough to back up fast. */ export const BUFFER_CAPACITY = 8; /** * Signal spec fields: * key, label, unit, min, max - display and chart scaling * warnHigh/alarmHigh/warnLow/alarmLow - thresholds (all optional) * precision - decimals to render * chart - include in station trend charts * primary - headline signal on the station card * cumulative - monotonically accumulating (wear, counters). * Never anomaly-tested: normal operation drifts * far from any frozen baseline, so a z-score on it * reports growth as a fault. Thresholds and trend * projection are the right tools for these. * volatile - legitimately swings with station state (belt speed * drops to zero on every micro-stop) or is an operator * input rather than a measurement. Also not * anomaly-tested. */ export const STATION_SPECS = [ { id: 'CONV-01', name: 'Infeed Conveyor', kind: 'conveyor', baseCycleTime: 4.0, power: 5.5, signals: [ { key: 'beltSpeed', label: 'Belt Speed', unit: 'm/min', min: 0, max: 20, precision: 1, chart: true, primary: true, volatile: true }, { key: 'motorAmps', label: 'Motor Current', unit: 'A', min: 0, max: 24, warnHigh: 16, alarmHigh: 20, precision: 1, chart: true }, { key: 'infeedQueue', label: 'Infeed Queue', unit: 'pcs', min: 0, max: BUFFER_CAPACITY, precision: 0, chart: true, volatile: true }, ], }, { id: 'CNC-02', name: 'CNC Machining Centre', kind: 'cnc', baseCycleTime: 4.4, power: 22, signals: [ { key: 'vibration', label: 'Bearing Vibration', unit: 'mm/s RMS', min: 0, max: 6, warnHigh: 3.5, alarmHigh: 4.5, precision: 2, chart: true, primary: true }, { key: 'spindleLoad', label: 'Spindle Load', unit: '%', min: 0, max: 100, warnHigh: 85, alarmHigh: 95, precision: 1, chart: true }, { key: 'spindleRpm', label: 'Spindle Speed', unit: 'rpm', min: 0, max: 10000, precision: 0, chart: true, volatile: true }, { key: 'coolantTemp', label: 'Coolant Temp', unit: '°C', min: 15, max: 80, warnHigh: 52, alarmHigh: 62, precision: 1, chart: true }, { key: 'toolWear', label: 'Tool Wear', unit: '%', min: 0, max: 100, warnHigh: 75, alarmHigh: 92, precision: 1, chart: true, cumulative: true }, ], }, { id: 'OVN-03', name: 'Curing Oven', kind: 'oven', baseCycleTime: 4.2, power: 85, signals: [ // Range runs to 450 because a saturated burner genuinely overheats the // outer zones when the control zone cannot reach setpoint. { key: 'zone2Temp', label: 'Zone 2 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true, primary: true }, { key: 'zone1Temp', label: 'Zone 1 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true }, { key: 'zone3Temp', label: 'Zone 3 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true }, { key: 'setpoint', label: 'Setpoint', unit: '°C', min: 200, max: 360, precision: 0, chart: false, volatile: true }, { key: 'burnerDuty', label: 'Burner Duty', unit: '%', min: 0, max: 100, warnHigh: 92, precision: 1, chart: true }, { key: 'tempDeviation', label: 'Temp Deviation', unit: '°C', min: -40, max: 40, warnLow: -8, alarmLow: -18, warnHigh: 8, alarmHigh: 18, precision: 1, chart: true }, ], }, { id: 'INS-04', name: 'Vision Inspection', kind: 'inspection', baseCycleTime: 3.6, power: 1.2, signals: [ { key: 'rejectRate', label: 'Reject Rate', unit: '%', min: 0, max: 20, warnHigh: 4, alarmHigh: 8, precision: 2, chart: true, primary: true }, { key: 'cameraConfidence', label: 'Camera Confidence', unit: '%', min: 60, max: 100, warnLow: 90, alarmLow: 80, precision: 1, chart: true }, { key: 'partsInspected', label: 'Parts Inspected', unit: 'pcs', min: 0, max: 100000, precision: 0, chart: false, cumulative: true }, ], }, { id: 'PKG-05', name: 'Packer', kind: 'packer', baseCycleTime: 4.1, power: 4.5, signals: [ { key: 'unitsPerMin', label: 'Output Rate', unit: 'u/min', min: 0, max: 20, warnLow: 8, alarmLow: 4, precision: 1, chart: true, primary: true, volatile: true }, { key: 'filmTension', label: 'Film Tension', unit: 'N', min: 0, max: 80, warnHigh: 58, alarmHigh: 68, warnLow: 26, alarmLow: 16, precision: 1, chart: true }, { key: 'downtime', label: 'Downtime', unit: 's', min: 0, max: 100000, precision: 0, chart: false, cumulative: true }, ], }, ]; /** Look up a signal spec, for thresholds and formatting. */ export function signalSpec(stationId, key) { const s = STATION_SPECS.find((x) => x.id === stationId); return s ? s.signals.find((g) => g.key === key) : undefined; } export function createStation(spec) { const st = { id: spec.id, name: spec.name, kind: spec.kind, baseCycleTime: spec.baseCycleTime, power: spec.power, state: STATE.IDLE, progress: 0, completed: 0, online: true, signals: {}, _integral: 0, _downSec: 0, /** Simulated time at which a micro-stop or unplanned stop ends. */ _stopUntil: -1, _stopKind: null, }; for (const g of spec.signals) st.signals[g.key] = 0; return st; } /** Nominal starting values, so the line does not have to warm up on camera. */ export function seedStation(st) { switch (st.kind) { case 'conveyor': st.signals.beltSpeed = 12; st.signals.motorAmps = 8.2; break; case 'cnc': st.signals.vibration = 1.62; st.signals.spindleLoad = 62; st.signals.spindleRpm = 8400; st.signals.coolantTemp = 34; st.signals.toolWear = 18; break; case 'oven': st.signals.setpoint = 305; st.signals.zone1Temp = 303; st.signals.zone2Temp = 305; st.signals.zone3Temp = 301; st.signals.burnerDuty = 68; st.signals.tempDeviation = 0; break; case 'inspection': st.signals.rejectRate = 1.8; st.signals.cameraConfidence = 98.4; break; case 'packer': st.signals.unitsPerMin = 13.6; st.signals.filmTension = 42; break; } } /** * Advance one station's continuous signals by dt simulated seconds. * * ctx carries the cross-station coupling: line speed factor, active fault * modifiers, the rng, buffer levels, and read access to sibling stations - the * oven deviation feeds the inspection reject rate, for example. */ export function updateSignals(st, ctx, dt) { const { rng, speedFactor, faults } = ctx; const running = st.state === STATE.RUNNING; const s = st.signals; switch (st.kind) { case 'conveyor': { const jam = st.state === STATE.FAULT; const target = jam ? 0 : running ? 12 * speedFactor : 0; s.beltSpeed = clamp(lag(s.beltSpeed, target, 2.5, dt) + noise(rng, 0.05), 0, 20); const loadAmps = 6.4 + s.beltSpeed * 0.16 + ctx.buffers[0] * 0.09; s.motorAmps = clamp(lag(s.motorAmps, jam ? 19.5 : loadAmps, 3, dt) + noise(rng, 0.12), 0, 24); s.infeedQueue = ctx.buffers[0]; break; } case 'cnc': { const rpmTarget = running ? 8400 * speedFactor : 0; s.spindleRpm = clamp(lag(s.spindleRpm, rpmTarget, 3.5, dt) + noise(rng, 12), 0, 10000); // Bearing degradation adds an exponential ramp on top of the wear-driven // baseline. This is the headline signal of the demo. const bearing = faults.bearingVibration || 0; const vibTarget = 1.55 + s.toolWear * 0.006 + bearing + (running ? 0.06 : -0.55); s.vibration = clamp(lag(s.vibration, vibTarget, 6, dt) + noise(rng, 0.035), 0, 6); // A degrading bearing loads the spindle harder for the same cut. const loadTarget = running ? 58 + s.toolWear * 0.18 + bearing * 5.5 + (speedFactor - 1) * 22 : 4; s.spindleLoad = clamp(lag(s.spindleLoad, loadTarget, 4, dt) + noise(rng, 0.5), 0, 100); const coolTarget = 22 + s.spindleLoad * 0.30 + bearing * 2.2; s.coolantTemp = clamp(lag(s.coolantTemp, coolTarget, 45, dt) + noise(rng, 0.08), 15, 80); break; } case 'oven': { // PID on zone 2, the control zone, driving burner duty. const err = s.setpoint - s.zone2Temp; st._integral = clamp(st._integral + err * dt, -900, 900); const duty = clamp(0.85 * err + 0.02 * st._integral + 62, 0, 100); s.burnerDuty = lag(s.burnerDuty, duty, 4, dt); // A burner fault cuts zone 2 heating capacity. The PID saturates trying to // compensate, so zone 2 sags while zones 1 and 3 drift slightly hot. const cap2 = faults.ovenZone2Capacity ?? 1; const heat = (s.burnerDuty / 100) * 420; s.zone1Temp = lag(s.zone1Temp, 20 + heat * 0.99, 55, dt) + noise(rng, 0.10); s.zone2Temp = lag(s.zone2Temp, 20 + heat * cap2, 48, dt) + noise(rng, 0.10); s.zone3Temp = lag(s.zone3Temp, 20 + heat * 0.97, 60, dt) + noise(rng, 0.10); s.tempDeviation = s.zone2Temp - s.setpoint; break; } case 'inspection': { // Sensor dropout: hold the last value rather than fabricating data. if (!st.online) break; // Reject rate is driven, not random. Worn tooling and an out-of-spec cure // both push parts out of tolerance. This is the causal chain the copilot // gets to explain. const wear = ctx.stationById['CNC-02'].signals.toolWear; const wearTerm = Math.pow(wear / 100, 2) * 14; const ovenDev = Math.abs(ctx.stationById['OVN-03'].signals.tempDeviation); const ovenTerm = ovenDev > 6 ? (ovenDev - 6) * 0.42 : 0; const vibTerm = Math.max(0, ctx.stationById['CNC-02'].signals.vibration - 2.6) * 1.1; const target = 1.5 + wearTerm + ovenTerm + vibTerm; s.rejectRate = clamp(lag(s.rejectRate, target, 20, dt) + noise(rng, 0.04), 0, 20); s.cameraConfidence = clamp(lag(s.cameraConfidence, 98.5 - ovenTerm * 0.6, 15, dt) + noise(rng, 0.12), 60, 100); break; } case 'packer': { if (DOWNTIME_STATES.has(st.state)) { if (st.state === STATE.FAULT) { // Film jam: tension spikes as the web binds, then collapses on tear. s.filmTension = lag(s.filmTension, faults.packerJamPhase === 'tear' ? 4 : 74, 1.5, dt); } else { s.filmTension = lag(s.filmTension, 30, 4, dt); } s.unitsPerMin = lag(s.unitsPerMin, 0, 2, dt); st._downSec += dt; } else { s.filmTension = clamp(lag(s.filmTension, 42 + (speedFactor - 1) * 9, 6, dt) + noise(rng, 0.35), 0, 80); // Achieved rate, derived from real completions in line.js. s.unitsPerMin = clamp(lag(s.unitsPerMin, ctx.achievedRate, 8, dt), 0, 20); } s.downtime = st._downSec; break; } } }