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:
@@ -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 armed → capture
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user