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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user