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>
This commit is contained in:
153
src/safety/safetyController.js
Normal file
153
src/safety/safetyController.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user