// Safety controller for the pumping-station basin. // // Two hard rules, applied independently every tick: // // 1. DRY-RUN (volume below minVol while draining): pumps must stop. // Shuts down all DOWNSTREAM machines + machine groups + child // stations. Sets blocked=true so the orchestrator skips control // logic — only a manual override or estop can restart pumps. // // 2. OVERFILL (volume above overflow level while filling): pumps must // keep running. Shuts down UPSTREAM equipment only (stop more water // coming in) and child stations. Does NOT touch machine groups or // downstream pumps — they must keep draining. blocked stays false // so level-based control keeps demanding maximum throughput. // // A third path: if no volume reading is available, panic — shut down // every machine and block control. function pickVariant(measurements, type, variants, position, unit) { for (const variant of variants) { const v = measurements.type(type).variant(variant).position(position).getCurrentValue(unit); if (Number.isFinite(v)) return v; } return null; } class SafetyController { /** * @param {object} ctx * @param {object} ctx.measurements MeasurementContainer-like instance * @param {object} ctx.basin BasinGeometry snapshot ({maxVolAtOverflow, minVol, ...}) * @param {object} ctx.config pumpingStation config (uses .safety subtree) * @param {object} ctx.logger generalFunctions logger * @param {object} ctx.machines map of childId → rotatingMachine * @param {object} ctx.stations map of childId → child pumpingStation * @param {object} ctx.machineGroups map of childId → machineGroupControl * @param {string[]} [ctx.volVariants] order of volume variants to try */ constructor(ctx) { this.ctx = ctx; this.volVariants = ctx.volVariants || ['measured', 'predicted']; } /** * Run the dry-run + overfill rules against the current measurement state. * * @param {object} flowSnapshot { direction: 'filling'|'draining'|'steady', * secondsRemaining: number|null } * @returns {{blocked:boolean, reason:string|null, triggered:string[]}} */ evaluate(flowSnapshot) { const { measurements, basin, config, logger, machines } = this.ctx; const direction = flowSnapshot?.direction ?? 'steady'; const secondsRemaining = flowSnapshot?.secondsRemaining ?? null; const volUnit = measurements.getUnit('volume'); const vol = pickVariant(measurements, 'volume', this.volVariants, 'atequipment', volUnit); if (vol == null) { Object.values(machines).forEach((m) => m.handleInput('parent', 'execSequence', 'shutdown')); logger.warn('No volume data available to safe guard system; shutting down all machines.'); return { blocked: true, reason: 'no-volume-data', triggered: ['no-volume-data'] }; } const triggered = []; let blocked = false; let reason = null; const dry = this._dryRunRule(vol, direction, secondsRemaining); if (dry.triggered) { this._shutdownDownstream(vol, secondsRemaining); blocked = true; reason = 'dry-run'; triggered.push(...dry.flags); } const over = this._overfillRule(vol, direction, secondsRemaining); if (over.triggered) { this._shutdownUpstream(vol, secondsRemaining); // Overfill never sets blocked — control keeps running. if (reason == null) reason = 'overfill'; triggered.push(...over.flags); } return { blocked, reason, triggered }; } _safetyConfig() { return this.ctx.config.safety || {}; } _dryRunRule(vol, direction, secondsRemaining) { if (direction !== 'draining') return { triggered: false, flags: [] }; const s = this._safetyConfig(); const dryRunEnabled = Boolean(s.enableDryRunProtection); const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0; const triggerLowVol = this.ctx.basin.minVol * (1 + ((Number(s.dryRunThresholdPercent) || 0) / 100)); const flags = []; if (dryRunEnabled && vol < triggerLowVol) flags.push('dry-run-volume'); if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) { flags.push('time-remaining'); } return { triggered: flags.length > 0, flags }; } _overfillRule(vol, direction, secondsRemaining) { if (direction !== 'filling') return { triggered: false, flags: [] }; const s = this._safetyConfig(); const overfillEnabled = Boolean(s.enableOverfillProtection); const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0; const triggerHighVol = this.ctx.basin.maxVolAtOverflow * ((Number(s.overfillThresholdPercent) || 0) / 100); const flags = []; if (overfillEnabled && vol > triggerHighVol) flags.push('overfill-volume'); if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) { flags.push('time-remaining'); } return { triggered: flags.length > 0, flags }; } _shutdownDownstream(vol, secondsRemaining) { const { machines, machineGroups, stations, logger } = this.ctx; Object.values(machines).forEach((machine) => { const pos = machine?.config?.functionality?.positionVsParent; if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) { machine.handleInput('parent', 'execSequence', 'shutdown'); } }); Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown')); Object.values(machineGroups).forEach((g) => g.turnOffAllMachines()); logger.warn( `Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down downstream equipment` ); } _shutdownUpstream(vol, secondsRemaining) { const { machines, stations, logger } = this.ctx; Object.values(machines).forEach((machine) => { const pos = machine?.config?.functionality?.positionVsParent; if (pos === 'upstream' && machine._isOperationalState()) { machine.handleInput('parent', 'execSequence', 'shutdown'); } }); Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown')); // Machine groups intentionally NOT shut down — they must keep draining. logger.warn( `Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running` ); } } module.exports = SafetyController;