/** * 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; }