diff --git a/pumpingStation.html b/pumpingStation.html
index 3ca10db..e172172 100644
--- a/pumpingStation.html
+++ b/pumpingStation.html
@@ -86,6 +86,7 @@
shiftLevel: { value: 0 },
shiftArmPercent: { value: 95 },
startLevel: { value: null },
+ stopLevel: { value: null },
minLevel: { value: null },
maxLevel: { value: null },
flowSetpoint: { value: null },
@@ -413,6 +414,11 @@
m
+
from basin above
— m
@@ -469,6 +475,7 @@
+
@@ -484,6 +491,7 @@
(cheaper than guarding each one). They're hidden via display:none. -->
+
diff --git a/src/editor/bounds.js b/src/editor/bounds.js
index 9388765..acdc387 100644
--- a/src/editor/bounds.js
+++ b/src/editor/bounds.js
@@ -66,6 +66,13 @@
max ?? inlet ?? start ?? EPS,
basinHeight);
+ // stopLevel — explicit pump-off threshold. Must sit between
+ // dryRunLevel and startLevel (so it can be reached during draining
+ // before pumps re-engage).
+ setBounds('stopLevel',
+ Number.isFinite(dryRun) ? dryRun + EPS : EPS,
+ start ?? inlet ?? max ?? overflow ?? basinHeight);
+
// Shift inputs (only relevant when shifted ramp enabled).
if (shiftEnabled) {
setBounds('shiftLevel',
diff --git a/src/editor/mode-preview.js b/src/editor/mode-preview.js
index 691a2aa..65d5783 100644
--- a/src/editor/mode-preview.js
+++ b/src/editor/mode-preview.js
@@ -25,6 +25,11 @@
const start = fNum('startLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
+ // Optional stopLevel — explicit pump-off threshold. Drawn as its
+ // own marker line; does NOT shift the ramp foot. Must be < startLevel
+ // for the marker to render.
+ const stopRaw = fNum('stopLevel');
+ const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
// (no separate input). Below dryRunLevel the runtime hard-stops;
// we draw it as the leftmost vertical marker so the user sees
@@ -85,11 +90,14 @@
return pts.join(' ');
};
- // Up curve: same as before.
+ // Up curve. Foot is startLevel (the configured pump-on threshold and
+ // ramp foot per the runtime in _controlLevelBased). The OFF baseline
+ // is drawn for level < startLevel; at startLevel demand jumps from
+ // OFF to 0 % and ramps up to 100 % at maxLevel.
const up = document.getElementById('ps-mode-curve-up');
const down = document.getElementById('ps-mode-curve-down');
const downLabel = document.getElementById('ps-mode-curve-down-label');
- if (up) up.setAttribute('points', buildPath(start, inlet, max));
+ if (up) up.setAttribute('points', buildPath(start, start, max));
// Shifted-DOWN curve (only when shift enabled): represents the
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
@@ -152,6 +160,7 @@
[
['dryRunLevel', dryRun],
['startLevel', start],
+ ['stopLevel', stop],
['inflowLevel', inlet],
['maxLevel', max],
['overflowLevel', overflow],
diff --git a/src/nodeClass.js b/src/nodeClass.js
index dbeb909..86dde41 100644
--- a/src/nodeClass.js
+++ b/src/nodeClass.js
@@ -66,6 +66,7 @@ class nodeClass {
levelbased:{
minLevel:uiConfig.minLevel,
startLevel:uiConfig.startLevel,
+ stopLevel: uiConfig.stopLevel,
maxLevel:uiConfig.maxLevel,
curveType: uiConfig.levelCurveType || uiConfig.curveType,
logCurveFactor: uiConfig.logCurveFactor,
diff --git a/src/specificClass.js b/src/specificClass.js
index 4b6e76b..aae56eb 100644
--- a/src/specificClass.js
+++ b/src/specificClass.js
@@ -119,6 +119,28 @@ class PumpingStation {
this._shiftHoldValue = null;
this._lastDirection = null;
+ // --- stopLevel hysteresis (Schmitt trigger) ---
+ // Levelbased control uses two thresholds:
+ // - startLevel: ramp foot AND rising-edge engage point. Demand
+ // scales 0..100 % over [startLevel, maxLevel].
+ // - stopLevel: falling-edge disengage point. Pumps stay engaged
+ // (running at minimum flow) while level drains through
+ // [stopLevel, startLevel]; below stopLevel they're turned off.
+ //
+ // _stopHystRunning is the engaged-state flag: flips TRUE when level
+ // crosses startLevel on the way up, FALSE when level crosses stopLevel
+ // on the way down. While engaged AND level < startLevel (i.e. the
+ // basin is draining through the dead band) the controller emits a
+ // small keep-alive percControl so MGC keeps a single pump running
+ // until level reaches stopLevel. Without this, percControl=0 in the
+ // dead band would let MGC turn the pump off, the basin would refill,
+ // and the pump would oscillate at startLevel instead of running for
+ // a full drain stroke.
+ //
+ // Editor preview also reads _stopHystRunning to shade the hysteresis
+ // band; runtime semantics are now explicit (no longer "bookkeeping").
+ this._stopHystRunning = false;
+
// --- Flow dead-band ---
// flowThreshold (m3/s) prevents control actions on noise.
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
@@ -379,13 +401,45 @@ class PumpingStation {
this.percControl = 0;
this._shiftHoldValue = null;
this._shiftArmed = false;
+ this._stopHystRunning = false;
this._lastDirection = direction;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
- // Up-curve value (always defined; foot=inflowLevel, top=maxLevel).
- const upPct = this._scaleLevelToFlowPercent(level, this.basin?.inflowLevel ?? startLevel, cfg.maxLevel);
+ // stopLevel hysteresis (Schmitt trigger).
+ // _stopHystRunning becomes TRUE on rising edge at startLevel
+ // FALSE on falling edge at stopLevel
+ // While engaged AND level < startLevel (basin draining through the
+ // dead band), the controller emits a small keep-alive percControl so
+ // a single pump keeps running until level reaches stopLevel. Without
+ // hysteresis the pump would oscillate at startLevel because the
+ // up-curve goes through 0 there.
+ const stopLvl = Number(cfg.stopLevel);
+ const stopThresholdActive = Number.isFinite(stopLvl) && stopLvl >= 0 && stopLvl < cfg.maxLevel;
+
+ if (stopThresholdActive && level <= stopLvl) {
+ // Hard off: drained past stopLevel.
+ this.percControl = 0;
+ this._stopHystRunning = false;
+ this._lastDirection = direction;
+ Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
+ return;
+ }
+ // Update Schmitt-trigger engaged state.
+ if (stopThresholdActive) {
+ if (!this._stopHystRunning && level >= startLevel) this._stopHystRunning = true;
+ // disengage on falling edge is handled by the `level <= stopLvl` block above.
+ } else {
+ // No stopLevel configured → no hysteresis; engaged only while level >= startLevel.
+ this._stopHystRunning = level >= startLevel;
+ }
+
+ // Up-curve value. Foot stays at startLevel (per the user-set demand
+ // ramp), top is maxLevel. Below startLevel the curve gives 0 %; above
+ // maxLevel it saturates at 100 %.
+ const rampFoot = startLevel;
+ const upPct = this._scaleLevelToFlowPercent(level, rampFoot, cfg.maxLevel);
// Update arming flag.
if (cfg.enableShiftedRamp) {
@@ -423,9 +477,23 @@ class PumpingStation {
&& direction === 'draining' && this._shiftHoldValue != null;
if (!inDrainingHold) {
- // Up curve: 0 % below inflow, scaled inflow..max → 0..100, saturates above max.
- if (level < (this.basin?.inflowLevel ?? startLevel)) {
- percControl = 0;
+ // Up curve: 0 % below the ramp foot (startLevel), scaled
+ // startLevel..maxLevel → 0..100 %, saturates above maxLevel.
+ // While engaged via the stopLevel Schmitt trigger AND level is
+ // inside the dead band [stopLevel, startLevel], emit a small
+ // keep-alive value so MGC's normalized scaling resolves to flow.min
+ // (a single pump at minimum stable speed) and the basin actually
+ // drains. Configurable via levelbased.deadZoneKeepAlivePercent
+ // (default 1%). Ramp foot stays at startLevel — keep-alive is a
+ // separate "engaged in dead band" signal, not a shifted ramp.
+ if (level < rampFoot) {
+ if (stopThresholdActive && this._stopHystRunning) {
+ const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
+ ? Number(cfg.deadZoneKeepAlivePercent) : 1;
+ percControl = Math.max(0, keepAlive);
+ } else {
+ percControl = 0;
+ }
} else {
percControl = Math.max(0, upPct);
}
@@ -484,6 +552,16 @@ class PumpingStation {
*/
async forwardDemandToChildren(demand) {
this.logger.info(`Manual demand forwarded: ${demand}`);
+ // Manual-mode explicit stop: MGC's handleInput now treats demand=0 as
+ // "hold current pump states" so the levelbased stopLevel hysteresis
+ // works. In manual mode the operator setting Qd=0 should still mean
+ // "stop now", so we issue an explicit turnOff and short-circuit.
+ if (Number(demand) <= 0) {
+ if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
+ Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
+ }
+ return;
+ }
// Forward to machine groups (MGC)
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
await Promise.all(