/** * The production line model: discrete part flow over continuous signal physics. * * Stations are connected by finite WIP buffers, so they block and starve each * other. That coupling is what makes the model read as a plant rather than as * five unrelated gauges - stop the packer and the backup propagates upstream * until the whole line is blocked. * * Stations are stepped downstream-first so that a block resolves within a single * sub-step rather than crawling one station per tick. */ import { STATION_SPECS, STATE, BUFFER_CAPACITY, DOWNTIME_STATES, createStation, seedStation, updateSignals, makeRng, clamp, } from './stations.js'; import { computeModifiers, faultProfile } from './faults.js'; import { KpiTracker, instantPower } from './kpi.js'; /** Largest sub-step we will integrate, in simulated seconds. */ export const MAX_SUBSTEP = 0.5; const INSPECTION_INDEX = STATION_SPECS.findIndex((s) => s.kind === 'inspection'); const IDEAL_CYCLE = Math.max(...STATION_SPECS.map((s) => s.baseCycleTime)); /** * Background loss rates, per station, per simulated second. * * Without these the line runs at ~97% OEE, which no plant manager will believe. * Micro-stops (a jammed part, a sensor re-read, an operator intervention) are the * single largest OEE loss in most real factories, and unplanned stops are what * Availability actually measures. Modelling them is more honest than hard-coding * a plausible-looking OEE number. */ const MICRO_STOP_RATE = 1 / 110; const MICRO_STOP_MIN = 4; const MICRO_STOP_SPAN = 12; const UNPLANNED_STOP_RATE = 1 / 20000; const UNPLANNED_STOP_MIN = 25; const UNPLANNED_STOP_SPAN = 55; export class ProductionLine { constructor(seed = 0x5eed) { this.seed = seed; this.reset(); } reset() { // Two independent streams. Signal noise is drawn every sub-step in a fixed // pattern, so sharing one stream with discrete event decisions (reject rolls, // stall decisions) lands those decisions at a correlated phase in the // sequence and measurably biases them - a reject roll against a 2.0% rate was // firing at 5.7%. Keep event randomness on its own stream. this.rng = makeRng(this.seed); this.stallRng = makeRng(this.seed ^ 0x9e3779b9); this.qualityRng = makeRng(this.seed ^ 0x85ebca6b); this.simTime = 0; this.stations = STATION_SPECS.map(createStation); this.stationById = {}; for (const st of this.stations) { seedStation(st); this.stationById[st.id] = st; } this.buffers = new Array(this.stations.length - 1).fill(4); this.activeFaults = new Map(); this.controls = { setpoint: 305, lineSpeedPct: 100 }; this.totals = { produced: 0, good: 0, rejected: 0 }; this.completionTimes = []; this.events = []; this.kpi = new KpiTracker(IDEAL_CYCLE); this.stationById['OVN-03'].signals.setpoint = this.controls.setpoint; this.logEvent('info', 'system', 'Line reset. Running at nominal setpoints.'); } // -- operator actions ----------------------------------------------------- setSetpoint(v) { const value = clamp(Number(v), 200, 360); this.controls.setpoint = value; this.stationById['OVN-03'].signals.setpoint = value; this.logEvent('action', 'OVN-03', `Oven setpoint changed to ${value.toFixed(0)} °C.`); return value; } setLineSpeed(pct) { const value = clamp(Number(pct), 50, 130); this.controls.lineSpeedPct = value; this.logEvent('action', 'LINE-1', `Line speed set to ${value.toFixed(0)}%.`); return value; } injectFault(id) { const profile = faultProfile(id); if (!profile) return false; if (this.activeFaults.has(id)) return true; this.activeFaults.set(id, { injectedAt: this.simTime }); // Logged as 'inject' so the copilot context can filter it out: the copilot // must diagnose from telemetry, not read the answer off an operator log. this.logEvent('inject', profile.station, `Fault injected: ${profile.label}.`); return true; } clearFault(id) { const profile = faultProfile(id); if (!this.activeFaults.delete(id)) return false; // Clearing a bearing fault means the bearing was replaced. if (id === 'bearing-degradation') { this.stationById['CNC-02'].signals.vibration = 1.6; } if (id === 'sensor-dropout') this.stationById['INS-04'].online = true; this.logEvent('inject', profile ? profile.station : 'system', `Fault cleared: ${profile ? profile.label : id}.`); return true; } clearAllFaults() { for (const id of [...this.activeFaults.keys()]) this.clearFault(id); } /** Maintenance intervention: fresh tooling resets wear and its knock-on effects. */ toolChange() { this.stationById['CNC-02'].signals.toolWear = 2; this.logEvent('action', 'CNC-02', 'Tool change completed. Wear counter reset.'); } logEvent(kind, station, message) { this.events.push({ id: `${this.simTime.toFixed(1)}-${this.events.length}`, t: this.simTime, wallT: Date.now(), kind, station, message, }); if (this.events.length > 200) this.events.shift(); } // -- simulation ----------------------------------------------------------- /** Advance the model by dt simulated seconds, sub-stepping for stability. */ step(dt) { let remaining = dt; while (remaining > 1e-6) { const h = Math.min(MAX_SUBSTEP, remaining); this.subStep(h); remaining -= h; } this.kpi.mark(this.simTime); } subStep(dt) { this.simTime += dt; const mods = computeModifiers(this.activeFaults, this.simTime); const speedFactor = this.controls.lineSpeedPct / 100; for (const st of this.stations) { st.online = !mods.offlineStations.has(st.id); } // --- discrete part flow, downstream first --- const last = this.stations.length - 1; for (let i = last; i >= 0; i--) { const st = this.stations[i]; if (mods.faultedStations.has(st.id)) { st.state = STATE.FAULT; continue; } // An in-progress stoppage holds the station regardless of material flow. if (st._stopUntil > this.simTime) { st.state = st._stopKind === 'down' ? STATE.DOWN : STATE.MICROSTOP; continue; } if (st._stopKind) { if (st._stopKind === 'down') { this.logEvent('info', st.id, 'Unplanned stop cleared, station restarted.'); } st._stopKind = null; } const hasInput = i === 0 || this.buffers[i - 1] > 0; const hasRoom = i === last || this.buffers[i] < BUFFER_CAPACITY; if (!hasInput) { st.state = STATE.STARVED; continue; } if (!hasRoom) { st.state = STATE.BLOCKED; continue; } // Only a station that would otherwise be producing can stall. if (this.maybeStall(st, dt)) { st.state = st._stopKind === 'down' ? STATE.DOWN : STATE.MICROSTOP; continue; } st.state = STATE.RUNNING; const cycle = st.baseCycleTime / speedFactor; st.progress += dt / cycle; while (st.progress >= 1 && (i === last || this.buffers[i] < BUFFER_CAPACITY)) { st.progress -= 1; if (i > 0) this.buffers[i - 1] -= 1; st.completed += 1; this.onPartCompleted(i, st); } if (st.progress >= 1) st.progress = 0.999; // output filled mid-completion } // --- continuous signals --- const achievedRate = this.recentRate(60); const ctx = { rng: this.rng, speedFactor, faults: mods, buffers: this.buffers, stationById: this.stationById, achievedRate, }; for (const st of this.stations) updateSignals(st, ctx, dt); // --- KPI accumulation --- // Availability counts only real stoppages. Micro-stops are a performance // loss and deliberately do not count here. const lineUp = !this.stations.some((st) => DOWNTIME_STATES.has(st.state)); this.kpi.accumulate(dt, lineUp, this.totals.produced, this.totals.good, this.totals.rejected); } /** * Decide whether a producing station stalls this sub-step. * * Returns true if a stoppage started. Micro-stops are silent - they are normal * line behaviour, not events worth alarming on. Unplanned stops are logged. */ maybeStall(st, dt) { // A chattering spindle does not only make bad parts, it stalls the cut. This // is what lets the bearing fault show up in Performance as well as Quality, // so OEE moves for a reason an engineer can name. let microRate = MICRO_STOP_RATE; if (st.kind === 'cnc') { microRate *= 1 + Math.max(0, st.signals.vibration - 2.4) * 1.6; } if (this.stallRng() < microRate * dt) { st._stopKind = 'micro'; st._stopUntil = this.simTime + MICRO_STOP_MIN + this.stallRng() * MICRO_STOP_SPAN; return true; } if (this.stallRng() < UNPLANNED_STOP_RATE * dt) { st._stopKind = 'down'; const secs = UNPLANNED_STOP_MIN + this.stallRng() * UNPLANNED_STOP_SPAN; st._stopUntil = this.simTime + secs; this.logEvent('fault', st.id, `Unplanned stop, estimated ${secs.toFixed(0)} s.`); return true; } return false; } /** A part finished at station index i. */ onPartCompleted(i, st) { const lastIndex = this.stations.length - 1; if (st.kind === 'cnc') { // Tooling wears per part, and a rough bearing chews through it faster. const vib = st.signals.vibration; const accel = 1 + Math.max(0, vib - 2.5) * 0.9; st.signals.toolWear = clamp(st.signals.toolWear + 0.012 * accel, 0, 100); } if (i === INSPECTION_INDEX) { st.signals.partsInspected += 1; this.totals.produced += 1; const rejected = this.qualityRng() < st.signals.rejectRate / 100; if (rejected) { this.totals.rejected += 1; return; // scrapped here, never reaches the packer } this.totals.good += 1; this.buffers[i] += 1; return; } if (i === lastIndex) { this.completionTimes.push(this.simTime); if (this.completionTimes.length > 400) this.completionTimes.shift(); return; } this.buffers[i] += 1; } /** Packed units per minute over the trailing window, in simulated time. */ recentRate(windowSec) { const cutoff = this.simTime - windowSec; while (this.completionTimes.length && this.completionTimes[0] < cutoff) { this.completionTimes.shift(); } const span = Math.min(windowSec, this.simTime); if (span < 5) return 0; return (this.completionTimes.length / span) * 60; } // -- output --------------------------------------------------------------- snapshot() { const powerKw = instantPower(this.stations); return { t: this.simTime, wallT: Date.now(), lineId: 'LINE-1', stations: this.stations.map((st) => ({ id: st.id, name: st.name, kind: st.kind, state: st.state, online: st.online, progress: st.progress, completed: st.completed, signals: { ...st.signals }, })), buffers: [...this.buffers], bufferCapacity: BUFFER_CAPACITY, kpi: this.kpi.compute(this.stations, powerKw), faults: [...this.activeFaults.entries()].map(([id, info]) => { const p = faultProfile(id); return { id, label: p.label, short: p.short, severity: p.severity, station: p.station, injectedAt: info.injectedAt, elapsed: this.simTime - info.injectedAt, }; }), controls: { ...this.controls }, totals: { ...this.totals }, }; } } export { STATION_SPECS, IDEAL_CYCLE };