Initial commit

This commit is contained in:
2026-08-24 15:35:32 +05:30
commit fcade251a6
51 changed files with 11565 additions and 0 deletions
+309
View File
@@ -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;
}
}
}