Initial commit with Dockerfile and demo code
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user