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
+105
View File
@@ -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;
}
+121
View File
@@ -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;
}
+345
View File
@@ -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 };
+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;
}
}
}