Compare commits
4 Commits
5e2ebe4d96
...
a2189457f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2189457f6 | ||
|
|
4637448c49 | ||
|
|
61e0688f73 | ||
|
|
0ff55f5e9c |
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
18
wiki/README.md
Normal file
18
wiki/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# pumpingStation — Documentation
|
||||
|
||||
All docs and diagrams for this node live in this folder so they version-lock with the code they describe.
|
||||
|
||||
## Pages
|
||||
|
||||
- **[Functional Description](functional-description.md)** — operator-facing reference derived from `src/specificClass.js`: basin model, net-flow selection, safety interlocks, registration topology.
|
||||
- **[Control modes](modes/README.md)** — one page per control mode (`levelbased`, `flowbased`, …) describing how the mode uses the shared basin model to compute demand.
|
||||
|
||||
## Diagrams
|
||||
|
||||
Editable draw.io sources live in [`diagrams/`](diagrams/). See [`diagrams/README.md`](diagrams/README.md) for the editing workflow — open `.drawio` files in [draw.io](https://app.diagrams.net/), export to `.drawio.svg`, commit both.
|
||||
|
||||
The basin model is the shared canvas ([`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg)); per-mode transfer-function diagrams live under [`diagrams/modes/`](diagrams/modes/).
|
||||
|
||||
## Part of
|
||||
|
||||
This node is a git submodule of [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV). The EVOLV superproject has its own [`wiki/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki) with platform-level docs (architecture, concepts, shared manuals).
|
||||
71
wiki/diagrams/README.md
Normal file
71
wiki/diagrams/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Diagrams
|
||||
|
||||
Editable source diagrams for the pumpingStation wiki. Each diagram is a **`.drawio` + `.drawio.svg` pair**, so anyone can edit the source in [draw.io](https://app.diagrams.net/) without touching any Markdown.
|
||||
|
||||
## Why two files?
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `<name>.drawio` | Native draw.io XML. The canonical source. |
|
||||
| `<name>.drawio.svg` | SVG export of the same diagram (with source embedded). What the wiki actually renders, and what round-trips back into draw.io. |
|
||||
|
||||
Checking both in means the wiki renders for everyone, and the next editor picks up from exactly where the last one left off.
|
||||
|
||||
## Editing workflow
|
||||
|
||||
1. **Clone** the repo (you likely already have it if you're editing):
|
||||
```bash
|
||||
git clone https://gitea.wbd-rd.nl/RnD/pumpingStation.git
|
||||
cd pumpingStation/wiki/diagrams
|
||||
```
|
||||
2. **Open** the `.drawio` file in draw.io:
|
||||
- Web: [app.diagrams.net](https://app.diagrams.net/) → *Open Existing Diagram*, or drag-and-drop.
|
||||
- Desktop: [drawio-desktop](https://github.com/jgraph/drawio-desktop/releases).
|
||||
3. **Edit** — move shapes, change labels, adjust layout.
|
||||
4. **Export** to SVG with the source embedded:
|
||||
- `File → Export as → SVG…`
|
||||
- Check **Include a copy of my diagram** ← this is what lets future edits round-trip through the SVG.
|
||||
- Save next to the source as `<name>.drawio.svg` (overwrite).
|
||||
5. **Commit & push** both files:
|
||||
```bash
|
||||
git add wiki/diagrams/<name>.drawio wiki/diagrams/<name>.drawio.svg
|
||||
git commit -m "Update <name>: <what changed>"
|
||||
git push
|
||||
```
|
||||
|
||||
## Referencing a diagram from a wiki page
|
||||
|
||||
In any Markdown page under `wiki/`:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up in exports.
|
||||
|
||||
## Naming
|
||||
|
||||
- kebab-case, one concept per diagram.
|
||||
- Current diagrams:
|
||||
|
||||
| Diagram | Shows |
|
||||
|---|---|
|
||||
| `basin-model` | Physical basin cross-section — walls, pipes at their real heights, control thresholds cutting across, zone labels |
|
||||
| `control-zones` | Vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
|
||||
| `safety-rules` | Dry-run vs overfill rule asymmetry — which children stop, which keep running |
|
||||
|
||||
## Making a brand-new diagram
|
||||
|
||||
1. Open draw.io, start blank.
|
||||
2. Draw it.
|
||||
3. `File → Save As…` → `wiki/diagrams/<name>.drawio`.
|
||||
4. `File → Export as → SVG…` with **Include a copy of my diagram** checked → save as `wiki/diagrams/<name>.drawio.svg`.
|
||||
5. Reference from the wiki page with ``.
|
||||
6. Add an entry to the table above.
|
||||
7. Commit all three files together (`.drawio`, `.drawio.svg`, updated `.md`).
|
||||
|
||||
## These starters are rough
|
||||
|
||||
The `.drawio` files and their matching `.drawio.svg` exports committed here are **placeholders** — layout is approximate, colors and fonts are defaults, no fine alignment. They're meant to be a starting point; open them in draw.io and refine.
|
||||
|
||||
Both formats are round-trippable: open either the `.drawio` or the `.drawio.svg` in draw.io and it will load the editable model. (The SVG has the drawio XML embedded in a `content="…"` attribute on the root `<svg>` element — that's what lets draw.io re-open its own SVG exports.)
|
||||
109
wiki/diagrams/basin-model.drawio
Normal file
109
wiki/diagrams/basin-model.drawio
Normal file
@@ -0,0 +1,109 @@
|
||||
<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="basin-model" id="basinModel">
|
||||
<mxGraphModel dx="1200" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="900" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="Basin model — physical layout + control thresholds" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="20" width="500" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="tank" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E6F2FF;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="80" width="260" height="520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="deadvol" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#9FC5E8;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="302" y="550" width="256" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="freeboard_label" value="freeboard" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="90" width="240" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_line" value="" style="endArrow=none;html=1;strokeColor=#B22222;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="145" as="sourcePoint" />
|
||||
<mxPoint x="620" y="145" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="overflow_label_l" value="heightOverflow" style="text;html=1;fontSize=12;align=right;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="130" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_label_r" value="spill → measure" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="130" width="140" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_line" value="" style="endArrow=none;html=1;strokeColor=#D68910;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="200" as="sourcePoint" />
|
||||
<mxPoint x="620" y="200" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_label_l" value="maxFlowLevel" style="text;html=1;fontSize=12;align=right;fontColor=#D68910;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="185" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="scaling_label" value="SCALING RANGE (levelbased: demand ramps 0→100%)" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="255" width="240" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="startlevel_line" value="" style="endArrow=none;html=1;strokeColor=#1E8449;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="345" as="sourcePoint" />
|
||||
<mxPoint x="620" y="345" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="startlevel_label_l" value="startLevel" style="text;html=1;fontSize=12;align=right;fontColor=#1E8449;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="330" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="deadzone_label" value="DEAD ZONE (hysteresis — keep last cmd)" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="360" width="240" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inflow_arrow" value="" style="endArrow=classic;html=1;strokeColor=#1F4E79;strokeWidth=3;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="150" y="410" as="sourcePoint" />
|
||||
<mxPoint x="300" y="410" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="inflow_label" value="INFLOW" style="text;html=1;fontSize=13;fontStyle=1;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="90" y="395" width="70" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inlet_label" value="heightInlet" style="text;html=1;fontSize=12;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="570" y="400" width="90" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stoplevel_line" value="" style="endArrow=none;html=1;strokeColor=#6C3483;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="465" as="sourcePoint" />
|
||||
<mxPoint x="620" y="465" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="stoplevel_label_l" value="stopLevel" style="text;html=1;fontSize=12;align=right;fontColor=#6C3483;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="450" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stoplevel_label_r" value="unconditional STOP" style="text;html=1;fontSize=12;align=left;fontColor=#6C3483;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="450" width="160" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="buffer_label" value="BUFFER" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="490" width="240" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outflow_arrow" value="" style="endArrow=classic;html=1;strokeColor=#1F4E79;strokeWidth=3;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="560" y="540" as="sourcePoint" />
|
||||
<mxPoint x="720" y="540" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="outflow_label" value="OUTFLOW" style="text;html=1;fontSize=13;fontStyle=1;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="525" width="80" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_label_l" value="heightOutlet" style="text;html=1;fontSize=12;align=right;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="525" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_label_r" value="dry-run trip" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="550" width="120" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="deadvol_label" value="dead volume" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="560" width="240" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="floor_label" value="floor (0)" style="text;html=1;fontSize=11;align=right;" vertex="1" parent="1">
|
||||
<mxGeometry x="190" y="590" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="basin_label" value="heightBasin" style="text;html=1;fontSize=11;align=right;" vertex="1" parent="1">
|
||||
<mxGeometry x="180" y="70" width="60" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
6
wiki/diagrams/basin-model.drawio.svg
Normal file
6
wiki/diagrams/basin-model.drawio.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 566 KiB |
102
wiki/diagrams/control-zones.drawio
Normal file
102
wiki/diagrams/control-zones.drawio
Normal file
@@ -0,0 +1,102 @@
|
||||
<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="control-zones" id="controlZones">
|
||||
<mxGraphModel dx="1000" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="700" pageHeight="800" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="levelbased mode — three zones" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="20" width="500" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="axis" value="" style="endArrow=classic;html=1;strokeColor=#000;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="280" y="600" as="sourcePoint" />
|
||||
<mxPoint x="280" y="80" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="axis_label" value="level" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="60" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overflow" value="heightOverflow — weir crest (spill → measure)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="130" width="380" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="140" as="sourcePoint" />
|
||||
<mxPoint x="290" y="140" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="run_band" value="RUN — linear 0 → 100 %" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#1E8449;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="160" width="220" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="maxflow" value="maxFlowLevel — 100 % demand" style="text;html=1;fontSize=12;align=left;fontColor=#D68910;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="265" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_tick" value="" style="endArrow=none;html=1;strokeColor=#D68910;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="275" as="sourcePoint" />
|
||||
<mxPoint x="295" y="275" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="ramp_label" value="(ramp — demand scales linearly with level)" style="text;html=1;fontSize=11;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="300" width="320" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="startlevel" value="startLevel — 0 % demand (ramp starts)" style="text;html=1;fontSize=12;align=left;fontColor=#1E8449;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="335" width="340" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="start_tick" value="" style="endArrow=none;html=1;strokeColor=#1E8449;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="345" as="sourcePoint" />
|
||||
<mxPoint x="295" y="345" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dead_band" value="DEAD ZONE — hysteresis, keep last cmd" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#F57C00;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="360" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="inlet" value="heightInlet — inflow pipe" style="text;html=1;fontSize=12;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="395" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inlet_tick" value="" style="endArrow=none;html=1;strokeColor=#1F4E79;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="405" as="sourcePoint" />
|
||||
<mxPoint x="290" y="405" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stoplevel" value="stopLevel — unconditional STOP" style="text;html=1;fontSize=12;align=left;fontColor=#6C3483;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="440" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stop_tick" value="" style="endArrow=none;html=1;strokeColor=#6C3483;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="450" as="sourcePoint" />
|
||||
<mxPoint x="295" y="450" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stop_band" value="pumps OFF (MGC shutdown)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#F4ECF7;strokeColor=#6C3483;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="465" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="outlet" value="heightOutlet — outflow pipe (dry-run trip here)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="510" width="360" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="520" as="sourcePoint" />
|
||||
<mxPoint x="290" y="520" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="floor" value="0 (floor)" style="text;html=1;fontSize=11;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="580" width="60" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
162
wiki/diagrams/control-zones.drawio.svg
Normal file
162
wiki/diagrams/control-zones.drawio.svg
Normal file
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 660" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="control-zones" id="controlZones">
|
||||
<mxGraphModel dx="1000" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="700" pageHeight="800" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="levelbased mode — three zones" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="20" width="500" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="axis" value="" style="endArrow=classic;html=1;strokeColor=#000;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="280" y="600" as="sourcePoint" />
|
||||
<mxPoint x="280" y="80" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="axis_label" value="level" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="60" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overflow" value="heightOverflow — weir crest (spill → measure)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="130" width="380" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="140" as="sourcePoint" />
|
||||
<mxPoint x="290" y="140" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="run_band" value="RUN — linear 0 → 100 %" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#1E8449;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="160" width="220" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="maxflow" value="maxFlowLevel — 100 % demand" style="text;html=1;fontSize=12;align=left;fontColor=#D68910;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="265" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_tick" value="" style="endArrow=none;html=1;strokeColor=#D68910;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="275" as="sourcePoint" />
|
||||
<mxPoint x="295" y="275" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="ramp_label" value="(ramp — demand scales linearly with level)" style="text;html=1;fontSize=11;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="300" width="320" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="startlevel" value="startLevel — 0 % demand (ramp starts)" style="text;html=1;fontSize=12;align=left;fontColor=#1E8449;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="335" width="340" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="start_tick" value="" style="endArrow=none;html=1;strokeColor=#1E8449;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="345" as="sourcePoint" />
|
||||
<mxPoint x="295" y="345" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dead_band" value="DEAD ZONE — hysteresis, keep last cmd" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#F57C00;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="360" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="inlet" value="heightInlet — inflow pipe" style="text;html=1;fontSize=12;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="395" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inlet_tick" value="" style="endArrow=none;html=1;strokeColor=#1F4E79;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="405" as="sourcePoint" />
|
||||
<mxPoint x="290" y="405" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stoplevel" value="stopLevel — unconditional STOP" style="text;html=1;fontSize=12;align=left;fontColor=#6C3483;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="440" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stop_tick" value="" style="endArrow=none;html=1;strokeColor=#6C3483;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="450" as="sourcePoint" />
|
||||
<mxPoint x="295" y="450" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stop_band" value="pumps OFF (MGC shutdown)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#F4ECF7;strokeColor=#6C3483;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="465" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="outlet" value="heightOutlet — outflow pipe (dry-run trip here)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="510" width="360" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="520" as="sourcePoint" />
|
||||
<mxPoint x="290" y="520" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="floor" value="0 (floor)" style="text;html=1;fontSize=11;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="580" width="60" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>">
|
||||
<title>levelbased mode — three zones</title>
|
||||
<defs>
|
||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#000" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<text x="350" y="30" text-anchor="middle" font-weight="bold" font-size="16">levelbased mode — three zones</text>
|
||||
|
||||
<!-- Vertical level axis -->
|
||||
<line x1="280" y1="600" x2="280" y2="80" stroke="#000" stroke-width="2" marker-end="url(#arr)" />
|
||||
<text x="260" y="75" text-anchor="end" font-weight="bold" font-size="13">level</text>
|
||||
|
||||
<!-- heightOverflow -->
|
||||
<line x1="270" y1="140" x2="290" y2="140" stroke="#B22222" stroke-width="2" />
|
||||
<text x="300" y="144" fill="#B22222" font-size="12">heightOverflow — weir crest (spill → measure)</text>
|
||||
|
||||
<!-- RUN band -->
|
||||
<rect x="300" y="160" width="240" height="110" fill="#E8F5E9" stroke="#1E8449" />
|
||||
<text x="420" y="220" text-anchor="middle" font-size="13" fill="#1E8449" font-weight="bold">RUN</text>
|
||||
<text x="420" y="238" text-anchor="middle" font-size="12" fill="#1E8449">linear 0 → 100 %</text>
|
||||
|
||||
<!-- maxFlowLevel -->
|
||||
<line x1="265" y1="275" x2="295" y2="275" stroke="#D68910" stroke-width="3" />
|
||||
<text x="305" y="279" fill="#D68910" font-size="12" font-weight="bold">maxFlowLevel — 100 % demand</text>
|
||||
|
||||
<!-- Ramp label -->
|
||||
<text x="305" y="314" font-size="11" font-style="italic">(ramp — demand scales linearly with level)</text>
|
||||
|
||||
<!-- startLevel -->
|
||||
<line x1="265" y1="345" x2="295" y2="345" stroke="#1E8449" stroke-width="3" />
|
||||
<text x="305" y="349" fill="#1E8449" font-size="12" font-weight="bold">startLevel — 0 % demand (ramp starts)</text>
|
||||
|
||||
<!-- DEAD ZONE band -->
|
||||
<rect x="300" y="360" width="240" height="80" fill="#FFF8E1" stroke="#F57C00" />
|
||||
<text x="420" y="390" text-anchor="middle" font-size="13" fill="#B78200" font-weight="bold">DEAD ZONE</text>
|
||||
<text x="420" y="408" text-anchor="middle" font-size="12" fill="#B78200">hysteresis — keep last cmd</text>
|
||||
|
||||
<!-- heightInlet (inside dead zone) -->
|
||||
<line x1="270" y1="405" x2="290" y2="405" stroke="#1F4E79" stroke-width="2" />
|
||||
<text x="550" y="409" fill="#1F4E79" font-size="12">heightInlet</text>
|
||||
|
||||
<!-- stopLevel -->
|
||||
<line x1="265" y1="450" x2="295" y2="450" stroke="#6C3483" stroke-width="3" />
|
||||
<text x="305" y="454" fill="#6C3483" font-size="12" font-weight="bold">stopLevel — unconditional STOP</text>
|
||||
|
||||
<!-- STOP band -->
|
||||
<rect x="300" y="465" width="240" height="80" fill="#F4ECF7" stroke="#6C3483" />
|
||||
<text x="420" y="500" text-anchor="middle" font-size="13" fill="#6C3483" font-weight="bold">pumps OFF</text>
|
||||
<text x="420" y="518" text-anchor="middle" font-size="12" fill="#6C3483">(MGC shutdown)</text>
|
||||
|
||||
<!-- heightOutlet -->
|
||||
<line x1="270" y1="540" x2="290" y2="540" stroke="#B22222" stroke-width="2" />
|
||||
<text x="305" y="544" fill="#B22222" font-size="12">heightOutlet — outflow pipe (dry-run trip)</text>
|
||||
|
||||
<!-- floor -->
|
||||
<line x1="265" y1="600" x2="295" y2="600" stroke="#000" stroke-width="2" />
|
||||
<text x="305" y="604" font-size="11">0 (floor)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
104
wiki/diagrams/modes/levelbased.drawio.svg
Normal file
104
wiki/diagrams/modes/levelbased.drawio.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
58
wiki/diagrams/safety-rules.drawio
Normal file
58
wiki/diagrams/safety-rules.drawio
Normal file
@@ -0,0 +1,58 @@
|
||||
<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="safety-rules" id="safetyRules">
|
||||
<mxGraphModel dx="1200" dy="700" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="900" pageHeight="700" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="Safety rules — asymmetric by direction" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="20" width="600" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dryrun_box" value="DRY-RUN (direction = draining)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#E65100;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_upstream" value="upstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_downstream" value="downstream children — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_machinegroups" value="machineGroups — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_control" value="control loop — BLOCKED" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_note" value="safetyControllerActive = true Pumps must stop before sucking air." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="290" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overfill_box" value="OVERFILL (direction = filling)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="480" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_upstream" value="upstream children — STOP ⚠" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#C62828;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_downstream" value="downstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_machinegroups" value="machineGroups — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_control" value="control loop — ACTIVE" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_note" value="Level control keeps commanding downstream MGC. ⚠ "upstream STOP" is only correct in a cascaded layout. In a gravity-sewer station the inflow can't be stopped — log the spill instead." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="290" width="300" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="trigger_title" value="Triggers (either condition fires the rule):" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="450" width="740" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="trigger_list" value="• vol < triggerLowVol (triggerLowVol = minVol × (1 + pct/100)) • vol > triggerHighVol (triggerHighVol = maxVolOverflow × pct/100) • remainingTime < timeleftToFullOrEmptyThresholdSeconds (if enabled)" style="text;html=1;fontSize=12;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="480" width="740" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
99
wiki/diagrams/safety-rules.drawio.svg
Normal file
99
wiki/diagrams/safety-rules.drawio.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="safety-rules" id="safetyRules">
|
||||
<mxGraphModel dx="1200" dy="700" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="900" pageHeight="700" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="Safety rules — asymmetric by direction" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="20" width="600" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dryrun_box" value="DRY-RUN&#10;(direction = draining)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#E65100;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_upstream" value="upstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_downstream" value="downstream children — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_machinegroups" value="machineGroups — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_control" value="control loop — BLOCKED" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_note" value="safetyControllerActive = true&#10;&#10;Pumps must stop before sucking air." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="290" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overfill_box" value="OVERFILL&#10;(direction = filling)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="480" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_upstream" value="upstream children — STOP ⚠" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#C62828;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_downstream" value="downstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_machinegroups" value="machineGroups — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_control" value="control loop — ACTIVE" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_note" value="Level control keeps commanding downstream MGC.&#10;&#10;⚠ &quot;upstream STOP&quot; is only correct in a cascaded layout. In a gravity-sewer station the inflow can&apos;t be stopped — log the spill instead." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="290" width="300" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="trigger_title" value="Triggers (either condition fires the rule):" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="450" width="740" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="trigger_list" value="• vol &lt; triggerLowVol (triggerLowVol = minVol × (1 + pct/100))&#10;• vol &gt; triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)&#10;• remainingTime &lt; timeleftToFullOrEmptyThresholdSeconds (if enabled)" style="text;html=1;fontSize=12;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="480" width="740" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>">
|
||||
<title>Safety rules — asymmetric by direction</title>
|
||||
|
||||
<text x="450" y="30" text-anchor="middle" font-weight="bold" font-size="16">Safety rules — asymmetric by direction</text>
|
||||
|
||||
<!-- DRY-RUN box -->
|
||||
<rect x="80" y="80" width="340" height="340" fill="#FFF3E0" stroke="#E65100" stroke-width="2" />
|
||||
<text x="250" y="112" text-anchor="middle" font-weight="bold" font-size="14">DRY-RUN</text>
|
||||
<text x="250" y="130" text-anchor="middle" font-size="13" fill="#6F4A19">(direction = draining)</text>
|
||||
|
||||
<text x="100" y="162" font-size="13">upstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="100" y="188" font-size="13" fill="#E65100">downstream children — <tspan font-weight="bold">STOP</tspan></text>
|
||||
<text x="100" y="214" font-size="13" fill="#E65100">machineGroups — <tspan font-weight="bold">STOP</tspan></text>
|
||||
<text x="100" y="240" font-size="13" fill="#E65100">control loop — <tspan font-weight="bold">BLOCKED</tspan></text>
|
||||
|
||||
<line x1="100" y1="268" x2="400" y2="268" stroke="#E65100" stroke-dasharray="3 3" />
|
||||
<text x="100" y="294" font-size="12" font-style="italic">safetyControllerActive = true</text>
|
||||
<text x="100" y="316" font-size="12" font-style="italic">Pumps must stop before sucking air.</text>
|
||||
|
||||
<!-- OVERFILL box -->
|
||||
<rect x="480" y="80" width="340" height="340" fill="#FFEBEE" stroke="#C62828" stroke-width="2" />
|
||||
<text x="650" y="112" text-anchor="middle" font-weight="bold" font-size="14">OVERFILL</text>
|
||||
<text x="650" y="130" text-anchor="middle" font-size="13" fill="#7A1919">(direction = filling)</text>
|
||||
|
||||
<text x="500" y="162" font-size="13" fill="#C62828">upstream children — <tspan font-weight="bold">STOP</tspan> ⚠</text>
|
||||
<text x="500" y="188" font-size="13">downstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="500" y="214" font-size="13">machineGroups — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="500" y="240" font-size="13">control loop — <tspan font-weight="bold">ACTIVE</tspan></text>
|
||||
|
||||
<line x1="500" y1="268" x2="800" y2="268" stroke="#C62828" stroke-dasharray="3 3" />
|
||||
<text x="500" y="294" font-size="12" font-style="italic">Level control keeps commanding downstream MGC.</text>
|
||||
<text x="500" y="324" font-size="12" font-style="italic" fill="#C62828">⚠ "upstream STOP" is only correct in a cascaded layout.</text>
|
||||
<text x="500" y="342" font-size="12" font-style="italic" fill="#C62828">In a gravity-sewer station the inflow can't be</text>
|
||||
<text x="500" y="360" font-size="12" font-style="italic" fill="#C62828">stopped — log the spill instead.</text>
|
||||
|
||||
<!-- Triggers block -->
|
||||
<text x="80" y="470" font-weight="bold" font-size="13">Triggers (either condition fires the rule):</text>
|
||||
<text x="100" y="498" font-size="12">• vol < triggerLowVol (triggerLowVol = minVol × (1 + pct/100))</text>
|
||||
<text x="100" y="520" font-size="12">• vol > triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)</text>
|
||||
<text x="100" y="542" font-size="12">• remainingTime < timeleftToFullOrEmptyThresholdSeconds (if enabled)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
330
wiki/functional-description.md
Normal file
330
wiki/functional-description.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: pumpingStation — Functional Description
|
||||
node: pumpingStation
|
||||
updated: 2026-04-22
|
||||
status: draft
|
||||
---
|
||||
|
||||
# pumpingStation — Functional Description
|
||||
|
||||
The `pumpingStation` node models an S88 **Process Cell**: a wet-well basin with inflow and outflow, wrapped around one or more pump controllers. Every second it recomputes the basin's water balance, picks the most trustworthy net-flow source, runs its safety interlocks, and finally commands its children (individual pumps, `machineGroupControl`, or nested pumping stations) so the level stays inside the safe operating band.
|
||||
|
||||
This page is the operator-facing reference, derived from [`src/specificClass.js`](../src/specificClass.js). For the 3-tier code layout see [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md); for the atomic pump model see the [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki).
|
||||
|
||||
> **Diagrams on this page are editable.** Sources live in [`diagrams/`](diagrams/) — open the `.drawio` file in [draw.io](https://app.diagrams.net/), export to SVG, commit. See [`diagrams/README.md`](diagrams/README.md).
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Node category | EVOLV |
|
||||
| S88 level | Process Cell (`#0c99d9`, lane L5) |
|
||||
| Inputs | 1 (message-driven) |
|
||||
| Outputs | 3 — `process` / `dbase` / `parent` |
|
||||
| Tick period | 1 s |
|
||||
| Basin model | Rectangular prismatic — `volume = level × surfaceArea` |
|
||||
| Canonical units (internal) | Pa, m³/s, W, K, m, m³ |
|
||||
| Control modes implemented | `levelbased`, `manual` (placeholders for `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid`) |
|
||||
| Default flow dead-band | `1e-4 m³/s` (≈ 0.36 m³/h) |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Construct.** The node merges the user's editor config over the schema defaults, creates the measurement store, and seeds the predicted volume at the basin's operational floor (`minVol`).
|
||||
2. **Register children.** Sensors, pumps, machine groups, and nested stations register via the Port-2 handshake. The station subscribes only to the *highest-level aggregator* for predicted flow to avoid double-counting (MGC if present, otherwise the individual pump).
|
||||
3. **Tick loop (1 s).** `_updatePredictedVolume → _selectBestNetFlow → _safetyController → _controlLogic → state snapshot → output`.
|
||||
|
||||
## Editor configuration
|
||||
|
||||
Every field on the pumpingStation editor maps directly to the config schema in `generalFunctions/src/configs/pumpingStation.json`.
|
||||
|
||||
### Basin geometry (section `basin`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Basin Volume (m³)** | `1` | Total geometric volume of the empty basin (floor to rim). |
|
||||
| **Basin Height (m)** | `1` | Physical wall height from floor to rim. |
|
||||
| **Inlet Elevation (m)** | `2` | Centre of the inlet pipe, measured from the floor. |
|
||||
| **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 (`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 = 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. |
|
||||
|
||||
### Control (section `control`)
|
||||
|
||||
| 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. |
|
||||
| **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. |
|
||||
|
||||
### Safety (section `safety`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Time To Empty/Full (s)** | `0` | If > 0, triggers safety when predicted time-to-overflow or time-to-empty falls below this value. `0` disables time-based protection. |
|
||||
| **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 = 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 `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
|
||||
|
||||
All commands enter on the single input port. `msg.topic` selects the handler; `msg.payload` carries the argument.
|
||||
|
||||
### `changemode`
|
||||
|
||||
```json
|
||||
{ "topic": "changemode", "payload": "manual" }
|
||||
```
|
||||
|
||||
Switches the active control strategy. The new mode must be in `config.control.allowedModes` — unknown values are rejected with a warning. Typical transitions: `levelbased ⇄ manual` for operator override during maintenance.
|
||||
|
||||
### `calibratePredictedVolume`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedVolume", "payload": 3.4 }
|
||||
```
|
||||
|
||||
Hard-reset the predicted volume time-series to the supplied value (m³). Also rewrites the predicted level (derived from the constant-area geometry) and resets the internal flow-integrator state. Use this when a trustworthy measured level becomes available.
|
||||
|
||||
### `calibratePredictedLevel`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedLevel", "payload": 1.8 }
|
||||
```
|
||||
|
||||
Same as above, but caller supplies a level (m). The predicted volume is recomputed via `volume = level × surfaceArea`.
|
||||
|
||||
### `q_in`
|
||||
|
||||
```json
|
||||
{ "topic": "q_in", "payload": 300, "unit": "l/s" }
|
||||
```
|
||||
|
||||
Inject a **manual inflow** into the basin. Registered as a predicted flow under the synthetic child `manual-qin` at position `in`. Useful when no physical inflow sensor is wired but the inflow is known externally (e.g. fed from a sewer model).
|
||||
|
||||
### `Qd`
|
||||
|
||||
```json
|
||||
{ "topic": "Qd", "payload": 75 }
|
||||
```
|
||||
|
||||
Forward a manual demand to every child aggregator (MGC first, then any direct pumps). **Only honoured when `config.control.mode === 'manual'`** — in any other mode the command is logged and discarded. Mirrors how `rotatingMachine` gates commands behind its mode field. The interpretation of the number depends on the child's scaling (`absolute` = m³/h, `normalized` = 0–100 %).
|
||||
|
||||
### `registerChild`
|
||||
|
||||
Internal. Child nodes (measurements, rotatingMachines, machineGroupControls, nested pumpingStations) emit this on their Port 2 a few hundred ms after deploy. The station resolves the Node-RED node id back to the source object and registers it via `childRegistrationUtils`.
|
||||
|
||||
## Output ports
|
||||
|
||||
### Port 0 — process data
|
||||
|
||||
Delta-compressed payload (only changed fields per tick). Keys follow the standard 4-segment format `<type>.<variant>.<position>.<childId>` plus a handful of top-level state fields merged in by `getOutput()`:
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `volume.predicted.atequipment.default` | Running predicted volume from the flow integrator (m³). |
|
||||
| `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) / (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. |
|
||||
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow − outflow). |
|
||||
| `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`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
|
||||
| `percControl` | Last demand (0–100+ %) 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.
|
||||
|
||||
### Port 1 — dbase (InfluxDB)
|
||||
|
||||
Line-protocol payload for the `telemetry` bucket. Tags stay low-cardinality (station name, asset type); fields carry the numeric state. See [EVOLV — InfluxDB Schema Design](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/concepts/influxdb-schema-design.md).
|
||||
|
||||
### Port 2 — parent
|
||||
|
||||
`{ topic: "registerChild", payload: <this-node-id>, positionVsParent, distance }` — fired once ~100 ms after deploy so an upstream cascade can discover this station. Nested stations use this to register with an outer `pumpingStation` parent.
|
||||
|
||||
## Basin model
|
||||
|
||||
The basin is modelled as a rectangular prism with constant cross-section. Everything derives from `volume = level × surfaceArea`.
|
||||
|
||||

|
||||
|
||||
*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): `outflowLevel ≤ minLevel < inflowLevel < startLevel < maxLevel ≤ overflowLevel`.
|
||||
|
||||
> ⚠️ 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:
|
||||
|
||||
● maxVolAtOverflow ● maxVolAtOverflow
|
||||
│ │
|
||||
● inflowLevel ● inflowLevel ─── minVol
|
||||
│ │
|
||||
● outflowLevel ──── minVol ● outflowLevel
|
||||
│ │
|
||||
● floor ● floor
|
||||
|
||||
Buffer counts as usable stock. Buffer reserved; 0% fill
|
||||
starts at the inlet.
|
||||
```
|
||||
|
||||
## Net-flow selection
|
||||
|
||||
Every tick, `_selectBestNetFlow()` walks a priority ladder and returns the first net flow that clears the dead-band (`|flow| ≥ flowThreshold`):
|
||||
|
||||
```
|
||||
priority source note
|
||||
|
||||
1 ────● measured.flow real sensors on inflow/outflow
|
||||
│
|
||||
2 ────● predicted.flow manual q_in + pump-curve outputs
|
||||
│
|
||||
3 ────● level:measured dL/dt × surfaceArea
|
||||
│
|
||||
4 ────● level:predicted dL/dt of the integrator
|
||||
│
|
||||
5 ────● steady (fallback) warn, return { value: 0, source: null }
|
||||
```
|
||||
|
||||
Both **measured** and **predicted** variants are always computed and stored, regardless of which one drives control. The active source surfaces on Port 0 as `flowSource`, so operators can watch sensor drift (measured diverges from predicted), validate the volume integrator, and diagnose "which source was active when X happened?".
|
||||
|
||||
The inflow / outflow alias map is deliberately wide so measurements (`upstream`/`downstream`) and predicted-flow subscriptions (`in`/`out`) both feed the same aggregator:
|
||||
|
||||
```js
|
||||
flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
|
||||
```
|
||||
|
||||
## Control logic
|
||||
|
||||
The `pumpingStation` supports multiple control modes. Each mode is a **policy that sets the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and produces a demand (0 – 100 %)** — the two safety thresholds (`dryRunLevel`, `overflowLevel`) are mode-independent and handled by the safety layer below.
|
||||
|
||||
Every mode gets its own page under [`modes/`](modes/README.md) with a consistent layout (inputs, threshold policy, demand formula, edge cases) so they can be compared side-by-side. Currently:
|
||||
|
||||
| Mode | Status | Page |
|
||||
|---|---|---|
|
||||
| `levelbased` | ✅ implemented | [modes/levelbased.md](modes/levelbased.md) |
|
||||
| `manual` | ✅ implemented (via `Qd` topic) | — |
|
||||
| `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid` | 🚧 placeholder in code | — |
|
||||
|
||||
See [`modes/README.md`](modes/README.md) for the index and page template.
|
||||
|
||||
## Safety controller
|
||||
|
||||
`_safetyController` runs **before** `_controlLogic` every tick. Two rules, deliberately asymmetric — *dry-run protects the pumps from running themselves into air*, *overfill protects the basin from spilling*.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
A missing volume reading is treated as a hard fault: every direct machine is sent `execSequence: shutdown` and `safetyControllerActive` latches. Calibrate predicted volume (`calibratePredictedVolume`) or wire a level measurement to recover.
|
||||
|
||||
## Registration — which children count as flow?
|
||||
|
||||
`_registerPredictedFlowChild` subscribes only to the *highest-level aggregator* to prevent double-counting.
|
||||
|
||||
```
|
||||
Without MGC: With MGC:
|
||||
|
||||
[ PumpingStation ] [ PumpingStation ]
|
||||
│ │ │ │
|
||||
│ │ │ [ MGC ]
|
||||
│ │ │ │ │ │
|
||||
● ● ● ● ● ●
|
||||
(each pump subscribed (only MGC is subscribed;
|
||||
directly) MGC aggregates its pumps)
|
||||
|
||||
N flow subscriptions. 1 flow subscription.
|
||||
Risk: double-count if an Pumps' flow is already
|
||||
MGC is added later. inside the MGC total.
|
||||
```
|
||||
|
||||
Measurement children register separately via `_registerMeasurementChild` and feed the `measured` variant — they never collide with the predicted-flow subscription. Nested `pumpingStation` children are always subscribed and expose their net flow at the parent's position.
|
||||
|
||||
## Node status badge
|
||||
|
||||
Updated every second by `_updateNodeStatus` in `nodeClass.js`:
|
||||
|
||||
```
|
||||
⬆️ 42.3% | V=4.57 / 10.80 m³ | net: 180 m³/h | t≈12 min
|
||||
```
|
||||
|
||||
| Symbol | Direction | Badge colour |
|
||||
|---|---|---|
|
||||
| ⬆️ | `filling` | blue |
|
||||
| ⬇️ | `draining` | orange |
|
||||
| ⏸️ | `steady` | green |
|
||||
| ❔ | `unknown` / missing measurements | grey |
|
||||
|
||||
## Example flow
|
||||
|
||||
The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pumpingstation-3pumps-dashboard/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/examples/pumpingstation-3pumps-dashboard). It wires three `rotatingMachine` pumps beneath an MGC beneath a `pumpingStation`, with the dashboard layout rule set (see the [EVOLV flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md)) — a useful template for any new station.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `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. |
|
||||
| Pumps keep running during overfill | Intended — overfill safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
|
||||
| Predicted volume drifts away from measured | Flow integrator has no reference — flows might have the wrong sign, or `unit` is mis-declared. | Call `calibratePredictedVolume` periodically from a measured level. |
|
||||
|
||||
## Running it locally
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://gitea.wbd-rd.nl/RnD/EVOLV.git
|
||||
cd EVOLV
|
||||
docker compose up -d
|
||||
# Node-RED: http://localhost:1880 InfluxDB: :8086 Grafana: :3000
|
||||
```
|
||||
|
||||
Then in Node-RED: **Import ▸ Examples ▸ EVOLV ▸ pumpingStation** (or open `examples/pumpingstation-3pumps-dashboard/flow.json`).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd nodes/pumpingStation
|
||||
npm test
|
||||
```
|
||||
|
||||
Unit tests live in `test/specificClass.test.js` — construction, basin derivation, measurement registration, net-flow selection, safety interlocks, and calibration.
|
||||
|
||||
## Related
|
||||
|
||||
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki) — atomic pump model beneath pumpingStation / MGC.
|
||||
- [measurement wiki](https://gitea.wbd-rd.nl/RnD/measurement/wiki) — sensor conditioning for inflow, outflow, level, and pressure inputs.
|
||||
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki) — how MGC coordinates multiple pumps.
|
||||
- [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md) — the entry → nodeClass → specificClass pattern.
|
||||
- [EVOLV — Group Optimization](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/group-optimization.md) — pump-group scheduling theory.
|
||||
- [EVOLV — flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) — the lane / group / channel layout rules used by the demo flows.
|
||||
29
wiki/modes/README.md
Normal file
29
wiki/modes/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Control modes
|
||||
|
||||
Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it sets the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC.
|
||||
|
||||
The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-independent and are enforced by the safety layer outside any mode. They never appear in a mode's policy.
|
||||
|
||||
## Template
|
||||
|
||||
Every mode page follows the same structure:
|
||||
|
||||
1. **At a glance** — one sentence + small fact table (inputs, output, status)
|
||||
2. **Diagram** — reference to `../diagrams/modes/<mode>.drawio.svg`
|
||||
3. **Inputs** — what signals the mode reads
|
||||
4. **Threshold policy** — how it sets/adjusts `minLevel`, `startLevel`, `maxLevel`
|
||||
5. **Demand formula** — how it turns inputs into a 0-100 % demand for the MGC
|
||||
6. **Edge cases** — cold start, sensor dropout, interaction with safety layer
|
||||
7. **Related** — links to other modes + functional description
|
||||
|
||||
## Implementation status
|
||||
|
||||
| Mode | Status | Page |
|
||||
|---|---|---|
|
||||
| `levelbased` | ✅ implemented | [levelbased.md](levelbased.md) |
|
||||
| `flowbased` | 🚧 placeholder in code | — |
|
||||
| `pressureBased` | 🚧 placeholder in code | — |
|
||||
| `percentageBased` | 🚧 placeholder in code | — |
|
||||
| `powerBased` | 🚧 placeholder in code | — |
|
||||
| `hybrid` | 🚧 placeholder in code | — |
|
||||
| `manual` | ✅ implemented (Qd topic) | — |
|
||||
84
wiki/modes/levelbased.md
Normal file
84
wiki/modes/levelbased.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Level-based mode
|
||||
mode: levelbased
|
||||
status: implemented
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Level-based mode
|
||||
|
||||
The simplest and most widely deployed control strategy. Demand is a direct, *static* piecewise-linear function of basin level — no feedback loop, no predictions beyond the level measurement itself. This page uses the [shared basin model](../functional-description.md#basin-model); see [`modes/README.md`](README.md) for the template other mode pages follow.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Signal driving demand | basin level (measured, predicted fallback) |
|
||||
| Output | demand 0–100 % forwarded to every MGC child |
|
||||
| Thresholds adjusted at runtime? | No — static from editor config |
|
||||
| Use when | Inflow is sewer-gravity (no smart metering) and operator wants a predictable, inspectable response |
|
||||
|
||||
## Diagram
|
||||
|
||||

|
||||
|
||||
*Editable source: [`../diagrams/modes/levelbased.drawio.svg`](../diagrams/modes/levelbased.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — it round-trips).*
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current level | `measurement` child (`measured`) → predicted from volume integrator (fallback) | X-axis of the transfer function |
|
||||
| `config.control.levelbased.minLevel` | editor, static | below → pumps hard OFF |
|
||||
| `config.control.levelbased.startLevel` | editor, static | where demand-ramp starts |
|
||||
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
|
||||
|
||||
The three control thresholds are the **only** mode-specific configuration. Nothing here is recomputed at runtime.
|
||||
|
||||
## Threshold policy
|
||||
|
||||
| Threshold | Source | Adjustable at runtime? |
|
||||
|---|---|---|
|
||||
| `minLevel` | `config.control.levelbased.minLevel` | No |
|
||||
| `startLevel` | `config.control.levelbased.startLevel` | No |
|
||||
| `maxLevel` | `config.control.levelbased.maxLevel` | No |
|
||||
|
||||
That this policy is trivial (all static) is **the defining simplicity of this mode**. Modes like `powerBased` or future `weather-aware` variants will recompute these thresholds on the fly.
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
if level < minLevel:
|
||||
demand = 0
|
||||
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
|
||||
elif level < startLevel:
|
||||
demand = <previous demand> # dead zone — hold last command (hysteresis)
|
||||
elif level <= maxLevel:
|
||||
demand = lerp(level, [startLevel, maxLevel], [0 %, 100 %])
|
||||
else:
|
||||
demand = 100 % # saturated; MGC clamps internally if overshoot
|
||||
```
|
||||
|
||||
Where `lerp` is linear interpolation. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cold start with level in the dead zone.** `demand` has no prior value; it defaults to `0`. Pumps stay OFF until the level first crosses `startLevel` upward. Once it does, normal ramp-and-hold behaviour engages.
|
||||
- **Level sensor drops out mid-run.** `_selectBestNetFlow` falls back to predicted level (computed from the volume integrator) — the mode doesn't care which variant wins, it just reads the chosen level.
|
||||
- **Both sensor and predictor unavailable.** The mode's preconditions fail; `_controlLogic` logs a warning and exits without issuing a command. The last-known demand is held, which is safe.
|
||||
- **Level crosses `maxLevel` upward.** Demand saturates at 100 %. Level may still continue rising if inflow > station capacity — this is the scenario that trips the overflow-safety layer (see below).
|
||||
- **Level crosses `dryRunLevel` downward.** The **safety layer** (not this mode) force-shuts all downstream pumps regardless of what demand the mode is commanding. The mode's demand is effectively overridden until level climbs back above `dryRunLevel + hysteresis_margin`.
|
||||
- **Level crosses `overflowLevel` upward.** The safety layer logs the spill event and raises an alarm. The mode continues commanding at 100 % — which is what you want, because the pumps should keep draining as fast as physically possible. (See [functional description § Safety controller](../functional-description.md#safety-controller) for the gravity-sewer caveat.)
|
||||
|
||||
## Why this is worth migrating off of
|
||||
|
||||
Level-based is fine for steady-state sewer inflows. It has two known weaknesses:
|
||||
|
||||
1. **Predictable, not proactive.** It can't *pre-empty* the basin ahead of a forecasted storm or a power-price peak. Modes like `weather-aware` or `powerBased` can — by moving `startLevel` down or up at runtime.
|
||||
2. **Thresholds assume pump capacity is fixed.** If you add or remove pumps, the `startLevel ↔ maxLevel` band that gave smooth 0-100 % coverage no longer matches the new capacity. Flow-based and percentage-based modes are less brittle to capacity changes because they close the loop on *what you actually measure* (outflow or fill %) rather than *what you assume the level→capacity map is*.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model, net-flow selection, safety layer (shared across all modes)
|
||||
- [modes/README.md](README.md) — mode index + template
|
||||
- Other mode pages: *to be written* (`flowbased.md`, `pressurebased.md`, `percentagebased.md`, `powerbased.md`, `hybrid.md`, `manual.md`)
|
||||
Reference in New Issue
Block a user