stopLevel Schmitt-trigger hysteresis + dead-zone keep-alive

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) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-08 11:20:36 +02:00
parent 6ab585bcc2
commit e2ebb31816
5 changed files with 110 additions and 7 deletions

View File

@@ -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(