Files
pumpingStation/src/specificClass.js
znetsixe 016433abe6 Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests
### Guardrails (specificClass.js)

New _validateThresholdOrdering() runs in the constructor. Checks every
ordered pair of basin + control + derived-safety levels and logs a
warning for each violation; returns the list as this.thresholdIssues
so tests and the eval harness can inspect. Non-fatal — we prefer a
running-but-warned station to a refusal-to-start (availability-first).

Strict invariants (bottom → top):
  0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
  dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel

Uses a list-of-checks pattern rather than a switch — easier to add new
invariants without reflowing cases, and the list itself is readable
documentation.

### Bug fix (specificClass.js)

calibratePredictedLevel was writing the volume value into the LEVEL
slot. Root cause: MeasurementContainer is stateful — its type()/
variant()/position() calls mutate the container's own cursor, so
caching chain references (const levelChain = ...; const volumeChain
= ...) doesn't isolate them. The second cached chain ended up sharing
the state of the last type() call. Rebuilt chains fresh each time,
matching the calibratePredictedVolume pattern that already worked.

### Tests (test/basic/specificClass.test.js)

Ported from Jest to node:test + node:assert — the project's standard
per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js
(tests referenced methods that no longer exist post-rename).

New coverage, 42 passing subtests:
- Basin geometry derivations + minHeightBasedOn
- Level/volume roundtrip
- Threshold guardrails (5 violation cases)
- Direction derivation
- Mode change accept/reject
- Calibration (volume and level paths — catches the bug above)
- Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate)
- getOutput flattening
- setManualInflow

