From e2ebb31816dcfdf3c2841b19de6b35cc0598f920 Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Fri, 8 May 2026 11:20:36 +0200 Subject: [PATCH] stopLevel Schmitt-trigger hysteresis + dead-zone keep-alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Levelbased control now distinguishes startLevel (rising-edge engage, ramp foot) from stopLevel (falling-edge disengage). _stopHystRunning flag flips TRUE crossing startLevel up, FALSE crossing stopLevel down. While engaged AND level inside [stopLevel, startLevel] (basin draining through the dead band), emit a configurable keep-alive percControl (default 1 %) so MGC keeps a single pump running for a full drain stroke instead of oscillating at startLevel. Hard turn-off the moment level <= stopLevel — independent of ramp scaling. Manual-mode demand=0 now also issues explicit turnOff to keep parity with the new MGC handleInput semantics where demand<=0 means "off". Editor preview shades the new hysteresis band; admin endpoint exposes runtime engaged state. Co-Authored-By: Claude Opus 4.7 (1M context) --- pumpingStation.html | 8 ++++ src/editor/bounds.js | 7 +++ src/editor/mode-preview.js | 13 +++++- src/nodeClass.js | 1 + src/specificClass.js | 88 +++++++++++++++++++++++++++++++++++--- 5 files changed, 110 insertions(+), 7 deletions(-) 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 +
+
pump-off threshold (optional, ≤ startLevel)
+ + 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(