Initial commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Injectable fault profiles.
|
||||
*
|
||||
* Each profile turns "time since injection" into a set of physics modifiers that
|
||||
* stations.js and line.js consume. Faults are progressive where a real fault
|
||||
* would be progressive - the bearing does not fail the instant you press the
|
||||
* button, it degrades, which is the whole point of showing trend extrapolation.
|
||||
*/
|
||||
|
||||
export const FAULT_PROFILES = [
|
||||
{
|
||||
id: 'bearing-degradation',
|
||||
station: 'CNC-02',
|
||||
label: 'CNC-02 bearing degradation',
|
||||
short: 'Bearing wear',
|
||||
severity: 'major',
|
||||
headline: true,
|
||||
description:
|
||||
'Spindle bearing begins to spall. Vibration rises exponentially, spindle load creeps up, and out-of-tolerance parts start reaching inspection.',
|
||||
},
|
||||
{
|
||||
id: 'oven-burner',
|
||||
station: 'OVN-03',
|
||||
label: 'OVN-03 zone 2 burner fault',
|
||||
short: 'Burner fault',
|
||||
severity: 'major',
|
||||
description:
|
||||
'Zone 2 loses roughly 40% of its heating capacity. The controller saturates trying to hold setpoint, the cure runs cold, and quality falls downstream.',
|
||||
},
|
||||
{
|
||||
id: 'packer-jam',
|
||||
station: 'PKG-05',
|
||||
label: 'PKG-05 film jam',
|
||||
short: 'Film jam',
|
||||
severity: 'critical',
|
||||
description:
|
||||
'Film binds then tears, stopping the packer. Work in progress backs up through the line and upstream stations block.',
|
||||
},
|
||||
{
|
||||
id: 'sensor-dropout',
|
||||
station: 'INS-04',
|
||||
label: 'INS-04 sensor dropout',
|
||||
short: 'Sensor dropout',
|
||||
severity: 'minor',
|
||||
description:
|
||||
'The inspection station stops reporting. Values go stale rather than to zero, which is what a real dropout looks like and what a twin has to handle honestly.',
|
||||
},
|
||||
];
|
||||
|
||||
export function faultProfile(id) {
|
||||
return FAULT_PROFILES.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
/** Time constant for the bearing ramp, in simulated seconds. */
|
||||
const BEARING_TAU = 420;
|
||||
const BEARING_GAIN = 0.35;
|
||||
const BEARING_CAP = 3.2;
|
||||
|
||||
/**
|
||||
* Collapse the set of active faults into physics modifiers.
|
||||
*
|
||||
* active: Map of faultId -> { injectedAt } in simulated seconds.
|
||||
*/
|
||||
export function computeModifiers(active, simTime) {
|
||||
const mods = {
|
||||
bearingVibration: 0,
|
||||
ovenZone2Capacity: 1,
|
||||
packerJamPhase: null,
|
||||
faultedStations: new Set(),
|
||||
offlineStations: new Set(),
|
||||
};
|
||||
|
||||
for (const [id, info] of active) {
|
||||
const t = Math.max(0, simTime - info.injectedAt);
|
||||
|
||||
switch (id) {
|
||||
case 'bearing-degradation':
|
||||
// Exponential ramp: slow to start, unmistakable once it moves. Crosses
|
||||
// the 3.5 mm/s warn band around 13 simulated minutes.
|
||||
mods.bearingVibration = Math.min(
|
||||
BEARING_CAP,
|
||||
BEARING_GAIN * (Math.exp(t / BEARING_TAU) - 1),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'oven-burner': {
|
||||
// Capacity degrades over the first minute rather than cliff-edging.
|
||||
const frac = Math.min(1, t / 60);
|
||||
mods.ovenZone2Capacity = 1 - 0.38 * frac;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'packer-jam':
|
||||
mods.faultedStations.add('PKG-05');
|
||||
mods.packerJamPhase = t < 8 ? 'bind' : 'tear';
|
||||
break;
|
||||
|
||||
case 'sensor-dropout':
|
||||
mods.offlineStations.add('INS-04');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return mods;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* OEE and line KPI rollups.
|
||||
*
|
||||
* Everything is computed over a rolling window of simulated time so the numbers
|
||||
* actually move during a demo. A cumulative-since-reset OEE barely budges in
|
||||
* twenty minutes, which reads as a broken dashboard.
|
||||
*
|
||||
* OEE = Availability x Performance x Quality, using the standard definitions:
|
||||
* Availability = run time / planned production time
|
||||
* Performance = (total parts x ideal cycle time) / run time
|
||||
* Quality = good parts / total parts
|
||||
*/
|
||||
|
||||
/** Rolling window length, in simulated seconds. */
|
||||
export const KPI_WINDOW_SEC = 1200;
|
||||
|
||||
export class KpiTracker {
|
||||
constructor(idealCycleTime) {
|
||||
this.idealCycleTime = idealCycleTime;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
/** Ring of cumulative counters, so any window is a difference of two samples. */
|
||||
this.samples = [];
|
||||
this.cum = { plannedSec: 0, runSec: 0, total: 0, good: 0, rejected: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate one simulation sub-step.
|
||||
*
|
||||
* lineUp: false while any station is in a fault state (planned time still
|
||||
* accrues, run time does not - that is what Availability measures).
|
||||
*/
|
||||
accumulate(dt, lineUp, producedTotal, producedGood, producedRejected) {
|
||||
this.cum.plannedSec += dt;
|
||||
if (lineUp) this.cum.runSec += dt;
|
||||
this.cum.total = producedTotal;
|
||||
this.cum.good = producedGood;
|
||||
this.cum.rejected = producedRejected;
|
||||
}
|
||||
|
||||
/** Record a window sample. Call once per broadcast tick, not per sub-step. */
|
||||
mark(simTime) {
|
||||
this.samples.push({ t: simTime, ...this.cum });
|
||||
while (this.samples.length > 2 && simTime - this.samples[0].t > KPI_WINDOW_SEC) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute KPIs over the rolling window. */
|
||||
compute(stations, instantPowerKw) {
|
||||
const first = this.samples[0];
|
||||
const last = this.samples[this.samples.length - 1];
|
||||
|
||||
if (!first || !last || last.t - first.t < 1) {
|
||||
return {
|
||||
oee: 0, availability: 0, performance: 0, quality: 0,
|
||||
throughputPerHour: 0, scrapRate: 0, energyKw: instantPowerKw,
|
||||
energyPerUnit: 0, windowSec: 0,
|
||||
produced: this.cum.total, good: this.cum.good, rejected: this.cum.rejected,
|
||||
};
|
||||
}
|
||||
|
||||
const dPlanned = last.plannedSec - first.plannedSec;
|
||||
const dRun = last.runSec - first.runSec;
|
||||
const dTotal = last.total - first.total;
|
||||
const dGood = last.good - first.good;
|
||||
const dRejected = last.rejected - first.rejected;
|
||||
|
||||
const availability = dPlanned > 0 ? dRun / dPlanned : 0;
|
||||
|
||||
// Performance is capped at 100% by definition: the ideal cycle time is the
|
||||
// fastest the line can physically go, so exceeding it is impossible. Draining
|
||||
// a WIP buffer can briefly produce faster than the bottleneck, which would
|
||||
// otherwise show as OEE above 100% and read as a broken dashboard.
|
||||
const performance = dRun > 0 ? Math.min(1, (dTotal * this.idealCycleTime) / dRun) : 0;
|
||||
const quality = dTotal > 0 ? dGood / dTotal : dRun > 0 ? 1 : 0;
|
||||
const oee = availability * performance * quality;
|
||||
|
||||
const hours = dPlanned / 3600;
|
||||
const throughputPerHour = hours > 0 ? dGood / hours : 0;
|
||||
|
||||
return {
|
||||
oee,
|
||||
availability,
|
||||
performance,
|
||||
quality,
|
||||
throughputPerHour,
|
||||
scrapRate: dTotal > 0 ? dRejected / dTotal : 0,
|
||||
energyKw: instantPowerKw,
|
||||
energyPerUnit: throughputPerHour > 0 ? instantPowerKw / throughputPerHour : 0,
|
||||
windowSec: dPlanned,
|
||||
produced: this.cum.total,
|
||||
good: this.cum.good,
|
||||
rejected: this.cum.rejected,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Instantaneous line power draw, kW. */
|
||||
export function instantPower(stations) {
|
||||
let kw = 0;
|
||||
for (const st of stations) {
|
||||
switch (st.kind) {
|
||||
case 'conveyor':
|
||||
kw += st.signals.motorAmps * 0.62;
|
||||
break;
|
||||
case 'cnc':
|
||||
kw += (st.signals.spindleLoad / 100) * st.power;
|
||||
break;
|
||||
case 'oven':
|
||||
// An oven keeps drawing standby heat even when the line is stopped.
|
||||
kw += (st.signals.burnerDuty / 100) * st.power + 6;
|
||||
break;
|
||||
default:
|
||||
kw += st.state === 'running' ? st.power : st.power * 0.2;
|
||||
}
|
||||
}
|
||||
return kw;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user