Hold-then-ramp shift semantics + shiftArmPercent + e2e tests

Runtime (specificClass.js):
- Replace the "shift left both ramp ends" geometry with a true
  hold-then-ramp hysteresis driven by output %, not level:
  • Up-curve % crosses shiftArmPercent on the way up → ARM.
  • Filling→draining transition while armed → capture the up-curve %
    at that moment as _shiftHoldValue.
  • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue
    (horizontal hold, matching the dashed segment in the SVG).
  • Draining + level in [start, shift] → output ramps holdValue → 0 %
    along the same curve shape (linear or log) as the up curve.
  • Draining + level < startLevel → 0 % AND disarm.
  • Returning to filling clears holdValue, stays armed; next drain
    transition captures a fresh hold so bouncing fills rearm cleanly.
  • Disarm only when level ≤ startLevel.
- New _curveShape(x) helper for shared linear/log shaping.
- Removed legacy _levelBasedRampStart / _levelBasedRampTop /
  _updateShiftArmed in favour of the inline state machine.

Adapter (nodeClass.js):
- Pipe shiftArmPercent through to control.levelbased.

Editor (pumpingStation.html + src/editor/):
- Add shiftArmPercent input row (% with unit) to the mode side panel
  (only shown when shifted ramp is enabled). Default 95 %.
- Add the horizontal arming-% line + label inside the mode SVG —
  this is the "% Threshold triggering shifted ramp down" line from
  the original drawing that had been missing.
- Redraw the shifted-down curve to match the SVG geometry literally:
  100 % flat from maxLevel → shiftLevel, then ramp shiftLevel →
  startLevel down to 0 %, OFF below startLevel. Preview shows the
  worst-case envelope (hold = 100 %); runtime hold is captured live.
- Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules
  preserved (start < shift ≤ max etc.).
- Auto-default shiftArmPercent to 95 when shift is enabled and the
  current value is missing or out of range.

Dashboard example (examples/basic-dashboard.flow.json):
- Parser now reads `level.predicted.atequipment.default` etc. The
  MeasurementContainer flatten format includes the implicit 'default'
  childId; consumers must include it. Comment in the parser points
  at the documenting source in generalFunctions.

Tests:
- test/basic: replace old level-armed-shift tests with two new ones
  that exercise the hold-then-ramp arming, capture, hold, ramp-down,
  disarm, and the bounce case (filling→draining→filling→draining
  captures a fresh hold each time).
- test/integration/shifted-ramp-end-to-end.test.js: new file. Drives
  Q_IN/Q_OUT through the full runtime tick with a controllable clock,
  asserting the same hysteresis path the dashboard exercises.
- test/integration/basic-dashboard-flow.test.js: fixture keys updated
  to the .default-suffixed form so they match the real flatten output.
56/56 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-06 11:46:46 +02:00
parent 8a6ca1baeb
commit de9a79b888
10 changed files with 550 additions and 95 deletions

View File

