Files
pumpingStation/src/specificClass.js
Rene De Ren e2ebb31816 stopLevel Schmitt-trigger hysteresis + dead-zone keep-alive
Levelbased control now distinguishes startLevel (rising-edge engage,
ramp foot) from stopLevel (falling-edge disengage). _stopHystRunning
flag flips TRUE crossing startLevel up, FALSE crossing stopLevel down.
While engaged AND level inside [stopLevel, startLevel] (basin draining
through the dead band), emit a configurable keep-alive percControl
(default 1 %) so MGC keeps a single pump running for a full drain
stroke instead of oscillating at startLevel.

Hard turn-off the moment level <= stopLevel — independent of ramp
scaling. Manual-mode demand=0 now also issues explicit turnOff to
keep parity with the new MGC handleInput semantics where demand<=0
means "off".

Editor preview shades the new hysteresis band; admin endpoint
exposes runtime engaged state.

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

1397 lines
57 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', overflowVolume: '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;
// --- Level-armed hysteresis state (see _controlLevelBased) ---
// _shiftArmed: true once up-curve output % crosses shiftArmPercent on
// the way up. Cleared when level drops to startLevel.
// _shiftHoldValue: captured on every filling→draining transition while
// armed. The output stays at this value while level drops from the
// flip point to shiftLevel; below shiftLevel it ramps to 0 % at
// startLevel (linear or log shape).
// _lastDirection: tracks the previous tick's direction so we can
// detect filling→draining transitions. We don't update it on
// 'steady' ticks so transitions through the dead-band are preserved.
this._shiftArmed = false;
this._shiftHoldValue = null;
this._lastDirection = null;
// --- stopLevel hysteresis (Schmitt trigger) ---
// Levelbased control uses two thresholds:
// - startLevel: ramp foot AND rising-edge engage point. Demand
// scales 0..100 % over [startLevel, maxLevel].
// - stopLevel: falling-edge disengage point. Pumps stay engaged
// (running at minimum flow) while level drains through
// [stopLevel, startLevel]; below stopLevel they're turned off.
//
// _stopHystRunning is the engaged-state flag: flips TRUE when level
// crosses startLevel on the way up, FALSE when level crosses stopLevel
// on the way down. While engaged AND level < startLevel (i.e. the
// basin is draining through the dead band) the controller emits a
// small keep-alive percControl so MGC keeps a single pump running
// until level reaches stopLevel. Without this, percControl=0 in the
// dead band would let MGC turn the pump off, the basin would refill,
// and the pump would oscillate at startLevel instead of running for
// a full drain stroke.
//
// Editor preview also reads _stopHystRunning to shade the hysteresis
// band; runtime semantics are now explicit (no longer "bookkeeping").
this._stopHystRunning = false;
// --- 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);
}
setManualOutflow(value, timestamp = Date.now(), unit) {
const num = Number(value);
this.measurements.type('flow').variant('predicted').position('out').child('manual-qout').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(direction);
break;
case 'flowbased':
this._controlFlowBased?.();
break;
case 'manual':
break;
default:
this.logger.warn(`Unsupported control mode: ${this.mode}`);
}
}
async _controlLevelBased(direction) {
const cfg = this.config.control.levelbased;
const { startLevel, minLevel } = cfg;
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. See wiki/modes/levelbased.md.
//
// Always:
// level < minLevel → STOP (unconditional MGC shutdown)
// level < inflowLevel → 0 % (HOLD zone, pumps idle)
// level in [inflow..max] → up curve 0..100 % (linear or log)
// level > maxLevel → 100 % (MGC clamps internally)
//
// With enableShiftedRamp (hysteresis):
// When up-curve % rises past shiftArmPercent → ARMED.
// On the next filling→draining transition while armed → capture
// hold = current up-curve %.
// While armed AND draining:
// level >= shiftLevel → output = hold (held)
// level in [start..shift] → output ramps hold→0 % over the range
// level < startLevel → output = 0 %
// While armed AND filling/steady → output = up curve (resets hold).
// Disarms only when level <= startLevel.
if (level < minLevel) {
this.percControl = 0;
this._shiftHoldValue = null;
this._shiftArmed = false;
this._stopHystRunning = false;
this._lastDirection = direction;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
// stopLevel hysteresis (Schmitt trigger).
// _stopHystRunning becomes TRUE on rising edge at startLevel
// FALSE on falling edge at stopLevel
// While engaged AND level < startLevel (basin draining through the
// dead band), the controller emits a small keep-alive percControl so
// a single pump keeps running until level reaches stopLevel. Without
// hysteresis the pump would oscillate at startLevel because the
// up-curve goes through 0 there.
const stopLvl = Number(cfg.stopLevel);
const stopThresholdActive = Number.isFinite(stopLvl) && stopLvl >= 0 && stopLvl < cfg.maxLevel;
if (stopThresholdActive && level <= stopLvl) {
// Hard off: drained past stopLevel.
this.percControl = 0;
this._stopHystRunning = false;
this._lastDirection = direction;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
// Update Schmitt-trigger engaged state.
if (stopThresholdActive) {
if (!this._stopHystRunning && level >= startLevel) this._stopHystRunning = true;
// disengage on falling edge is handled by the `level <= stopLvl` block above.
} else {
// No stopLevel configured → no hysteresis; engaged only while level >= startLevel.
this._stopHystRunning = level >= startLevel;
}
// Up-curve value. Foot stays at startLevel (per the user-set demand
// ramp), top is maxLevel. Below startLevel the curve gives 0 %; above
// maxLevel it saturates at 100 %.
const rampFoot = startLevel;
const upPct = this._scaleLevelToFlowPercent(level, rampFoot, cfg.maxLevel);
// Update arming flag.
if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
if (!this._shiftArmed && upPct >= armPct) {
this._shiftArmed = true;
this.logger.debug(`Shift armed: upPct=${upPct} >= ${armPct}`);
}
} else {
this._shiftArmed = false;
}
if (level <= startLevel) {
this._shiftArmed = false;
this._shiftHoldValue = null;
}
// Capture hold on filling→draining transition while armed.
if (cfg.enableShiftedRamp && this._shiftArmed) {
if (this._lastDirection !== 'draining' && direction === 'draining') {
this._shiftHoldValue = upPct;
this.logger.debug(`Shift hold captured: ${upPct} % at level=${level}`);
} else if (direction === 'filling') {
// Returning to filling clears any captured hold; the next drain
// transition will recapture from the up curve.
this._shiftHoldValue = null;
}
}
if (direction === 'filling' || direction === 'draining') {
this._lastDirection = direction;
}
// Compute output.
let percControl;
const inDrainingHold = cfg.enableShiftedRamp && this._shiftArmed
&& direction === 'draining' && this._shiftHoldValue != null;
if (!inDrainingHold) {
// Up curve: 0 % below the ramp foot (startLevel), scaled
// startLevel..maxLevel → 0..100 %, saturates above maxLevel.
// While engaged via the stopLevel Schmitt trigger AND level is
// inside the dead band [stopLevel, startLevel], emit a small
// keep-alive value so MGC's normalized scaling resolves to flow.min
// (a single pump at minimum stable speed) and the basin actually
// drains. Configurable via levelbased.deadZoneKeepAlivePercent
// (default 1%). Ramp foot stays at startLevel — keep-alive is a
// separate "engaged in dead band" signal, not a shifted ramp.
if (level < rampFoot) {
if (stopThresholdActive && this._stopHystRunning) {
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
? Number(cfg.deadZoneKeepAlivePercent) : 1;
percControl = Math.max(0, keepAlive);
} else {
percControl = 0;
}
} else {
percControl = Math.max(0, upPct);
}
} else {
const hold = this._shiftHoldValue;
const shift = cfg.shiftLevel;
if (!Number.isFinite(shift) || shift <= startLevel) {
// Bad config — fall back to up curve.
percControl = Math.max(0, upPct);
} else if (level >= shift) {
percControl = hold;
} else if (level > startLevel) {
// Ramp from (shiftLevel, hold) down to (startLevel, 0).
// Use the same curve shape (linear/log) as the up curve, scaled to
// peak at hold% at level=shiftLevel.
const x = (level - startLevel) / (shift - startLevel);
const shaped = this._curveShape(x);
percControl = Math.max(0, hold * shaped);
} else {
percControl = 0;
}
}
this.percControl = percControl;
this.logger.debug(
`Level-based: level=${level} dir=${direction} armed=${this._shiftArmed} hold=${this._shiftHoldValue} pct=${percControl}`
);
await this._applyMachineGroupLevelControl(percControl);
}
// Apply the configured curve shape to a normalized x in [0,1].
// Returns shaped value in [0,1]. Linear by default; log when curveType
// is 'log' (with logCurveFactor).
_curveShape(x) {
const { curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor) : 9;
return Math.log1p(factor * clamped) / Math.log1p(factor);
}
return clamped;
}
_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}`);
// Manual-mode explicit stop: MGC's handleInput now treats demand=0 as
// "hold current pump states" so the levelbased stopLevel hysteresis
// works. In manual mode the operator setting Qd=0 should still mean
// "stop now", so we issue an explicit turnOff and short-circuit.
if (Number(demand) <= 0) {
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
}
return;
}
// 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;
}
// (legacy _levelBasedRampStart/_levelBasedRampTop/_updateShiftArmed
// helpers were removed in favour of the inline state machine in
// _controlLevelBased — see that method's doc block.)
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const start = Number.isFinite(rampStartLevel) ? rampStartLevel : this.config.control.levelbased.startLevel;
const top = Number.isFinite(rampTopLevel) ? rampTopLevel : maxLevel;
if (!Number.isFinite(level) || !Number.isFinite(start) || !Number.isFinite(top)) return 0;
if (top <= start) return level >= top ? 100 : 0;
const x = Math.max(0, Math.min(1, (level - start) / (top - start)));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor)
: 9;
return 100 * (Math.log1p(factor * x) / Math.log1p(factor));
}
return x * 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();
// The synthetic spill flow lives at its OWN position ('overflow') —
// not as a child of 'out'. That keeps it out of the operational-outflow
// sum here (which only sees pumps + downstream measurements), so no
// self-subtraction is needed. _selectBestNetFlow folds it back in for
// net-flow balance while pinned at overflow.
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
if (!this._predictedFlowState) {
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
}
const timestampPrev = this._predictedFlowState.lastTimestamp ?? now;
const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0);
const netVolumeChange = deltaSeconds > 0 ? (inflow - outflowReal) * deltaSeconds : 0;
// Read currentVolume via a fresh chain — MeasurementContainer's chain
// methods mutate a shared cursor, so any later chain into a different
// type/variant invalidates a saved reference. We re-resolve every read
// and write below for the same reason.
const currentVolume = this.measurements
.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
const writeTimestamp = timestampPrev + deltaSeconds * 1000;
// Predicted-volume bounds.
// Upper (hard physical): maxVolAtOverflow — past this the basin spills
// over the weir; predicted level pins at overflowLevel and the
// excess is tracked as overflow volume + spill flow.
// Lower (operational): dryRunSafetyVol — where pumps must stop. Only
// clamps on transition from above; a basin seeded below (e.g.
// startup-from-empty) is left alone so it can fill from 0.
// Lower (hard physical): 0 — basin cannot hold negative water. Always
// clamps. Without this, a seeded-low basin under continued
// net-outflow integrates volume arbitrarily negative (the level
// output looks fine because _calcLevelFromVolume floors at 0,
// masking the underlying drift).
const safety = this._computeSafetyPoints();
const upperClamp = this.basin.maxVolAtOverflow;
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
const proposedVolume = currentVolume + netVolumeChange;
let nextVolume = proposedVolume;
let overflowIncrement = 0;
let underflowIncrement = 0;
if (proposedVolume > upperClamp) {
overflowIncrement = proposedVolume - upperClamp;
nextVolume = upperClamp;
} else if (proposedVolume < lowerClamp && currentVolume >= lowerClamp) {
nextVolume = lowerClamp;
}
if (nextVolume < 0) {
underflowIncrement = -nextVolume;
nextVolume = 0;
}
// Synthetic spill flow at position 'overflow'.
// While pinned at upper bound with continuing net-positive inflow, the
// weir is carrying away (inflow outflowReal). _selectBestNetFlow folds
// this into the outflow side so the predicted net-flow balance reads ~0
// (matches the level-pinned reality).
let spillRate = 0;
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
spillRate = inflow - outflowReal;
}
this.measurements
.type('flow').variant('predicted').position('overflow')
.value(spillRate, writeTimestamp, 'm3/s').unit('m3/s');
// Cumulative overflow volume — for compliance reporting via InfluxDB.
if (overflowIncrement > 0) {
const prevCumulative = this.measurements
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
this.measurements
.type('overflowVolume').variant('predicted').position('atequipment')
.value(prevCumulative + overflowIncrement, writeTimestamp, 'm3').unit('m3');
}
// Cumulative integrator underflow — diagnostic, NOT compliance.
// A nonzero value means the predicted-volume integrator tried to go
// below the physical floor (negative water). Root causes are usually
// upstream: outflow over-reported (sensor drift, pump curve too
// optimistic) or an inflow source missing from the measurement set.
if (underflowIncrement > 0) {
const prevUnderflow = this.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
this.measurements
.type('underflowVolume').variant('predicted').position('atequipment')
.value(prevUnderflow + underflowIncrement, writeTimestamp, 'm3').unit('m3');
}
this.measurements
.type('volume').variant('predicted').position('atequipment')
.value(nextVolume, writeTimestamp, 'm3').unit('m3');
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: outflowReal, 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 outflowReal = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
// Fold synthetic spill (position 'overflow') into the outflow side.
// It only exists for the predicted variant and only while pinned, so
// for measured this is 0.
const spill = this.measurements.sum(type, variant, ['overflow'], unit) || 0;
const outflow = outflowReal + spill;
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.
// When level pins at overflow, dL/dt collapses to 0 and the level-rate
// method loses the inflow signal — but flow IS still moving (in → spill).
// In that case we hold the last known non-zero net-flow so dashboards
// keep showing roughly what's coming in until level starts dropping.
for (const variant of this.levelVariants) {
const rate = this._levelRate(variant);
if (!Number.isFinite(rate)) continue;
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
const pinnedAtOverflow = Number.isFinite(lvl)
&& Number.isFinite(this.basin.overflowLevel)
&& lvl >= this.basin.overflowLevel - 1e-9;
const rateNearZero = Math.abs(rate) < 1e-9;
let netFlow = rate * this.basin.surfaceArea;
if (pinnedAtOverflow && rateNearZero && Number.isFinite(this._lastLevelRateNetFlow)) {
netFlow = this._lastLevelRateNetFlow;
} else if (!rateNearZero) {
this._lastLevelRateNetFlow = netFlow;
}
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 high-volume safety level: 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
* a high-volume or overflowing 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,
enableHighVolumeSafety,
timeleftToFullOrEmptyThresholdSeconds
} = this.config.safety || {};
const dryRunEnabled = Boolean(enableDryRunProtection);
const highVolumeSafetyEnabled = Boolean(enableHighVolumeSafety ?? enableOverfillProtection);
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
const safety = this._computeSafetyPoints();
const triggerHighVol = safety.highVolumeSafetyVol;
const triggerLowVol = safety.dryRunSafetyVol;
const currentLevel = this._pickVariant('level', this.levelVariants, 'atequipment', 'm');
this.safetyState = {
dryRunActive: false,
highVolumeActive: false,
isOverflowing: Number.isFinite(currentLevel) && currentLevel >= this.basin.overflowLevel,
dryRunLevel: safety.dryRunLevel,
highVolumeSafetyLevel: safety.highVolumeSafetyLevel,
dryRunSafetyVol: safety.dryRunSafetyVol,
highVolumeSafetyVol: safety.highVolumeSafetyVol
};
// 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) {
this.safetyState.dryRunActive = true;
// 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 highVolumeTriggered = highVolumeSafetyEnabled && vol > triggerHighVol;
if (timeTriggered || highVolumeTriggered) {
this.safetyState.highVolumeActive = true;
// 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(
`High-volume 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.
}
}
}
_computeSafetyPoints() {
const safety = this.config.safety || {};
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const highPct = Number(
safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent ?? 98
) || 0;
const dryRunSafetyVol = this.basin.minVol * (1 + (dryRunPct / 100));
const dryRunLevel = this._calcLevelFromVolume(dryRunSafetyVol);
const highVolumeSafetyVol = this.basin.maxVolAtOverflow * (highPct / 100);
const highVolumeSafetyLevel = this._calcLevelFromVolume(highVolumeSafetyVol);
return {
dryRunSafetyVol,
dryRunLevel,
highVolumeSafetyVol,
highVolumeSafetyLevel
};
}
/* --------------------------- 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 — inlet pipe bottom/invert
const outflowLevel = this.config.basin.outflowLevel; // m — outlet/pump suction pipe top
const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest
const inletPipeDiameter = this.config.basin.inletPipeDiameter;
const outletPipeDiameter = this.config.basin.outletPipeDiameter;
// 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,
inletPipeDiameter,
outletPipeDiameter,
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
* dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
*
* dryRunLevel and highVolumeSafetyLevel are DERIVED — computed
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
* highVolumeSafetyThresholdPercent/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 safetyPoints = this._computeSafetyPoints();
const dryRunLevel = safetyPoints.dryRunLevel;
const highVolumeSafetyLevel = safetyPoints.highVolumeSafetyLevel;
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, '<=', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
['highVolumeSafetyLevel', highVolumeSafetyLevel, '<', 'overflowLevel', basin.overflowLevel],
];
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();
const safety = this._computeSafetyPoints();
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.outflowLevel = this.basin.outflowLevel;
output.overflowLevel = this.basin.overflowLevel;
output.inletPipeDiameter = this.basin.inletPipeDiameter;
output.outletPipeDiameter = this.basin.outletPipeDiameter;
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.dryRunLevel = safety.dryRunLevel;
output.dryRunSafetyVol = safety.dryRunSafetyVol;
output.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
output.highVolumeSafetyVol = safety.highVolumeSafetyVol;
output.isOverflowing = Boolean(this.safetyState?.isOverflowing);
output.safetyState = this._deriveSafetyState();
output.percControl = this.percControl;
output.predictedOverflowVolume = this.measurements
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
output.predictedOverflowRate = this.measurements
.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s') ?? 0;
output.predictedUnderflowVolume = this.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
return output;
}
_deriveSafetyState() {
if (this.safetyState?.isOverflowing) return 'overflowing';
if (this.safetyState?.highVolumeActive) return 'highVolume';
if (this.safetyState?.dryRunActive) return 'dryRun';
return 'normal';
}
}
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,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0,
minHeightBasedOn: 'outlet'
},
safety: {
enableDryRunProtection:false,
enableHighVolumeSafety:false,
highVolumeSafetyThresholdPercent: 98
}
};
}
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);
});
}
//*/