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:
znetsixe
2026-04-22 16:13:59 +02:00
parent 4637448c49
commit a2189457f6
5 changed files with 232 additions and 141 deletions

View File

@@ -16,14 +16,23 @@
category: "EVOLV",
color: "#0c99d9", // color for the node based on the S88 schema
defaults: {
name: { value: "" },
// Define station-specific properties
simulator: { value: false },
basinVolume: { value: 1 }, // m³, total empty basin
basinHeight: { value: 1 }, // m, floor to top
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
heightOverflow: { value: 0.9 }, // m, overflow elevation
inflowLevel: { value: 0.8 }, // m, centre of inlet pipe above floor
outflowLevel: { value: 0.2 }, // m, centre of outlet pipe above floor
overflowLevel: { value: 0.9 }, // m, overflow elevation
defaultFluid: { value: "wastewater" },
inletPipeDiameter: { value: 0.3 }, // m
outletPipeDiameter: { value: 0.3 }, // m
pipelineLength: { value: 80 }, // m
maxDischargeHead: { value: 24 }, // m
staticHead: { value: 12 }, // m
maxInflowRate: { value: 200 }, // m³/h
temperatureReferenceDegC: { value: 15 },
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
enableDryRunProtection: { value: true },
enableOverfillProtection: { value: true },
@@ -60,9 +69,8 @@
// control strategy
controlMode: { value: "none" },
startLevel: { value: null },
stopLevel: { value: null },
minFlowLevel: { value: null },
maxFlowLevel: { value: null },
minLevel: { value: null },
maxLevel: { value: null },
flowSetpoint: { value: null },
flowDeadband: { value: null }
@@ -92,9 +100,9 @@
// NODE SPECIFIC
document.getElementById("node-input-basinVolume");
document.getElementById("node-input-basinHeight");
document.getElementById("node-input-heightInlet");
document.getElementById("node-input-heightOutlet");
document.getElementById("node-input-heightOverflow");
document.getElementById("node-input-inflowLevel");
document.getElementById("node-input-outflowLevel");
document.getElementById("node-input-overflowLevel");
document.getElementById("node-input-refHeight");
document.getElementById("node-input-basinBottomRef");
@@ -160,9 +168,8 @@
};
setNumberField('node-input-startLevel', this.startLevel);
setNumberField('node-input-stopLevel', this.stopLevel);
setNumberField('node-input-minFlowLevel', this.minFlowLevel);
setNumberField('node-input-maxFlowLevel', this.maxFlowLevel);
setNumberField('node-input-minLevel', this.minLevel);
setNumberField('node-input-maxLevel', this.maxLevel);
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
setNumberField('node-input-flowDeadband', this.flowDeadband);
@@ -180,7 +187,7 @@
node.minHeightBasedOn = document.getElementById("node-input-minHeightBasedOn").value || "outlet";
node.simulator = document.getElementById("node-input-simulator").checked;
["basinVolume","basinHeight","heightInlet","heightOutlet","heightOverflow","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
.forEach(field => {
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
});
@@ -194,9 +201,8 @@
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
node.startLevel = parseNum('node-input-startLevel');
node.stopLevel = parseNum('node-input-stopLevel');
node.minFlowLevel = parseNum('node-input-minFlowLevel');
node.maxFlowLevel = parseNum('node-input-maxFlowLevel');
node.minLevel = parseNum('node-input-minLevel');
node.maxLevel = parseNum('node-input-maxLevel');
node.flowSetpoint = parseNum('node-input-flowSetpoint');
node.flowDeadband = parseNum('node-input-flowDeadband');
@@ -230,16 +236,16 @@
<!-- Inlet/Outlet elevations -->
<div class="form-row">
<label for="node-input-heightInlet"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
<input type="number" id="node-input-heightInlet" min="0" step="0.01" />
<label for="node-input-inflowLevel"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
<input type="number" id="node-input-inflowLevel" min="0" step="0.01" />
</div>
<div class="form-row">
<label for="node-input-heightOutlet"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
<input type="number" id="node-input-heightOutlet" min="0" step="0.01" />
<label for="node-input-outflowLevel"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
<input type="number" id="node-input-outflowLevel" min="0" step="0.01" />
</div>
<div class="form-row">
<label for="node-input-heightOverflow"><i class="fa fa-tint"></i> Overflow Level (m)</label>
<input type="number" id="node-input-heightOverflow" min="0" step="0.01" />
<label for="node-input-overflowLevel"><i class="fa fa-tint"></i> Overflow Level (m)</label>
<input type="number" id="node-input-overflowLevel" min="0" step="0.01" />
</div>
<hr>
@@ -256,20 +262,16 @@
<div id="ps-mode-levelbased" class="ps-mode-section">
<div class="form-row">
<label for="node-input-startLevel">startLevel</label>
<label for="node-input-minLevel">minLevel (m)</label>
<input type="number" id="node-input-minLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-startLevel">startLevel (m)</label>
<input type="number" id="node-input-startLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-stopLevel">stopLevel</label>
<input type="number" id="node-input-stopLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-minFlowLevel">Min flow (m)</label>
<input type="number" id="node-input-minFlowLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-maxFlowLevel">Max flow (m)</label>
<input type="number" id="node-input-maxFlowLevel" placeholder="m" />
<label for="node-input-maxLevel">maxLevel (m)</label>
<input type="number" id="node-input-maxLevel" placeholder="m" />
</div>
</div>

View File

@@ -44,9 +44,9 @@ class nodeClass {
basin: {
volume: uiConfig.basinVolume,
height: uiConfig.basinHeight,
heightInlet: uiConfig.heightInlet,
heightOutlet: uiConfig.heightOutlet,
heightOverflow: uiConfig.heightOverflow,
inflowLevel: uiConfig.inflowLevel,
outflowLevel: uiConfig.outflowLevel,
overflowLevel: uiConfig.overflowLevel,
},
hydraulics: {
refHeight: uiConfig.refHeight,
@@ -56,10 +56,9 @@ class nodeClass {
control:{
mode: uiConfig.controlMode,
levelbased:{
minLevel:uiConfig.minLevel,
startLevel:uiConfig.startLevel,
stopLevel:uiConfig.stopLevel,
minFlowLevel:uiConfig.minFlowLevel,
maxFlowLevel:uiConfig.maxFlowLevel
maxLevel:uiConfig.maxLevel
}
},
safety:{
@@ -118,7 +117,7 @@ class nodeClass {
const level = pickVariant('level', ['measured', 'predicted'], 'atEquipment', 'm');
const netFlow = pickVariant('netFlowRate', ['measured', 'predicted'], 'atEquipment', 'm3/h');
const maxVolBeforeOverflow = ps.basin?.maxVolOverflow ?? ps.basin?.maxVol ?? 0;
const maxVolBeforeOverflow = ps.basin?.maxVolAtOverflow ?? ps.basin?.maxVol ?? 0;
const currentVolume = vol.value ?? 0;
const currentvolPercent = volPercent.value ?? 0;
const netFlowM3h = netFlow.value ?? 0;
@@ -254,6 +253,7 @@ class nodeClass {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.node.status({}); // clear node status badge
done();
});
}

View File

@@ -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);
// --- 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',

View File

@@ -29,9 +29,9 @@ function makeConfig(overrides = {}) {
basin: {
volume: 50, // m3 (empty basin volume)
height: 5, // m
heightInlet: 0.3, // m
heightOutlet: 0.2, // m
heightOverflow: 4.0, // m
inflowLevel: 0.3, // m
outflowLevel: 0.2, // m
overflowLevel: 4.0, // m
},
hydraulics: {
refHeight: 'NAP',
@@ -87,31 +87,31 @@ describe('pumpingStation specificClass', () => {
expect(ps.basin.maxVol).toBe(50);
});
it('should calculate maxVolOverflow = heightOverflow * surfaceArea', () => {
it('should calculate maxVolAtOverflow = overflowLevel * surfaceArea', () => {
const ps = new PumpingStation(makeConfig());
// 4.0 * 10 = 40
expect(ps.basin.maxVolOverflow).toBe(40);
expect(ps.basin.maxVolAtOverflow).toBe(40);
});
it('should calculate minVol = heightOutlet * surfaceArea', () => {
it('should calculate minVol = outflowLevel * surfaceArea', () => {
const ps = new PumpingStation(makeConfig());
// 0.2 * 10 = 2
expect(ps.basin.minVol).toBeCloseTo(2, 5);
});
it('should calculate minVolOut = heightInlet * surfaceArea', () => {
it('should calculate minVolAtOutflow = inflowLevel * surfaceArea', () => {
const ps = new PumpingStation(makeConfig());
// 0.3 * 10 = 3
expect(ps.basin.minVolOut).toBeCloseTo(3, 5);
expect(ps.basin.minVolAtOutflow).toBeCloseTo(3, 5);
});
it('should store the raw config values on basin', () => {
const ps = new PumpingStation(makeConfig());
expect(ps.basin.volEmptyBasin).toBe(50);
expect(ps.basin.heightBasin).toBe(5);
expect(ps.basin.heightInlet).toBe(0.3);
expect(ps.basin.heightOutlet).toBe(0.2);
expect(ps.basin.heightOverflow).toBe(4.0);
expect(ps.basin.inflowLevel).toBe(0.3);
expect(ps.basin.outflowLevel).toBe(0.2);
expect(ps.basin.overflowLevel).toBe(4.0);
});
});
@@ -246,13 +246,13 @@ describe('pumpingStation specificClass', () => {
describe('edge cases', () => {
it('should handle basin with zero height gracefully', () => {
// surfaceArea = volume / height => division by 0 gives Infinity
const config = makeConfig({ basin: { volume: 50, height: 0, heightInlet: 0, heightOutlet: 0, heightOverflow: 0 } });
const config = makeConfig({ basin: { volume: 50, height: 0, inflowLevel: 0, outflowLevel: 0, overflowLevel: 0 } });
const ps = new PumpingStation(config);
expect(ps.basin.surfaceArea).toBe(Infinity);
});
it('should handle basin with very small dimensions', () => {
const config = makeConfig({ basin: { volume: 0.001, height: 0.001, heightInlet: 0, heightOutlet: 0, heightOverflow: 0.0005 } });
const config = makeConfig({ basin: { volume: 0.001, height: 0.001, inflowLevel: 0, outflowLevel: 0, overflowLevel: 0.0005 } });
const ps = new PumpingStation(config);
expect(ps.basin.surfaceArea).toBeCloseTo(1, 5);
});

View File

@@ -47,13 +47,13 @@ Every field on the pumpingStation editor maps directly to the config schema in `
| **Outlet Elevation (m)** | `0.2` | Centre of the pump-suction pipe, measured from the floor. |
| **Overflow Level (m)** | `2.5` | Overflow-weir crest, measured from the floor. Above this → overfill safety. |
Constant cross-section is assumed: `surfaceArea = volume / height`. All derived volumes (`minVolOut`, `minVolIn`, `maxVolOverflow`, `maxVol`) are computed once in `initBasinProperties()` and kept on `station.basin`.
Constant cross-section is assumed: `surfaceArea = volume / height`. All derived volumes (`minVolAtOutflow`, `minVolAtInflow`, `maxVolAtOverflow`, `maxVol`) are computed once in `initBasinProperties()` and kept on `station.basin`.
### Hydraulics (section `hydraulics`)
| Field | Default | Meaning |
|---|---|---|
| **Minimum Height Based On** | `outlet` | `outlet``minVol = heightOutlet × area` (includes the buffer). `inlet``minVol = heightInlet × area` (buffer treated as unavailable). |
| **Minimum Height Based On** | `outlet` | `outlet``minVol = outflowLevel × area` (includes the buffer). `inlet``minVol = inflowLevel × area` (buffer treated as unavailable). |
| **Reference Height** | `NAP` | Vertical datum: `NAP` / `EVRF` / `EGM2008`. Metadata only — not used in math today. |
| **Basin Bottom (m Refheight)** | `0` | Absolute elevation of the basin floor, for cross-basin comparisons. |
@@ -62,10 +62,9 @@ Constant cross-section is assumed: `surfaceArea = volume / height`. All derived
| Field | Default | Meaning |
|---|---|---|
| **Control mode** | `levelbased` | Active control strategy. Schema enumerates seven modes; today `levelbased` is fully implemented, `manual` forwards demand via `Qd`, others are placeholders. |
| **startLevel (m)** | `1` | At or below this level, the station is in the DEAD ZONE — pumps stay in their last state. |
| **stopLevel (m)** | `1` | Below this level → unconditional MGC shutdown. |
| **Min flow (m)** | `1` | Bottom of the linear scaling range (0 % demand). Should equal `startLevel`. |
| **Max flow (m)** | `4` | Top of the linear scaling range (100 % demand). Typically ≈ `heightOverflow`. |
| **minLevel (m)** | `1` | Below this level → unconditional MGC shutdown. |
| **startLevel (m)** | `1` | Bottom of the linear scaling range (0 % demand — ramp starts here). |
| **maxLevel (m)** | `4` | Top of the linear scaling range (100 % demand). Typically ≈ `overflowLevel`. |
| **Flow setpoint** | `0` | Flow-based target (m³/h). Placeholder until `flowbased` is wired. |
| **Deadband** | `0` | Flow-based deadband (m³/h). Placeholder. |
@@ -77,14 +76,14 @@ Constant cross-section is assumed: `surfaceArea = volume / height`. All derived
| **Enable Dry-Run Protection** | `true` | If on, pumps are shut down once volume drops below the dry-run threshold while draining. |
| **Low Volume Threshold (%)** | `2` | Dry-run trigger: `triggerLowVol = minVol × (1 + pct/100)`. |
| **Enable Overfill Protection** | `true` | If on, upstream inflows are shut down once volume climbs above the overfill threshold while filling. |
| **High Volume Threshold (%)** | `98` | Overfill trigger: `triggerHighVol = maxVolOverflow × pct/100`. |
| **High Volume Threshold (%)** | `98` | Overfill trigger: `triggerHighVol = maxVolAtOverflow × pct/100`. |
### Output formats
- **Process Output** — format for Port 0 (`process` / `json` / `csv`).
- **Database Output** — format for Port 1 (`influxdb` / `json` / `csv`).
> **Tip — always configure every field.** The pumpingStation mixes geometry and control thresholds freely. Leaving `heightOverflow` at the schema default of 2.5 m while sizing the basin for 10 m walls produces nonsensical fill-percentages and spurious safety events. See the [EVOLV flow-layout rules §9](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) for the completeness rule.
> **Tip — always configure every field.** The pumpingStation mixes geometry and control thresholds freely. Leaving `overflowLevel` at the schema default of 2.5 m while sizing the basin for 10 m walls produces nonsensical fill-percentages and spurious safety events. See the [EVOLV flow-layout rules §9](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) for the completeness rule.
## Input topics
@@ -146,7 +145,7 @@ Delta-compressed payload (only changed fields per tick). Keys follow the standar
| `volume.measured.atequipment.default` | Volume derived from a `measured` level sensor (m³). |
| `level.predicted.atequipment.default` | Predicted level = `volume / area` (m). |
| `level.measured.<position>.<childId>` | Raw level sensor reading (m). |
| `volumePercent.predicted.atequipment.default` | `(vol - minVol) / (maxVolOverflow - minVol) × 100` (%). |
| `volumePercent.predicted.atequipment.default` | `(vol - minVol) / (maxVolAtOverflow - minVol) × 100` (%). |
| `flow.predicted.in.<childId>` | Inflow contribution from a registered child (m³/s internally; editor unit on output). |
| `flow.predicted.out.<childId>` | Outflow contribution from a registered child. |
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
@@ -154,7 +153,7 @@ Delta-compressed payload (only changed fields per tick). Keys follow the standar
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
| `flowSource` | Which variant drove the current control cycle (`measured`, `predicted`, `level:predicted`, `null`). |
| `timeleft` | Predicted seconds to overflow (while filling) or to dry-run (while draining). |
| `volEmptyBasin`, `heightInlet`, `heightOverflow`, `maxVol`, `maxVolOverflow`, `minVol`, `minVolIn`, `minVolOut`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
| `volEmptyBasin`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
| `percControl` | Last demand (0100+ %) forwarded to the machine group during level-based control. |
Consumers must cache and merge deltas — the example dashboard flows include a reusable function node that does exactly this.
@@ -175,20 +174,20 @@ The basin is modelled as a rectangular prism with constant cross-section. Everyt
*Editable source: [`diagrams/basin-model.drawio`](diagrams/basin-model.drawio). See [`diagrams/README.md`](diagrams/README.md) for the edit-and-export workflow.*
**Typical ordering** (bottom → top): `stopLevel < heightInlet < startLevel = minFlowLevel < maxFlowLevel ≤ heightOverflow`.
**Typical ordering** (bottom → top): `outflowLevel ≤ minLevel < inflowLevel < startLevel < maxLevel ≤ overflowLevel`.
> ⚠️ The comment block in `specificClass.js` currently says `startLevel ≤ heightInlet` (inlet above startLevel). The physical convention is the opposite: pumps start *before* the water reaches the gravity inlet, so `heightInlet < startLevel`. Worth fixing in the code comment next time that file is touched.
> ⚠️ The comment block in `specificClass.js` currently says `startLevel ≤ inflowLevel` (inlet above startLevel). The physical convention is the opposite: pumps start *before* the water reaches the gravity inlet, so `inflowLevel < startLevel`. Worth fixing in the code comment next time that file is touched.
**minHeightBasedOn** — which pipe defines `minVol`, the operational floor used for the initial seed, the dry-run trigger, and the 0 % point of the fill percentage:
```
outlet (default): inlet:
● maxVolOverflow ● maxVolOverflow
● maxVolAtOverflow ● maxVolAtOverflow
│ │
heightInletheightInlet ─── minVol
inflowLevelinflowLevel ─── minVol
│ │
heightOutlet ──── minVol ● heightOutlet
outflowLevel ──── minVol ● outflowLevel
│ │
● floor ● floor
@@ -242,7 +241,7 @@ See [`modes/README.md`](modes/README.md) for the index and page template.
![Safety rules — dry-run vs overfill](diagrams/safety-rules.drawio.svg)
During overfill, level-based control naturally commands ≥100 % on the downstream MGC because the level is above `maxFlowLevel`.
During overfill, level-based control naturally commands ≥100 % on the downstream MGC because the level is above `maxLevel`.
> ⚠️ **Known limitation — gravity-sewer context.** The "upstream STOP" action only makes sense in a **cascaded** station layout where the upstream equipment is an EVOLV-controllable pump or station. In a conventional wastewater wet-well the inflow is gravity-fed from the municipal sewer and **cannot be stopped** — attempting to would back up toilets. For that case the correct response to an overfill event is to **measure and log the spill over the weir** (for compliance reporting) and raise an alarm, while keeping downstream pumps at maximum demand. The current code fires `execSequence: shutdown` on upstream children regardless of what they are; that should be gated on "is the upstream actually controllable?" and supplemented with overflow-rate tracking. Tracked as follow-up work.
@@ -293,8 +292,8 @@ The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pump
| Symptom | Likely cause | Fix |
|---|---|---|
| `fill %` exceeds 100 % or is negative | Basin geometry inconsistent — e.g. `heightOverflow > heightBasin`, or `heightOutlet > heightInlet`. | Cross-check `0 < heightOutlet < heightInlet < heightOverflow ≤ heightBasin` in the editor. |
| Pumps never start in `levelbased` | Level is stuck in the DEAD ZONE between `stopLevel` and `startLevel`, or `minFlowLevel == maxFlowLevel` so scaling collapses. | Widen the control band: move `startLevel` above `stopLevel` and set `maxFlowLevel ≈ heightOverflow`. |
| `fill %` exceeds 100 % or is negative | Basin geometry inconsistent — e.g. `overflowLevel > heightBasin`, or `outflowLevel > inflowLevel`. | Cross-check `0 < outflowLevel < inflowLevel < overflowLevel ≤ heightBasin` in the editor. |
| Pumps never start in `levelbased` | Level is stuck in the DEAD ZONE between `minLevel` and `startLevel`, or `startLevel == maxLevel` so the scaling range collapses. | Widen the control band: move `startLevel` above `minLevel` and set `maxLevel ≈ overflowLevel`. |
| "No volume data available to safe guard system; shutting down all machines." in logs | No measured level, predicted volume not calibrated, and no inflow/outflow samples yet. | Issue `calibratePredictedVolume` (or `calibratePredictedLevel`) once at startup, or wire a level sensor. |
| `flowSource: null` and `direction: 'steady'` forever | Every flow / level signal falls inside the dead-band (default `1e-4 m³/s`). | Confirm flows are non-zero, or lower `config.general.flowThreshold` for a small-scale demo. |
| `Qd` ignored | Station is not in `manual` mode. | Send `{ topic: 'changemode', payload: 'manual' }` first, or fall back to level-based control. |