310 lines
13 KiB
JavaScript
310 lines
13 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
}
|
|
}
|