Files
2026-08-24 15:35:32 +05:30

120 lines
4.0 KiB
JavaScript

/**
* The simulated telemetry source: owns the model, the clock, and the analytics.
*
* Real time advances at TICK_MS. Simulated time advances at TICK_MS * speed, so
* the operator can run the plant at 60x and watch a twenty-minute degradation
* play out in twenty seconds. Nothing downstream knows or cares.
*/
import { ProductionLine } from '../sim/line.js';
import { AnalyticsEngine } from '../analytics/alarms.js';
import { TelemetrySource } from './source.js';
/** Real milliseconds between broadcast frames. */
export const TICK_MS = 500;
/** Selectable clock multipliers. 0 is paused. */
export const SPEEDS = [0, 1, 5, 20, 60];
/**
* Simulated seconds to run before serving the first frame.
*
* The demo must open on a plant that has been running, not one that just booted.
* Without this the rolling OEE window contains a few seconds of loss-free data and
* reads a perfect 100%, which is precisely the "obviously fabricated" impression
* the model works hard to avoid; the learned anomaly baselines are also not ready,
* so nothing can be detected for the first several minutes.
*/
export const PREWARM_SEC = 1800;
export class SimulatedSource extends TelemetrySource {
constructor({ seed, prewarmSec } = {}) {
super('simulated');
this.line = new ProductionLine(seed);
this.analytics = new AnalyticsEngine();
this.speed = 1;
this.timer = null;
this.prewarmSec = Number.isFinite(prewarmSec) ? prewarmSec : PREWARM_SEC;
}
get capabilities() {
return { timeControl: true, faultInjection: true, setpointControl: true };
}
async start() {
if (this.timer) return;
if (this.prewarmSec > 0) {
const t0 = Date.now();
const step = (TICK_MS / 1000);
const iterations = Math.floor(this.prewarmSec / step);
// Run through the normal tick path so the replay ring and the analytics
// baselines end up in exactly the state they would reach organically.
for (let i = 0; i < iterations; i++) this.tick(step);
console.log(
`[source] pre-warmed ${(this.prewarmSec / 60).toFixed(0)} simulated minutes in ${Date.now() - t0} ms`,
);
} else {
// At minimum emit one frame so a connecting client has something to render.
this.tick(0);
}
this.timer = setInterval(() => this.tick((TICK_MS / 1000) * this.speed), TICK_MS);
}
async stop() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
tick(simSeconds) {
if (simSeconds > 0) this.line.step(simSeconds);
const snap = this.line.snapshot();
const analytics = this.analytics.update(snap);
this.emit({
...snap,
analytics,
events: this.line.events.slice(-40).reverse(),
sim: {
speed: this.speed,
paused: this.speed === 0,
speeds: SPEEDS,
tickMs: TICK_MS,
source: this.name,
},
});
}
setSpeed(speed) {
const s = Number(speed);
if (!SPEEDS.includes(s)) throw new Error(`Unsupported speed ${speed}. Allowed: ${SPEEDS.join(', ')}`);
this.speed = s;
this.line.logEvent('action', 'LINE-1', s === 0 ? 'Simulation paused.' : `Simulation speed set to ${s}x.`);
return s;
}
setSetpoint(v) { return this.line.setSetpoint(v); }
setLineSpeed(v) { return this.line.setLineSpeed(v); }
injectFault(id) { return this.line.injectFault(id); }
clearFault(id) { return this.line.clearFault(id); }
toolChange() { return this.line.toolChange(); }
reset() {
this.line.reset();
// The learned baselines belong to the old run; keeping them would flag the
// fresh line as anomalous.
this.analytics.reset();
this.replay = [];
// Re-warm, or Reset would leave the dashboard showing a perfect 100% OEE and
// an anomaly detector with nothing learned - worse than before the reset.
if (this.prewarmSec > 0) {
const step = TICK_MS / 1000;
const iterations = Math.floor(this.prewarmSec / step);
for (let i = 0; i < iterations; i++) this.tick(step);
} else {
this.tick(0);
}
}
}