346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
/**
|
|
* 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 };
|