Files
Digitaltwin/server/sim/kpi.js
T

122 lines
4.0 KiB
JavaScript

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