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>
154 lines
6.5 KiB
JavaScript
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;
|