Compare commits

...

1 Commits

Author SHA1 Message Date
Rene De Ren
e2ebb31816 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>
2026-05-08 11:20:36 +02:00
5 changed files with 110 additions and 7 deletions

View File

@@ -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 @@
<input type="number" id="node-input-startLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#7D3C98" data-couples-line="ps-mode-line-stopLevel">
<div><label>stopLevel</label><div class="ps-sub">pump-off threshold (optional, ≤ startLevel)</div></div>
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
@@ -469,6 +475,7 @@
<!-- Vertical level-marker lines — span y=24..140 (top to baseline only, NOT into OFF tier). x set dynamically. -->
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
@@ -484,6 +491,7 @@
(cheaper than guarding each one). They're hidden via display:none. -->
<text id="ps-mode-label-dryRunLevel" style="display:none;"></text>
<text id="ps-mode-label-startLevel" style="display:none;"></text>
<text id="ps-mode-label-stopLevel" style="display:none;"></text>
<text id="ps-mode-label-inflowLevel" style="display:none;"></text>
<text id="ps-mode-label-maxLevel" style="display:none;"></text>
<text id="ps-mode-label-overflowLevel" style="display:none;"></text>

View File

@@ -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',

View File

@@ -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],

View File

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

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)) {
// 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(