Files

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 };