Rename basin/control thresholds to wiki naming; trim stale comments
Aligns the code with the 5-threshold convention used throughout the
wiki (basin model + per-mode transfer-function diagrams):
heightInlet → inflowLevel
heightOutlet → outflowLevel
heightOverflow → overflowLevel
stopLevel → minLevel
maxFlowLevel → maxLevel
minFlowLevel → removed (collapsed into startLevel; they were
always supposed to hold the same value)
minVolIn → minVolAtInflow
minVolOut → minVolAtOutflow
maxVolOverflow → maxVolAtOverflow
startLevel → unchanged
Config schema (generalFunctions/src/configs/pumpingStation.json) is
updated in a parallel commit in that submodule.
Also:
- Stripped the ~150-line ASCII basin diagram from initBasinProperties
JSDoc; it now points at wiki/functional-description.md#basin-model.
- Trimmed the top-of-class JSDoc — the config-sections breakdown was
drifting from the schema anyway; wiki is now the source of truth.
- Tidied inline comments in _controlLevelBased, _scaleLevelToFlowPercent.
- Editor order reshuffled to match the bottom→top basin order:
minLevel, startLevel, maxLevel.
Breaking change for saved flows: existing pumpingStation nodes in
production flows reference the old field names and will need to be
re-entered in the editor. No compat shim — node is RnD/trial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,38 +11,110 @@ const {
|
||||
} = 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);
|
||||
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;
|
||||
this._levelState = { crossed: new Set(), dwellUntil: null };
|
||||
// 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;
|
||||
|
||||
// Compute basin geometry from config and seed the predicted volume
|
||||
// at the basin's minimum volume (outflowLevel or inflowLevel based
|
||||
// on config.hydraulics.minHeightBasedOn).
|
||||
this.initBasinProperties();
|
||||
this.logger.debug('PumpingStation initialized');
|
||||
}
|
||||
@@ -241,7 +313,7 @@ class PumpingStation {
|
||||
_controlLogic(direction) {
|
||||
switch (this.mode) {
|
||||
case 'levelbased':
|
||||
this._controlLevelBased(direction);
|
||||
this._controlLevelBased();
|
||||
break;
|
||||
case 'flowbased':
|
||||
this._controlFlowBased?.();
|
||||
@@ -253,9 +325,8 @@ class PumpingStation {
|
||||
}
|
||||
}
|
||||
|
||||
async _controlLevelBased(direction) {
|
||||
const { startLevel, stopLevel } = this.config.control.levelbased;
|
||||
const flowUnit = this.measurements.getUnit('flow');
|
||||
async _controlLevelBased() {
|
||||
const { startLevel, minLevel } = this.config.control.levelbased;
|
||||
const levelUnit = this.measurements.getUnit('level');
|
||||
|
||||
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
|
||||
@@ -264,38 +335,35 @@ class PumpingStation {
|
||||
return;
|
||||
}
|
||||
|
||||
// Continuous proportional control: command pumps whenever level is
|
||||
// above stopLevel. The percControl ramp gives:
|
||||
// - 0% at minFlowLevel (= startLevel) → pumps barely running
|
||||
// - linearly up to 100% at maxFlowLevel → all pumps full
|
||||
// - Below startLevel but above stopLevel: percControl < 0 → clamp
|
||||
// to 0 → MGC turns off pumps (graceful ramp-down instead of a
|
||||
// dead zone where pumps keep running at their last setpoint).
|
||||
if (level > stopLevel) {
|
||||
const rawPercControl = this._scaleLevelToFlowPercent(level);
|
||||
const percControl = Math.max(0, rawPercControl);
|
||||
this.logger.debug(`Controllevel based => Level ${level} percControl ${percControl}`);
|
||||
if (percControl > 0) {
|
||||
await this._applyMachineLevelControl(percControl);
|
||||
await this._applyMachineGroupLevelControl(percControl);
|
||||
} else {
|
||||
// Between stopLevel and startLevel with percControl ≤ 0:
|
||||
// tell MGC to scale back to 0 rather than leaving pumps
|
||||
// running at the last commanded setpoint.
|
||||
await this._applyMachineGroupLevelControl(0);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (level < stopLevel && direction === 'draining') {
|
||||
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());
|
||||
// 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() {
|
||||
@@ -389,7 +457,7 @@ class PumpingStation {
|
||||
const percent = this.interpolate.interpolate_lin_single_point(
|
||||
volume,
|
||||
this.basin.minVol,
|
||||
this.basin.maxVolOverflow,
|
||||
this.basin.maxVolAtOverflow,
|
||||
0,
|
||||
100
|
||||
);
|
||||
@@ -434,11 +502,10 @@ class PumpingStation {
|
||||
return null;
|
||||
}
|
||||
|
||||
//scaled for robin min 2039 - 2960 max 53.04
|
||||
_scaleLevelToFlowPercent(level) {
|
||||
const { minFlowLevel, maxFlowLevel } = this.config.control.levelbased;
|
||||
this.logger.debug(`Scaling minflow level : ${minFlowLevel} and maxflowLevel : ${maxFlowLevel}`);
|
||||
return this.interpolate.interpolate_lin_single_point(level, minFlowLevel, maxFlowLevel, 0, 100);
|
||||
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) {
|
||||
@@ -487,7 +554,7 @@ class PumpingStation {
|
||||
const percent = this.interpolate.interpolate_lin_single_point(
|
||||
nextVolume,
|
||||
this.basin.minVol,
|
||||
this.basin.maxVolOverflow,
|
||||
this.basin.maxVolAtOverflow,
|
||||
0,
|
||||
100
|
||||
);
|
||||
@@ -533,14 +600,14 @@ class PumpingStation {
|
||||
_computeRemainingTime(netFlow) {
|
||||
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) return { seconds: null, source: null };
|
||||
|
||||
const { heightOverflow, heightOutlet, surfaceArea } = this.basin;
|
||||
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(heightOverflow - lvl, 0) : Math.max(lvl - heightOutlet, 0);
|
||||
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;
|
||||
|
||||
@@ -561,7 +628,7 @@ class PumpingStation {
|
||||
/**
|
||||
* Safety controller — two hard rules:
|
||||
*
|
||||
* 1. BELOW stopLevel (dry-run): pumps CANNOT start.
|
||||
* 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.
|
||||
@@ -599,10 +666,10 @@ class PumpingStation {
|
||||
const dryRunEnabled = Boolean(enableDryRunProtection);
|
||||
const overfillEnabled = Boolean(enableOverfillProtection);
|
||||
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||
const triggerHighVol = this.basin.maxVolOverflow * ((Number(overfillThresholdPercent) || 0) / 100);
|
||||
const triggerHighVol = this.basin.maxVolAtOverflow * ((Number(overfillThresholdPercent) || 0) / 100);
|
||||
const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100));
|
||||
|
||||
// Rule 1: DRY-RUN — below stopLevel, pumps cannot run.
|
||||
// Rule 1: DRY-RUN — below minLevel, pumps cannot run.
|
||||
if (direction === 'draining') {
|
||||
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
|
||||
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
|
||||
@@ -655,43 +722,65 @@ class PumpingStation {
|
||||
|
||||
/* --------------------------- 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;
|
||||
const heightBasin = this.config.basin.height;
|
||||
const heightInlet = this.config.basin.heightInlet;
|
||||
const heightOutlet = this.config.basin.heightOutlet;
|
||||
const heightOverflow = this.config.basin.heightOverflow;
|
||||
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;
|
||||
const maxVol = heightBasin * surfaceArea;
|
||||
const maxVolOverflow = heightOverflow * surfaceArea;
|
||||
const minVolOut = heightOutlet * surfaceArea;
|
||||
const minVolIn = heightInlet * surfaceArea;
|
||||
const minVol = minHeightBasedOn === 'inlet' ? minVolIn : minVolOut;
|
||||
|
||||
// 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,
|
||||
heightInlet,
|
||||
heightOutlet,
|
||||
heightOverflow,
|
||||
inflowLevel,
|
||||
outflowLevel,
|
||||
overflowLevel,
|
||||
surfaceArea,
|
||||
maxVol,
|
||||
maxVolOverflow,
|
||||
minVolIn,
|
||||
minVolOut,
|
||||
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');
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -704,14 +793,15 @@ class PumpingStation {
|
||||
output.flowSource = this.state.flowSource;
|
||||
output.timeleft = this.state.seconds;
|
||||
output.volEmptyBasin = this.basin.volEmptyBasin;
|
||||
output.heightInlet = this.basin.heightInlet;
|
||||
output.heightOverflow = this.basin.heightOverflow;
|
||||
output.inflowLevel = this.basin.inflowLevel;
|
||||
output.overflowLevel = this.basin.overflowLevel;
|
||||
output.maxVol = this.basin.maxVol;
|
||||
output.minVol = this.basin.minVol;
|
||||
output.maxVolOverflow = this.basin.maxVolOverflow;
|
||||
output.minVolOut = this.basin.minVolOut;
|
||||
output.minVolIn = this.basin.minVolIn;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -740,9 +830,9 @@ if (require.main === module) {
|
||||
basin: {
|
||||
volume: 43.75,
|
||||
height: 10,
|
||||
heightInlet: 3,
|
||||
heightOutlet: 0.2,
|
||||
heightOverflow: 3.2
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 3.2
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
|
||||
Reference in New Issue
Block a user