Files
pumpingStation/src/safety/safetyController.js
znetsixe 7afcd6e54a P2 wave 1: extract concerns from pumpingStation specificClass
Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.

  src/basin/         BasinGeometry + thresholdValidator (pure)
  src/measurement/   flowAggregator + measurementRouter + calibration
  src/control/       levelBased + flowBased(stub) + manual + index dispatcher
  src/safety/        safetyController split into dryRun + overfill rules
  src/commands/      registry array + handlers (canonical names from start)
  src/editor.js      260 lines of SVG basin-diagram redraw, was inline in .html
  examples/standalone-demo.js  was if(require.main===module) at bottom of specificClass.js
  CONTRACT.md        canonical inputs + outputs + emitted events

Modified:
  src/specificClass.js  removed the 170-line standalone demo block
  pumpingStation.html   oneditprepare/oneditsave delegate to editor.{init,save}
  pumpingStation.js     added admin endpoint serving src/editor.js

102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:18:49 +02:00

154 lines
6.5 KiB
JavaScript

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