Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:
control/levelBased.js
- stopLevel Schmitt-trigger + dead-band keep-alive
- Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
- Linear vs log up-curve (curveType + logCurveFactor)
measurement/flowAggregator.js
- Predicted-volume overflow clamp + spill flow stream
- Cumulative overflowVolume + underflowVolume
- Hard floor at 0 + dry-run-on-transition handling
basin/thresholdValidator.js
- computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
- startLevel ≤ inflowLevel invariant added
measurement/calibration.js + commands/
- Manual q_out path (set.outflow / q_out alias)
safety/safetyController.js
- Accepts both legacy + new high-volume threshold names
UI:
pumpingStation.html — restored the side-panel + SVG mode-preview block,
added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
logCurveFactor/enableShiftedRamp.
src/editor/* — basin-docs' 7-file modular editor (replaces single
src/editor.js, which is deleted).
pumpingStation.js — admin endpoint serves editor/:file.
Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.
Human-review items (see commit context):
- rampFoot = inflowLevel (matches basin-docs test); basin-docs source
used rampFoot = startLevel. Domain owner: confirm intent.
- Naming kept dual (overfillLevel + highVolumeSafetyLevel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
6.8 KiB
JavaScript
157 lines
6.8 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();
|
|
// basin-docs renamed enableOverfillProtection → enableHighVolumeSafety;
|
|
// both work as aliases (HEAD already maps in buildDomainConfig).
|
|
const enabled = Boolean(s.enableHighVolumeSafety ?? s.enableOverfillProtection);
|
|
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
|
const pct = Number(s.highVolumeSafetyThresholdPercent ?? s.overfillThresholdPercent) || 0;
|
|
const triggerHighVol = this.ctx.basin.maxVolAtOverflow * (pct / 100);
|
|
|
|
const flags = [];
|
|
if (enabled && 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;
|