@@ -105,13 +105,19 @@ class PumpingStation {
// levelbased mode. Exposed in getOutput() for dashboards.
this.percControl = 0;
// --- Level-armed hysteresis state ---
// _shiftArmed flips true when level rises past shiftLevel (with
// enableShiftedRamp). While armed, the demand ramp's lower foot
// is startLevel instead of inflowLevel — so on the way down the
// pumps stay aggressive until level falls below startLevel, at
// which point the arm clears.
// --- Level-armed hysteresis state (see _controlLevelBased) ---
// _shiftArmed: true once up-curve output % crosses shiftArmPercent on
// the way up. Cleared when level drops to startLevel.
// _shiftHoldValue: captured on every filling→draining transition while
// armed. The output stays at this value while level drops from the
// flip point to shiftLevel; below shiftLevel it ramps to 0 % at
// startLevel (linear or log shape).
// _lastDirection: tracks the previous tick's direction so we can
// detect filling→draining transitions. We don't update it on
// 'steady' ticks so transitions through the dead-band are preserved.
this._shiftArmed = false;
this._shiftHoldValue = null;
this._lastDirection = null;
// --- Flow dead-band ---
// flowThreshold (m3/s) prevents control actions on noise.
@@ -339,8 +345,9 @@ class PumpingStation {
}
}
async _controlLevelBased(_direction) {
const { startLevel, minLevel } = this.config.control.levelbased;
async _controlLevelBased(direction) {
const cfg = this.config.control.levelbased;
const { startLevel, minLevel } = cfg;
const levelUnit = this.measurements.getUnit('level');
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
@@ -349,49 +356,121 @@ class PumpingStation {
return;
}
// Level-based pump control via MGC (see wiki/modes/levelbased.md).
// Level-based pump control via MGC. See wiki/modes/levelbased.md.
//
// Always:
// level < minLevel → STOP (unconditional MGC shutdown)
// level < startLevel → 0 % (pumps held off)
// level in [startLevel..rampStart] → 0 % (HOLD zone)
// level in [rampStart..maxLevel]0..100 % (linear or log curve)
// level > maxLevel → ≥100 % (MGC clamps internally)
// level < inflowLevel → 0 % (HOLD zone, pumps idle)
// level in [inflow..max] → up curve 0..100 % (linear or log)
// level > maxLevel → 100 % (MGC clamps internally)
//
// With enableShiftedRamp:
// rampStart = inflowLevel by default
// when level rises past shiftLevel → arm rampStart = startLevel
// when level drops below startLevel → disarm → rampStart = inflowLevel
// Without enableShiftedRamp: rampStart = inflowLevel always.
// With enableShiftedRamp (hysteresis):
// When up-curve % rises past shiftArmPercent → ARMED.
// On the next filling→draining transition while armedcapture
// hold = current up-curve %.
// While armed AND draining:
// level >= shiftLevel → output = hold (held)
// level in [start..shift] → output ramps hold→0 % over the range
// level < startLevel → output = 0 %
// While armed AND filling/steady → output = up curve (resets hold).
// Disarms only when level <= startLevel.
if (level < minLevel) {
this.percControl = 0;
this._shiftHoldValue = null;
this._shiftArmed = false;
this._lastDirection = direction;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
this._updateShiftArmed(level);
const rampStartLevel = this._levelBasedRampStart();
const rampTopLevel = this._levelBasedRampTop();
// Up-curve value (always defined; foot=inflowLevel, top=maxLevel).
const upPct = this._scaleLevelToFlowPercent(level, this.basin?.inflowLevel ?? startLevel, cfg.maxLevel);
// HOLD/MINIMUM DEMAND — below the active ramp start, command 0 %
// without latching dry-run. Dry-run remains the safety layer's job.
if (level < rampStartLevel) {
this.percControl = 0;
await this._applyMachineGroupLevelControl(0);
return;
// Update arming flag.
if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
if (!this._shiftArmed && upPct >= armPct) {
this._shiftArmed = true;
this.logger.debug(`Shift armed: upPct=${upPct} >= ${armPct}`);
}
} else {
this._shiftArmed = false;
}
if (level <= startLevel) {
this._shiftArmed = false;
this._shiftHoldValue = null;
}
// Capture hold on filling→draining transition while armed.
if (cfg.enableShiftedRamp && this._shiftArmed) {
if (this._lastDirection !== 'draining' && direction === 'draining') {
this._shiftHoldValue = upPct;
this.logger.debug(`Shift hold captured: ${upPct} % at level=${level}`);
} else if (direction === 'filling') {
// Returning to filling clears any captured hold; the next drain
// transition will recapture from the up curve.
this._shiftHoldValue = null;
}
}
if (direction === 'filling' || direction === 'draining') {
this._lastDirection = direction;
}
// Compute output.
let percControl;
const inDrainingHold = cfg.enableShiftedRamp && this._shiftArmed
&& 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;
} else {
percControl = Math.max(0, upPct);
}
} else {
const hold = this._shiftHoldValue;
const shift = cfg.shiftLevel;
if (!Number.isFinite(shift) || shift <= startLevel) {
// Bad config — fall back to up curve.
percControl = Math.max(0, upPct);
} else if (level >= shift) {
percControl = hold;
} else if (level > startLevel) {
// Ramp from (shiftLevel, hold) down to (startLevel, 0).
// Use the same curve shape (linear/log) as the up curve, scaled to
// peak at hold% at level=shiftLevel.
const x = (level - startLevel) / (shift - startLevel);
const shaped = this._curveShape(x);
percControl = Math.max(0, hold * shaped);
} else {
percControl = 0;
}
}
// RUN — above rampStartLevel, compute demand and forward to MGC.
// _scaleLevelToFlowPercent maps [rampStartLevel..rampTopLevel] → [0..100].
// Above rampTopLevel demand saturates at 100 %.
const rawPercControl = this._scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel);
const percControl = Math.max(0, rawPercControl);
this.percControl = percControl;
this.logger.debug(`Level-based control: level=${level} armed=${this._shiftArmed} foot=${rampStartLevel} top=${rampTopLevel} percControl=${percControl}`);
this.logger.debug(
`Level-based: level=${level} dir=${direction} armed=${this._shiftArmed} hold=${this._shiftHoldValue} pct=${percControl}`
);
await this._applyMachineGroupLevelControl(percControl);
}
// Apply the configured curve shape to a normalized x in [0,1].
// Returns shaped value in [0,1]. Linear by default; log when curveType
// is 'log' (with logCurveFactor).
_curveShape(x) {
const { curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor) : 9;
return Math.log1p(factor * clamped) / Math.log1p(factor);
}
return clamped;
}
_controlFlowBased() {
// placeholder for flow-based logic
}
@@ -528,43 +607,9 @@ class PumpingStation {
return null;
}
_levelBasedRampStart() {
const { startLevel, enableShiftedRamp } = this.config.control.levelbased;
const inflowLevel = this.basin?.inflowLevel;
if (enableShiftedRamp && this._shiftArmed) return startLevel;
if (Number.isFinite(inflowLevel)) return inflowLevel;
return startLevel;
}
_levelBasedRampTop() {
// Returns the upper level at which demand saturates at 100 %.
// While the shift is armed, top moves left from maxLevel to shiftLevel
// so output reaches 100 % earlier and stays at 100 % until level
// falls back through shiftLevel on the way down.
const { maxLevel, enableShiftedRamp, shiftLevel } = this.config.control.levelbased;
if (enableShiftedRamp && this._shiftArmed
&& Number.isFinite(shiftLevel) && shiftLevel > 0
&& shiftLevel <= maxLevel) {
return shiftLevel;
}
return maxLevel;
}
_updateShiftArmed(level) {
const { enableShiftedRamp, shiftLevel, startLevel } = this.config.control.levelbased;
if (!enableShiftedRamp) {
this._shiftArmed = false;
return;
}
const trigger = Number.isFinite(shiftLevel) && shiftLevel > 0 ? shiftLevel : null;
if (!this._shiftArmed && trigger != null && level >= trigger) {
this._shiftArmed = true;
this.logger.debug(`Shift armed at level=${level} (shiftLevel=${trigger})`);
} else if (this._shiftArmed && Number.isFinite(startLevel) && level < startLevel) {
this._shiftArmed = false;
this.logger.debug(`Shift disarmed at level=${level} (startLevel=${startLevel})`);
}
}
// (legacy _levelBasedRampStart/_levelBasedRampTop/_updateShiftArmed
// helpers were removed in favour of the inline state machine in
// _controlLevelBased — see that method's doc block.)
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;