Run with: node --test test/basic/*.test.js

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00

1039 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const EventEmitter = require('events');
const {
logger,
configUtils,
configManager,
childRegistrationUtils,
MeasurementContainer,
coolprop,
interpolation,
POSITIONS
} = require('generalFunctions');
class PumpingStation {
/**
* PumpingStation — S88 Process Cell.
*
* Models a wet-well basin with inflow/outflow and orchestrates child
* equipment (pumps via rotatingMachine, pump groups via MGC, nested
* stations) to keep the water level within safe bounds.
*
* Full behaviour, threshold semantics, control modes, and the basin
* diagram are documented in the wiki:
* wiki/functional-description.md + wiki/modes/*.md
*
* Tick loop (1 s): predicted volume → net flow → safety → control.
*/
constructor(config = {}) {
// --- Dependency injection & config merge ---
this.emitter = new EventEmitter();
this.configManager = new configManager();
this.defaultConfig = this.configManager.getConfig('pumpingStation');
this.configUtils = new configUtils(this.defaultConfig);
// initConfig deep-merges user config over schema defaults so every
// field is guaranteed present even if the caller omits it.
this.config = this.configUtils.initConfig(config);
this.interpolate = new interpolation();
this.logger = new logger(this.config.general.logging.enabled, this.config.general.logging.logLevel, this.config.general.name);
// --- Measurement store ---
// autoConvert: incoming values in any unit are stored in their
// original unit but getCurrentValue(targetUnit) converts on read.
// preferredUnits: the canonical units used for ALL internal math.
// Flow and netFlowRate MUST be m3/s because the volume integrator
// multiplies flow × seconds to get m3. Level in m and volume in m3
// keep the basin geometry math unit-consistent.
this.measurements = new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' }
});
// --- Child registries ---
// Children register via Port 2 handshake. Each dict is keyed by
// the child's config.general.id.
// machines : rotatingMachine instances (direct pumps, no MGC)
// stations : nested pumpingStation instances (cascaded basins)
// machineGroups : MGC instances (each manages its own pump pool)
this.childRegistrationUtils = new childRegistrationUtils(this);
this.machines = {};
this.stations = {};
this.machineGroups = {};
// predictedFlowChildren tracks predicted flow subscriptions per child.
// Key = childId, value = { in: <last m3/s>, out: <last m3/s> }.
// Only the highest-level aggregator is subscribed (MGC if present,
// otherwise individual machines) to avoid double-counting.
this.predictedFlowChildren = new Map();
// --- Variant priority ---
// Order determines which variant is used for CONTROL decisions:
// 'measured' is preferred; 'predicted' is the fallback.
//
// IMPORTANT — both variants are ALWAYS computed regardless of which
// one drives control. The output exposes both values plus a flag
// indicating which variant is currently driving control decisions.
// This lets operators see the difference between measured and
// predicted, which is valuable for:
// - Detecting sensor drift (measured diverges from predicted)
// - Validating the volume integrator (predicted tracks measured?)
// - Diagnosing control issues (was the wrong source active?)
//
// Implementation: _selectBestNetFlow computes both and stores both
// in MeasurementContainer; it returns the winning variant as the
// control source. getOutput() exposes all variants.
this.flowVariants = ['measured', 'predicted'];
this.levelVariants = ['measured', 'predicted'];
this.volVariants = ['measured', 'predicted'];
// Position aliases — two naming conventions coexist because:
// - Measurement children (sensors) store their raw
// positionVsParent from config: 'upstream' / 'downstream'
// - Predicted-flow children (MGC, machines) map positions to
// shorthand: 'in' / 'out' (see _registerPredictedFlowChild)
//
// The .sum() helper aggregates across an array of position names,
// so this map gives each logical direction ALL its aliases. This
// way sum('flow', 'predicted', flowPositions.outflow) catches both
// a measurement stored under 'downstream' AND a prediction stored
// under 'out'.
this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] };
// --- Runtime state ---
this.mode = this.config.control.mode;
// state is the public snapshot updated at the end of each tick().
// Consumers (nodeClass, dashboard) read this for display/telemetry.
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
// percControl: the 0-100% demand sent to MGC / direct machines in
// levelbased mode. Exposed in getOutput() for dashboards.
this.percControl = 0;
// --- Flow dead-band ---
// flowThreshold (m3/s) prevents control actions on noise.
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
// treated as 'steady' (no filling, no draining).
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
// Geometry + threshold ordering check. initBasinProperties seeds
// predicted volume at minVol; _validateThresholdOrdering warns if
// any physical/control invariant is violated. Non-fatal — prefer
// continuity over refusal to start (availability-first).
this.initBasinProperties();
this.thresholdIssues = this._validateThresholdOrdering();
this.logger.debug('PumpingStation initialized');
}
/* --------------------------- Registration --------------------------- */
registerChild(child, softwareType) {
this.logger.debug(`Registering child (${softwareType}) "${child.config.general.name}"`);
if (softwareType === 'measurement') {
this._registerMeasurementChild(child);
return;
}
if (softwareType === 'machine') {
this.machines[child.config.general.id] = child;
} else if (softwareType === 'pumpingstation') {
this.stations[child.config.general.id] = child;
} else if (softwareType === 'machinegroup') {
this.machineGroups[child.config.general.id] = child;
}
// Register predicted-flow subscription. Only register the HIGHEST-
// level aggregator: if a machinegroup is present, subscribe to IT
// (its flow.predicted already aggregates all child machines). Do NOT
// also subscribe to individual machines — that would double-count
// because each pump's flow is included in the group total.
//
// Individual machines (softwareType='machine') are only subscribed
// when there is NO machinegroup parent — i.e., pumps wired directly
// to the pumping station without an MGC in between.
if (softwareType === 'machinegroup' || softwareType === 'pumpingstation') {
this._registerPredictedFlowChild(child);
} else if (softwareType === 'machine' && Object.keys(this.machineGroups).length === 0) {
// Direct-child machine, no group above it — register its flow.
this._registerPredictedFlowChild(child);
}
}
_registerMeasurementChild(child) {
const position = child.config.functionality.positionVsParent;
const measurementType = child.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
child.measurements.emitter.on(eventName, (eventData = {}) => {
this.logger.debug(
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
);
this.measurements
.type(measurementType)
.variant('measured')
.position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this._handleMeasurement(measurementType, eventData.value, position, eventData);
});
}
_registerPredictedFlowChild(child) {
const position = (child.config.functionality.positionVsParent || '').toLowerCase();
const childName = child.config.general.name;
const childId = child.config.general.id ?? childName;
let posKey;
let eventName;
switch (position) {
case 'downstream':
case 'out':
case 'atequipment':
posKey = 'out';
// Subscribe to ONE event only. 'downstream' is the most specific
// — avoids double-counting from 'atequipment' which carries the
// same total flow on a different event name.
eventName = 'flow.predicted.downstream';
break;
case 'upstream':
case 'in':
posKey = 'in';
eventName = 'flow.predicted.upstream';
break;
default:
this.logger.warn(`Unsupported predicted flow position "${position}" from ${childName}`);
return;
}
if (!this.predictedFlowChildren.has(childId)) {
this.predictedFlowChildren.set(childId, { in: 0, out: 0 });
}
const handler = (eventData = {}) => {
const unit = eventData.unit || child.config?.general?.unit;
const ts = eventData.timestamp || Date.now();
this.logger.debug(`Emitting for child ${unit} `);
this.measurements
.type('flow')
.variant('predicted')
.position(posKey)
.child(childId)
.value(eventData.value, ts, unit);
};
child.measurements.emitter.on(eventName, handler);
}
/* --------------------------- Calibration --------------------------- */
calibratePredictedVolume(calibratedVol, timestamp = Date.now()) {
const volume = this.measurements.type('volume').variant('predicted').position('atequipment').get();
const level = this.measurements.type('level').variant('predicted').position('atequipment').get();
if (volume) {
volume.values = [];
volume.timestamps = [];
}
if (level) {
level.values = [];
level.timestamps = [];
}
this.measurements.type('volume').variant('predicted').position('atequipment').value(calibratedVol, timestamp, 'm3').unit('m3');
this.measurements.type('level').variant('predicted').position('atequipment').value(this._calcLevelFromVolume(calibratedVol), timestamp, 'm');
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
}
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
// Rebuild the chain each time — MeasurementContainer is stateful
// (its type/variant/position methods mutate the container itself,
// so cached chain references share one cursor).
const volMeas = this.measurements.type('volume').variant('predicted').position('atequipment');
if (volMeas.exists()) {
const m = volMeas.get();
m.values = []; m.timestamps = [];
}
const lvlMeas = this.measurements.type('level').variant('predicted').position('atequipment');
if (lvlMeas.exists()) {
const m = lvlMeas.get();
m.values = []; m.timestamps = [];
}
this.measurements.type('level').variant('predicted').position('atequipment').value(val, timestamp, unit);
this.measurements.type('volume').variant('predicted').position('atequipment').value(this._calcVolumeFromLevel(val), timestamp, 'm3');
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
}
setManualInflow(value, timestamp = Date.now(), unit) {
const num = Number(value);
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit);
}
/* --------------------------- Tick / Control --------------------------- */
tick() {
this._updatePredictedVolume();
const netFlow = this._selectBestNetFlow();
const remaining = this._computeRemainingTime(netFlow);
this._safetyController(remaining.seconds, netFlow.direction);
if (this.safetyControllerActive) return;
this._controlLogic(netFlow.direction);
this.state = {
direction: netFlow.direction,
netFlow: netFlow.value,
flowSource: netFlow.source,
seconds: remaining.seconds,
remainingSource: remaining.source
};
this.logger.debug(`netflow = ${JSON.stringify(netFlow)}`);
this.logger.debug(
`Height : ${this.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m')} m`
);
}
changeMode(newMode){
if ( this.config.control.allowedModes.has(newMode) ){
const currentMode = this.mode;
this.logger.info(`Control mode changing from ${currentMode} to ${newMode}`);
this.mode = newMode;
}
else{
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
}
}
_controlLogic(direction) {
switch (this.mode) {
case 'levelbased':
this._controlLevelBased();
break;
case 'flowbased':
this._controlFlowBased?.();
break;
case 'manual':
break;
default:
this.logger.warn(`Unsupported control mode: ${this.mode}`);
}
}
async _controlLevelBased() {
const { startLevel, minLevel } = this.config.control.levelbased;
const levelUnit = this.measurements.getUnit('level');
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
if (level == null) {
this.logger.warn('No valid level found');
return;
}
// Level-based pump control via MGC — three zones:
// level < minLevel → STOP (unconditional MGC shutdown)
// minLevel ≤ level < startLevel → DEAD ZONE (hysteresis; keep last cmd)
// level ≥ startLevel → RUN (linear [startLevel..maxLevel] → [0..100 %])
// See wiki/modes/levelbased.md for the full transfer-function diagram.
// STOP — below minLevel, always shut down regardless of direction.
if (level < minLevel) {
this.percControl = 0;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
// DEAD ZONE — between minLevel and startLevel, do nothing.
// Pumps that are running keep their last command; pumps that
// are off stay off. This prevents rapid on/off cycling.
if (level < startLevel) {
return;
}
// RUN — above startLevel, compute demand and forward to MGC.
// _scaleLevelToFlowPercent maps [startLevel..maxLevel] → [0..100].
// Above maxLevel the MGC clamps internally.
const rawPercControl = this._scaleLevelToFlowPercent(level);
const percControl = Math.max(0, rawPercControl);
this.percControl = percControl;
this.logger.debug(`Level-based control: level=${level} percControl=${percControl}`);
await this._applyMachineGroupLevelControl(percControl);
}
_controlFlowBased() {
// placeholder for flow-based logic
}
/**
* Forward a manual demand value to all child machine groups + direct
* machines. Called from the 'Qd' topic handler when PS is in manual
* mode — mirrors how rotatingMachine gates commands by mode.
* @param {number} demand - the operator-set demand (interpretation
* depends on MGC scaling: 'absolute' = m³/h, 'normalized' = 0-100%)
*/
async forwardDemandToChildren(demand) {
this.logger.info(`Manual demand forwarded: ${demand}`);
// Forward to machine groups (MGC)
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
await Promise.all(
Object.values(this.machineGroups).map((group) =>
group.handleInput('parent', demand).catch((err) => {
this.logger.error(`Failed to forward demand to group: ${err.message}`);
})
)
);
}
// Forward to direct machines (if any)
if (this.machines && Object.keys(this.machines).length > 0) {
const perMachine = demand / Object.keys(this.machines).length;
for (const machine of Object.values(this.machines)) {
try {
await machine.handleInput('parent', 'execMovement', perMachine);
} catch (err) {
this.logger.error(`Failed to forward demand to machine: ${err.message}`);
}
}
}
}
async _applyMachineGroupLevelControl(percentControl) {
if (!this.machineGroups || Object.keys(this.machineGroups).length === 0) return;
await Promise.all(
Object.values(this.machineGroups).map((group) =>
group.handleInput('parent', percentControl).catch((err) => {
this.logger.error(`Failed to send level control to group "${group.config.general.name}": ${err.message}`);
})
)
);
}
async _applyMachineLevelControl(percentControl) {
const machines = Object.values(this.machines).filter((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
return (pos === 'downstream' || pos === 'atequipment');
});
if (!machines.length) return;
const perMachine = percentControl / machines.length;
for (const machine of machines) {
try {
await machine.handleInput('parent', 'execSequence', 'startup');
await machine.handleInput('parent', 'execMovement', perMachine);
} catch (err) {
this.logger.error(`Failed to start machine "${machine.config.general.name}": ${err.message}`);
}
}
}
/* --------------------------- Measurements --------------------------- */
_handleMeasurement(measurementType, value, position, context) {
switch (measurementType) {
case 'level':
this._onLevelMeasurement(position, value, context);
break;
case 'pressure':
this._onPressureMeasurement(position, value, context);
break;
default:
break;
}
}
_onLevelMeasurement(position, value, context = {}) {
this.measurements.type('level').variant('measured').position(position).value(value).unit(context.unit);
const levelSeries = this.measurements.type('level').variant('measured').position(position);
const levelMeters = levelSeries.getCurrentValue('m');
if (levelMeters == null) return;
const volume = this._calcVolumeFromLevel(levelMeters);
const percent = this.interpolate.interpolate_lin_single_point(
volume,
this.basin.minVol,
this.basin.maxVolAtOverflow,
0,
100
);
this.measurements.type('volume').variant('measured').position('atequipment').value(volume, context.timestamp, 'm3');
this.measurements
.type('volumePercent')
.variant('measured')
.position('atequipment')
.value(percent, context.timestamp, '%');
}
_onPressureMeasurement(position, value, context = {}) {
let kelvinTemp =
this.measurements.type('temperature').variant('measured').position('atequipment').getCurrentValue('K') ?? null;
if (kelvinTemp === null) {
this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.');
this.measurements.type('temperature').variant('assumed').position('atequipment').value(15, Date.now(), 'C');
kelvinTemp = this.measurements.type('temperature').variant('assumed').position('atequipment').getCurrentValue('K');
}
if (kelvinTemp == null) return;
const density = coolprop.PropsSI('D', 'T', kelvinTemp, 'P', 101325, 'Water');
const pressurePa = this.measurements.type('pressure').variant('measured').position(position).getCurrentValue('Pa');
if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return;
const g = 9.80665;
const level = pressurePa / (density * g);
this.measurements.type('level').variant('predicted').position(position).value(level, context.timestamp, 'm');
}
/* --------------------------- Core Calculations --------------------------- */
_pickVariant(type, variants, position, unit) {
for (const variant of variants) {
const val = this.measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
if (!Number.isFinite(val)) continue;
return val;
}
return null;
}
_scaleLevelToFlowPercent(level) {
const { startLevel, maxLevel } = this.config.control.levelbased;
this.logger.debug(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
return this.interpolate.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
}
_levelRate(variant) {
const chain = this.measurements.type('level').variant(variant).position('atequipment');
if (!chain.exists({ requireValues: true })) return null;
const m = chain.get();
const current = m?.getLaggedSample?.(0);
const previous = m?.getLaggedSample?.(1);
if (!current || !previous || previous.timestamp == null) return null;
const dt = (current.timestamp - previous.timestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) return null;
return (current.value - previous.value) / dt;
}
_updatePredictedVolume() {
const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below
const now = Date.now();
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
const outflow = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
if (!this._predictedFlowState) {
this._predictedFlowState = { inflow, outflow, lastTimestamp: now };
}
const timestampPrev = this._predictedFlowState.lastTimestamp ?? now;
const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0);
const netVolumeChange = deltaSeconds > 0 ? (inflow - outflow) * deltaSeconds : 0;
const volumeSeries = this.measurements.type('volume').variant('predicted').position('atequipment');
const currentVolume = volumeSeries.getCurrentValue('m3');
const nextVolume = currentVolume + netVolumeChange;
const writeTimestamp = timestampPrev + deltaSeconds * 1000;
volumeSeries.value(nextVolume, writeTimestamp, 'm3').unit('m3'); //olifant
const nextLevel = this._calcLevelFromVolume(nextVolume);
this.measurements
.type('level')
.variant('predicted')
.position('atequipment')
.value(nextLevel, writeTimestamp, 'm')
.unit('m');
const percent = this.interpolate.interpolate_lin_single_point(
nextVolume,
this.basin.minVol,
this.basin.maxVolAtOverflow,
0,
100
);
this.measurements
.type('volumePercent')
.variant('predicted')
.position('atequipment')
.value(percent, writeTimestamp, '%');
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTimestamp };
}
_selectBestNetFlow() {
const type = 'flow';
const unit = this.measurements.getUnit(type) || 'm3/s';
for (const variant of this.flowVariants) {
const bucket = this.measurements.measurements?.[type]?.[variant];
if (!bucket || Object.keys(bucket).length === 0) continue;
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
const outflow = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
const net = inflow - outflow;
this.measurements.type('netFlowRate').variant(variant).position('atequipment').value(net, Date.now(), unit);
return { value: net, source: variant, direction: this._deriveDirection(net) };
}
// Fallback: level trend
for (const variant of this.levelVariants) {
const rate = this._levelRate(variant);
if (!Number.isFinite(rate)) continue;
const netFlow = rate * this.basin.surfaceArea;
return { value: netFlow, source: `level:${variant}`, direction: this._deriveDirection(netFlow) };
}
this.logger.warn('No usable measurements to compute net flow; assuming steady.');
return { value: 0, source: null, direction: 'steady' };
}
_computeRemainingTime(netFlow) {
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) return { seconds: null, source: null };
const { overflowLevel, outflowLevel, surfaceArea } = this.basin;
if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) return { seconds: null, source: null };
for (const variant of this.levelVariants) {
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
if (!Number.isFinite(lvl)) continue;
const remainingHeight = netFlow.value > 0 ? Math.max(overflowLevel - lvl, 0) : Math.max(lvl - outflowLevel, 0);
const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value);
if (!Number.isFinite(seconds)) continue;
return { seconds, source: `${netFlow.source}/${variant}` };
}
return { seconds: null, source: netFlow.source };
}
_deriveDirection(netFlow) {
if (netFlow > this.flowThreshold) return 'filling';
if (netFlow < -this.flowThreshold) return 'draining';
return 'steady';
}
/* --------------------------- Safety --------------------------- */
/**
* Safety controller — two hard rules:
*
* 1. BELOW minLevel (dry-run): pumps CANNOT start.
* Shuts down all downstream machines + machine groups.
* Only a manual override or emergency can restart them.
* safetyControllerActive = true → blocks _controlLogic.
*
* 2. ABOVE overflow level (overfill): pumps CANNOT stop.
* Shuts down UPSTREAM equipment only (stop more water coming in).
* Does NOT shut down downstream pumps or machine groups — they
* must keep draining. Does NOT set safetyControllerActive — the
* level-based control keeps running so pumps stay at the demand
* dictated by the current level (which will be >100% near overflow,
* meaning all pumps at maximum via the normal demand curve).
* Only a manual override or emergency stop can shut pumps during
* an overfill event.
*/
_safetyController(remainingTime, direction) {
this.safetyControllerActive = false;
const volUnit = this.measurements.getUnit('volume');
const vol = this._pickVariant('volume', this.volVariants, 'atequipment', volUnit);
if (vol == null) {
Object.values(this.machines).forEach((machine) => machine.handleInput('parent', 'execSequence', 'shutdown'));
this.logger.warn('No volume data available to safe guard system; shutting down all machines.');
this.safetyControllerActive = true;
return;
}
const {
enableDryRunProtection,
dryRunThresholdPercent,
enableOverfillProtection,
overfillThresholdPercent,
timeleftToFullOrEmptyThresholdSeconds
} = this.config.safety || {};
const dryRunEnabled = Boolean(enableDryRunProtection);
const overfillEnabled = Boolean(enableOverfillProtection);
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
const triggerHighVol = this.basin.maxVolAtOverflow * ((Number(overfillThresholdPercent) || 0) / 100);
const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100));
// Rule 1: DRY-RUN — below minLevel, pumps cannot run.
if (direction === 'draining') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
if (timeTriggered || dryRunTriggered) {
// Shut down all downstream equipment — pumps must stop.
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) {
machine.handleInput('parent', 'execSequence', 'shutdown');
}
});
Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown'));
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
this.logger.warn(
`Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
);
// Block _controlLogic so level-based control can't restart pumps.
this.safetyControllerActive = true;
}
}
// Rule 2: OVERFILL — above overflow level, pumps cannot stop.
// Only shut down UPSTREAM equipment. Downstream pumps + machine
// groups keep running at whatever the level control demands
// (which will be >100% near overflow = all pumps at max).
// Do NOT set safetyControllerActive — _controlLogic must keep
// running to maintain pump demand.
if (direction === 'filling') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const overfillTriggered = overfillEnabled && vol > triggerHighVol;
if (timeTriggered || overfillTriggered) {
// Shut down UPSTREAM only — stop more water coming in.
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
if (pos === 'upstream' && machine._isOperationalState()) {
machine.handleInput('parent', 'execSequence', 'shutdown');
}
});
Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown'));
// NOTE: machine groups (downstream pumps) are NOT shut down.
// They must keep draining to prevent overflow from worsening.
this.logger.warn(
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
);
// NOTE: safetyControllerActive is NOT set — level control
// keeps commanding pumps at maximum demand.
}
}
}
/* --------------------------- Basin --------------------------- */
/**
* Compute basin geometry from config and seed the initial predicted
* volume at the operational floor.
*
* Basin is modelled as a rectangular prism (constant cross-section),
* so `volume = level × surfaceArea`. See the wiki's basin-model
* diagram for the full threshold layout and naming conventions:
* wiki/functional-description.md#basin-model
*
* `minHeightBasedOn` ('inlet' | 'outlet') selects which pipe height
* defines `minVol` — the 0 % point of fill-percent and the default
* dry-run reference.
*/
initBasinProperties() {
const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn;
const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity
const heightBasin = this.config.basin.height; // m — floor to rim
const inflowLevel = this.config.basin.inflowLevel; // m — sewer feed pipe centre
const outflowLevel = this.config.basin.outflowLevel; // m — pump suction pipe centre
const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest
// Constant cross-section assumption: volume = level × area
const surfaceArea = volEmptyBasin / heightBasin;
// Volume at each critical height
const maxVol = heightBasin * surfaceArea; // ≡ volEmptyBasin (see note above)
const maxVolAtOverflow = overflowLevel * surfaceArea; // spill threshold
const minVolAtOutflow = outflowLevel * surfaceArea; // dry-run threshold
const minVolAtInflow = inflowLevel * surfaceArea; // gravity-feed threshold
// Operational floor: which pipe defines "basin too low"
const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow;
this.basin = {
volEmptyBasin,
heightBasin,
inflowLevel,
outflowLevel,
overflowLevel,
surfaceArea,
maxVol,
maxVolAtOverflow,
minVolAtInflow,
minVolAtOutflow,
minVol,
minHeightBasedOn
};
// Seed predicted volume at operational floor — the station assumes
// the basin is at minimum until calibrated by a real measurement.
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
}
/**
* Validate basin + control threshold ordering.
*
* Every pair is a strict physical or control invariant. Violations
* don't throw — they log a warning and return the list so callers
* (tests, node-status, the eval harness) can surface them. Returning
* [] means "all invariants hold".
*
* Strict invariants (bottom → top):
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
* dryRunTriggerLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overflowLevel
*
* dryRunTriggerLevel and the overfill trigger are DERIVED — computed
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
* overfillThresholdPercent/100 in the safety layer. Validating those
* catches config that would let minLevel sit below where safety has
* already force-stopped the pumps (no-op control band).
*/
_validateThresholdOrdering() {
const basin = this.basin;
const lvl = this.config.control?.levelbased || {};
const safety = this.config.safety || {};
// Derived safety trigger levels (level-space equivalents of what
// _safetyController does in volume-space).
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const overfillPct = Number(safety.overfillThresholdPercent) || 100;
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
];
const issues = [];
for (const [aName, a, op, bName, b] of checks) {
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
const ok = op === '<' ? a < b : a <= b;
if (!ok) {
const msg = `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`;
issues.push({ aName, a, op, bName, b, msg });
this.logger.warn(msg);
}
}
return issues;
}
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
_calcVolumeFromLevel(level) {
return Math.max(level, 0) * this.basin.surfaceArea;
}
/** Convert volume (m3) → level (m from floor). Clamps to 0. */
_calcLevelFromVolume(volume) {
return Math.max(volume, 0) / this.basin.surfaceArea;
}
/* --------------------------- Output --------------------------- */
getOutput() {
const output = this.measurements.getFlattenedOutput();
output.direction = this.state.direction;
output.flowSource = this.state.flowSource;
output.timeleft = this.state.seconds;
output.volEmptyBasin = this.basin.volEmptyBasin;
output.inflowLevel = this.basin.inflowLevel;
output.overflowLevel = this.basin.overflowLevel;
output.maxVol = this.basin.maxVol;
output.minVol = this.basin.minVol;
output.maxVolAtOverflow = this.basin.maxVolAtOverflow;
output.minVolAtOutflow = this.basin.minVolAtOutflow;
output.minVolAtInflow = this.basin.minVolAtInflow;
output.minHeightBasedOn = this.basin.minHeightBasedOn;
output.percControl = this.percControl;
return output;
}
}
module.exports = PumpingStation;
/* ------------------------------------------------------------------------- */
/* Example usage */
/* ------------------------------------------------------------------------- */
if (require.main === module) {
const Measurement = require('../../measurement/src/specificClass');
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
function createPumpingStationConfig(name) {
return {
general: {
logging: { enabled: true, logLevel: 'debug' },
name,
id: `${name}-${Date.now()}`,
flowThreshold: 1e-4
},
functionality: {
softwareType: 'pumpingStation',
role: 'stationcontroller'
},
basin: {
volume: 43.75,
height: 10,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 3.2
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0
},
safety: {
enableDryRunProtection:false,
enableOverfillProtection:false
}
};
}
function createLevelMeasurementConfig(name) {
return {
general: {
logging: { enabled: true, logLevel: 'debug' },
name,
id: `${name}-${Date.now()}`,
unit: 'm'
},
functionality: {
softwareType: 'measurement',
role: 'sensor',
positionVsParent: 'atequipment'
},
asset: {
category: 'sensor',
type: 'level',
model: 'demo-level',
supplier: 'demoCo',
unit: 'm'
},
scaling: { enabled: false },
smoothing: { smoothWindow: 5, smoothMethod: 'none' }
};
}
function createFlowMeasurementConfig(name, position) {
return {
general: {
logging: { enabled: true, logLevel: 'debug' },
name,
id: `${name}-${Date.now()}`,
unit: 'm3/s'
},
functionality: {
softwareType: 'measurement',
role: 'sensor',
positionVsParent: position
},
asset: {
category: 'sensor',
type: 'flow',
model: 'demo-flow',
supplier: 'demoCo',
unit: 'm3/s'
},
scaling: { enabled: false },
smoothing: { smoothWindow: 5, smoothMethod: 'none' }
};
}
function createMachineConfig(name,position) {
return {
general: {
name,
logging: { enabled: false, logLevel: 'debug' }
},
functionality: {
softwareType: "machine",
positionVsParent: position
},
asset: {
supplier: 'Hydrostal',
type: 'pump',
category: 'centrifugal',
model: 'hidrostal-H05K-S03R'
}
};
}
function createMachineStateConfig() {
return {
general: {
logging: {
enabled: true,
logLevel: 'debug'
}
},
movement: { speed: 1 },
time: {
starting: 2,
warmingup: 3,
stopping: 2,
coolingdown: 3
}
};
}
function seedSample(measurement, type, value, unit) {
const pos = measurement.config.functionality.positionVsParent;
measurement.measurements.type(type).variant('measured').position(pos).value(value, Date.now(), unit);
}
(async function demo() {
const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
const pump1 = new RotatingMachine(createMachineConfig('Pump1','downstream'), createMachineStateConfig());
//const pump2 = new RotatingMachine(createMachineConfig('Pump2','upstream'), createMachineStateConfig());
//const levelSensor = new Measurement(createLevelMeasurementConfig('WetWellLevel'));
//const inflowSensor = new Measurement(createFlowMeasurementConfig('InfluentFlow', 'in'));
//const outflowSensor = new Measurement(createFlowMeasurementConfig('PumpDischargeFlow', 'out'));
//station.childRegistrationUtils.registerChild(levelSensor, levelSensor.config.functionality.softwareType);
//station.childRegistrationUtils.registerChild(inflowSensor, inflowSensor.config.functionality.softwareType);
//station.childRegistrationUtils.registerChild(outflowSensor, outflowSensor.config.functionality.softwareType);
station.childRegistrationUtils.registerChild(pump1, 'machine');
//station.childRegistrationUtils.registerChild(pump2, 'machine');
// Seed initial measurements
//seedSample(levelSensor, 'level', 1.8, 'm');
//seedSample(inflowSensor, 'flow', 0.35, 'm3/s');
//seedSample(outflowSensor, 'flow', 0.20, 'm3/s');
setInterval(
() => station.tick(), 1000);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log('Initial state:', station.state);
station.setManualInflow(300,Date.now(),'l/s');
station.calibratePredictedVolume(3.4);
//await pump1.handleInput('parent', 'execSequence', 'startup');
//await pump1.handleInput('parent', 'execMovement', 10);
//
//await pump2.handleInput('parent', 'execSequence', 'startup');
//await pump2.handleInput('parent', 'execMovement', 10);
console.log('Station state:', station.state);
console.log('Station output:', station.getOutput());
})().catch((err) => {
console.error('Demo failed:', err);
});
}
//*/