Compare commits

..

1 Commits

Author SHA1 Message Date
Rene De Ren
de9a79b888 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>
2026-05-06 11:46:46 +02:00
10 changed files with 550 additions and 95 deletions

View File

@@ -295,7 +295,7 @@
"type": "function",
"z": "ps_tab_basic_dashboard",
"name": "Parse PS output",
"func": "const fields = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst snapshot = Object.assign({}, context.get('snapshot') || {}, fields);\ncontext.set('snapshot', snapshot);\nconst firstFinite = (...keys) => {\n for (const key of keys) {\n const value = Number(snapshot[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\nconst level = firstFinite('level.predicted.atequipment', 'level.measured.atequipment');\nconst volume = firstFinite('volume.predicted.atequipment', 'volume.measured.atequipment');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment', 'netFlowRate.measured.atequipment');\nconst demand = firstFinite('percControl');\nconst safety = snapshot.safetyState || 'normal';\nconst direction = snapshot.direction || 'unknown';\nconst overflow = snapshot.isOverflowing === true || snapshot.isOverflowing === 'true';\nconst timeleft = Number(snapshot.timeleft);\nconst fmt = (value, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '-';\nreturn [\n level == null ? null : { topic: 'level', payload: level },\n volume == null ? null : { topic: 'volume', payload: volume },\n demand == null ? null : { topic: 'demand', payload: demand },\n netFlow == null ? null : { topic: 'net_flow', payload: netFlow },\n { topic: 'safety', payload: `${safety} | overflowing=${overflow}` },\n { topic: 'snapshot', payload: `level=${fmt(level)} m | volume=${fmt(volume)} m3 | demand=${fmt(demand, 0)}% | direction=${direction} | t=${Number.isFinite(timeleft) ? Math.round(timeleft) + ' s' : '-'}` }\n];",
"func": "// MeasurementContainer flat keys are `${type}.${variant}.${position}.${childId}`.\n// When PS writes without an explicit .child(), the childId is the literal\n// string 'default' — DON'T strip it. See generalFunctions/src/measurements/\n// MeasurementContainer.js getFlattenedOutput for details.\nconst fields = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst snapshot = Object.assign({}, context.get('snapshot') || {}, fields);\ncontext.set('snapshot', snapshot);\nconst firstFinite = (...keys) => {\n for (const key of keys) {\n const value = Number(snapshot[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\nconst level = firstFinite('level.predicted.atequipment.default', 'level.measured.atequipment.default');\nconst volume = firstFinite('volume.predicted.atequipment.default', 'volume.measured.atequipment.default');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment.default', 'netFlowRate.measured.atequipment.default');\nconst demand = firstFinite('percControl');\nconst safety = snapshot.safetyState || 'normal';\nconst direction = snapshot.direction || 'unknown';\nconst overflow = snapshot.isOverflowing === true || snapshot.isOverflowing === 'true';\nconst timeleft = Number(snapshot.timeleft);\nconst fmt = (value, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '-';\nreturn [\n level == null ? null : { topic: 'level', payload: level },\n volume == null ? null : { topic: 'volume', payload: volume },\n demand == null ? null : { topic: 'demand', payload: demand },\n netFlow == null ? null : { topic: 'net_flow', payload: netFlow },\n { topic: 'safety', payload: `${safety} | overflowing=${overflow}` },\n { topic: 'snapshot', payload: `level=${fmt(level)} m | volume=${fmt(volume)} m3 | demand=${fmt(demand, 0)}% | direction=${direction} | t=${Number.isFinite(timeleft) ? Math.round(timeleft) + ' s' : '-'}` }\n];",
"outputs": 6,
"noerr": 0,
"initialize": "",

View File

@@ -83,6 +83,7 @@
logCurveFactor: { value: 9 },
enableShiftedRamp: { value: false },
shiftLevel: { value: 0 },
shiftArmPercent: { value: 95 },
startLevel: { value: null },
minLevel: { value: null },
maxLevel: { value: null },
@@ -417,10 +418,15 @@
<span class="ps-unit">m</span>
</div>
<div class="ps-row" id="ps-shiftLevel-row" data-stroke="#D68910" data-couples-line="ps-mode-line-shiftLevel" style="display:none;">
<div><label>shiftLevel</label><div class="ps-sub">arms hysteresis</div></div>
<div><label>shiftLevel</label><div class="ps-sub">held output drops here</div></div>
<input type="number" id="node-input-shiftLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" id="ps-shiftArmPercent-row" data-stroke="#D68910" data-couples-line="ps-mode-line-armPercent" style="display:none;">
<div><label>shiftArmPercent</label><div class="ps-sub">arms when output % crosses this</div></div>
<input type="number" id="node-input-shiftArmPercent" min="0" max="100" step="1" />
<span class="ps-unit">%</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-overflowLevel">
<div><label>overflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-overflow" class="ps-readonly-val"> m</span>
@@ -461,6 +467,10 @@
<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" />
<line id="ps-mode-line-shiftLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" style="display:none;" />
<!-- Horizontal arming-% line y is set DYNAMICALLY by the JS to the
shiftArmPercent value (in plot-y space). Spans full plot width. -->
<line id="ps-mode-line-armPercent" x1="52" x2="402" stroke="#D68910" stroke-dasharray="4 3" stroke-width="1" opacity="0.7" style="display:none;" />
<text id="ps-mode-label-armPercent" x="404" text-anchor="start" fill="#D68910" font-size="9" style="display:none;">arm%</text>
<!-- Axis labels y=180 row sits below the OFF baseline (y=160). x set dynamically. -->
<text id="ps-mode-label-dryRunLevel" y="180" text-anchor="middle" fill="#C0392B">dry run</text>
<text id="ps-mode-label-startLevel" y="180" text-anchor="middle" fill="#1E8449">start</text>
@@ -472,7 +482,7 @@
so they never collide with the title (y=14). Up-caption left-aligned at
x=60; down-caption to its right at x=210. Both font-size 10. -->
<text id="ps-mode-curve-up-label" x="60" y="205" fill="#1E8449" font-size="10"> ramp inletmax</text>
<text id="ps-mode-curve-down-label" x="210" y="205" fill="#D68910" font-size="10" style="display:none;"> shifted startshift</text>
<text id="ps-mode-curve-down-label" x="210" y="205" fill="#D68910" font-size="10" style="display:none;"> shifted (held @100% then ramp shiftstart)</text>
</svg>
</div>
</div>

View File

@@ -34,6 +34,8 @@
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
const shiftRaw = fNum('shiftLevel');
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
const armRaw = fNum('shiftArmPercent');
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
@@ -83,14 +85,40 @@
return pts.join(' ');
};
// Up curve: same as before.
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));
// Shifted-DOWN curve (only when shift enabled): represents the
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
// then linear/log ramp from (shiftLevel, 100 %) down to
// (startLevel, 0 %), then OFF below startLevel.
// Real runtime hold value depends on where direction flips, so the
// preview shows the maximum extent.
const buildShiftedDown = () => {
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
const pts = [];
// OFF baseline far-left to startLevel
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
// Jump 0 % at startLevel
pts.push(`${xFor(start)},${yForPct(0)}`);
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const lvl = start + t * (shift - start);
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
}
// Held at 100 % from shift → far-right
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
if (down) {
if (shiftEnabled) {
const shiftedTop = Number.isFinite(shift) && shift > start ? shift : max;
down.setAttribute('points', buildPath(start, start, shiftedTop));
down.setAttribute('points', buildShiftedDown());
down.style.display = '';
if (downLabel) downLabel.style.display = '';
} else {
@@ -100,6 +128,24 @@
}
}
// Horizontal arming-% line — only meaningful when shift enabled.
const armLine = document.getElementById('ps-mode-line-armPercent');
const armLabel = document.getElementById('ps-mode-label-armPercent');
if (armLine && armLabel) {
if (shiftEnabled) {
const yArm = yForPct(armPct);
armLine.setAttribute('y1', yArm);
armLine.setAttribute('y2', yArm);
armLabel.setAttribute('y', yArm - 2);
armLabel.textContent = `arm ${Math.round(armPct)}%`;
armLine.style.display = '';
armLabel.style.display = '';
} else {
armLine.style.display = 'none';
armLabel.style.display = 'none';
}
}
// Vertical level markers + axis labels.
[
['dryRunLevel', dryRun],
@@ -167,6 +213,8 @@
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
const shiftRow = document.getElementById('ps-shiftLevel-row');
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
const armRow = document.getElementById('ps-shiftArmPercent-row');
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
const logRow = document.getElementById('ps-log-factor-row');
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
@@ -179,6 +227,15 @@
shiftInput.value = (max * 0.9).toFixed(2);
}
}
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
// current value is missing / out of [0, 100].
const armInput = document.getElementById('node-input-shiftArmPercent');
if (shiftEnabled && armInput) {
const cur = parseFloat(armInput.value);
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
armInput.value = 95;
}
}
// Validation: ordering constraints.
const issues = [];
@@ -200,6 +257,9 @@
} else {
issues.push('shiftLevel is required when shifted ramp is enabled');
}
const armVal = Number(armInput?.value);
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
issues.push('shiftArmPercent must be in (0, 100]');
}
const warnBox = document.getElementById('ps-mode-validation');
if (warnBox) {

View File

@@ -68,6 +68,7 @@
ns.setNumberField('node-input-maxLevel', node.maxLevel);
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
@@ -87,7 +88,8 @@
// so the mode preview must redraw when either of those change.
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
'dryRunThresholdPercent',
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel'],
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
'shiftArmPercent'],
ns.modePreview.redraw
);

View File

@@ -55,6 +55,8 @@
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
const shiftLevelVal = parseNum('node-input-shiftLevel');
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
const armPctVal = parseNum('node-input-shiftArmPercent');
node.shiftArmPercent = Number.isFinite(armPctVal) ? armPctVal : 95;
const flowSetpoint = parseNum('node-input-flowSetpoint');
const flowDeadband = parseNum('node-input-flowDeadband');
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;

View File

@@ -70,7 +70,8 @@ class nodeClass {
curveType: uiConfig.levelCurveType || uiConfig.curveType,
logCurveFactor: uiConfig.logCurveFactor,
enableShiftedRamp: uiConfig.enableShiftedRamp,
shiftLevel: uiConfig.shiftLevel
shiftLevel: uiConfig.shiftLevel,
shiftArmPercent: uiConfig.shiftArmPercent
}
},
safety:{

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;

View File

@@ -303,14 +303,17 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
assert.equal(ps.percControl, 0);
});
await t.test('shift enabled: arming when level crosses shiftLevel; foot moves to startLevel', async () => {
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
@@ -319,25 +322,65 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Below shiftLevel: not yet armed → foot=inflowLevel, level 2.5 is in hold zone → 0%.
ps.calibratePredictedLevel(2.5);
await ps._controlLevelBased();
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
ps.calibratePredictedLevel(3.5);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, false);
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
ps.calibratePredictedLevel(3.6);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
ps.calibratePredictedLevel(2.75);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
// Below startLevel ⇒ output 0 % AND disarm.
ps.calibratePredictedLevel(1.9);
await ps._controlLevelBased('draining');
assert.equal(ps.percControl, 0);
assert.equal(ps._shiftArmed, false);
// Cross the shift trigger going up — at level >= shiftLevel, output saturates at 100%.
ps.calibratePredictedLevel(3.6);
await ps._controlLevelBased();
assert.equal(ps._shiftHoldValue, null);
});
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// Direction back to filling ⇒ up curve, hold cleared, still armed.
ps.calibratePredictedLevel(3.9);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true);
assert.ok(ps.percControl >= 100 - 1e-9);
// Drop to midpoint of shifted ramp [start=2 .. shiftLevel=3.5] → x=0.5 → 50%.
ps.calibratePredictedLevel(2.75);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
// Drop below startLevel → disarm.
ps.calibratePredictedLevel(1.9);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, false);
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
});
await t.test('log curve has fast early response', async () => {

View File

@@ -0,0 +1,94 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function loadDashboardFlow() {
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
}
function makeContextStub() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
},
};
}
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
const flow = loadDashboardFlow();
const ps = flow.find((n) => n.id === 'ps_node_basic');
const parser = flow.find((n) => n.id === 'ps_parse_output');
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
assert.ok(ps, 'ps_node_basic should exist');
assert.equal(ps.type, 'pumpingStation');
assert.equal(ps.controlMode, 'levelbased');
assert.equal(ps.levelCurveType, 'linear');
assert.equal(ps.inletPipeDiameter, 0.4);
assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'ps_parse_output should exist');
assert.equal(parser.outputs, 6);
assert.equal(levelChart.type, 'ui-chart');
assert.equal(demandChart.type, 'ui-chart');
});
test('basic dashboard parser routes process fields to charts and state text', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
assert.ok(parser, 'ps_parse_output should exist');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
// Flatten format is `${type}.${variant}.${position}.${childId}`. When the
// runtime writes without an explicit .child(), childId='default'. Mirror
// the real shape here. (See generalFunctions/src/measurements/
// MeasurementContainer.js getFlattenedOutput.)
const out = func({
payload: {
'level.predicted.atequipment.default': 3.25,
'volume.predicted.atequipment.default': 32.5,
'netFlowRate.predicted.atequipment.default': 0.003,
percControl: 25,
direction: 'filling',
safetyState: 'normal',
isOverflowing: false,
timeleft: 400,
},
}, context, node);
assert.ok(Array.isArray(out));
assert.equal(out.length, 6);
assert.equal(out[0].topic, 'level');
assert.equal(out[0].payload, 3.25);
assert.equal(out[1].topic, 'volume');
assert.equal(out[1].payload, 32.5);
assert.equal(out[2].topic, 'demand');
assert.equal(out[2].payload, 25);
assert.equal(out[3].topic, 'net_flow');
assert.equal(out[3].payload, 0.003);
assert.match(out[4].payload, /normal/);
assert.match(out[5].payload, /level=3.25 m/);
});
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
const out = func({ payload: { percControl: 20 } }, context, node);
assert.equal(out[0].payload, 3.1);
assert.equal(out[2].payload, 20);
});

View File

@@ -0,0 +1,198 @@
// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
// Drives a full fill→arm→drain cycle through the same code path the
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
// hold-then-ramp output behaviour.
//
// Run with: node --test test/integration/shifted-ramp-end-to-end.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const PumpingStation = require('../../src/specificClass');
const SURFACE_AREA = 10; // basin volume / height = 50/5
const TICK_MS = 1000; // simulate 1 s per tick
function makeConfig() {
return {
general: {
name: 'TestPS',
id: 'ps-e2e',
unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' },
flowThreshold: 1e-4,
},
functionality: {
softwareType: 'pumpingStation',
role: 'stationcontroller',
positionVsParent: 'atEquipment',
},
basin: {
volume: 50, height: 5,
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
},
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4,
curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
safety: {
enableDryRunProtection: false, enableOverfillProtection: false,
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
},
};
}
// Build a PS with a fake MGC that captures every demand sent to it,
// and a clock we control so _updatePredictedVolume integrates over a
// known dt regardless of wall-clock.
function buildHarness() {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
// Seed level at startLevel so the run begins idle.
ps.calibratePredictedLevel(2.0);
// Override Date.now via a controllable clock that advances `step()`.
let now = ps._predictedFlowState.lastTimestamp || 0;
ps._fakeNow = () => now;
ps._fakeAdvance = (ms) => { now += ms; };
// Patch global Date.now JUST inside the scope of these tests.
const realNow = Date.now;
Date.now = ps._fakeNow;
// Restore on completion.
ps._restore = () => { Date.now = realNow; };
return { ps, demands };
}
async function step(ps, qIn, qOut) {
// Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
// topic handlers in nodeClass.js), advance time, then tick once.
if (Number.isFinite(qIn)) ps.setManualInflow(qIn, Date.now(), 'm3/s');
if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
ps._fakeAdvance(TICK_MS);
ps.tick();
}
function levelOf(ps) {
return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
}
test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
const { ps } = buildHarness();
try {
// ─── PHASE A: fill from start (2.0) up past the arm point ──────────
// Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
// 0.05/SURFACE_AREA = 0.005 m per second.
let armedAt = null;
for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
await step(ps, 0.05, 0);
if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
}
assert.ok(armedAt, 'shift should arm during fill');
// Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
// jitter for time-discretization.
assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
`expected arm near level=3.8, got ${armedAt.level}`);
assert.ok(armedAt.pct >= 80 - 1e-6,
`at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);
// While still filling and armed, output should track the up curve
// (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
const fillingPct = ps.percControl;
assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
`filling-armed output should still be on up curve, got ${fillingPct}`);
// No hold captured yet (still filling).
assert.equal(ps._shiftHoldValue, null);
// ─── PHASE B: flip to draining ─────────────────────────────────────
// First drain tick captures the hold. We need direction='draining' as
// determined by _selectBestNetFlow → so q_in - q_out must be negative
// by more than the dead-band (1e-4).
await step(ps, 0, 0.05); // net = -0.05
assert.equal(ps.state.direction, 'draining');
// Hold captured = up curve at the level when direction flipped. The
// captured value is recorded BEFORE this drain tick lowered the level
// further, so it should match the last filling tick's output (within
// the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
`hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
const hold = ps._shiftHoldValue;
// ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
// Drain until level just above shiftLevel=3.5. Output stays = hold.
let held = true;
for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
await step(ps, 0, 0.05);
if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
}
assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
`still expected hold=${hold}, got ${ps.percControl}`);
// ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
// Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
const justBelow = ps.percControl;
assert.ok(justBelow < hold,
`output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
// Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
const mid = ps.percControl;
assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
`at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);
// ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps.percControl, 0);
} finally {
ps._restore();
}
});
test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
const { ps } = buildHarness();
try {
// Fill to arm + some headroom.
while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
assert.equal(ps._shiftArmed, true);
// First drain transition → hold #1.
await step(ps, 0, 0.05);
const hold1 = ps._shiftHoldValue;
assert.ok(hold1 >= 80 - 1e-6);
// Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);
// Flip back to filling at higher rate; up curve resumes; hold cleared.
await step(ps, 0.05, 0);
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');
// Fill higher than before (output goes higher).
while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
const fillingPct = ps.percControl;
assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);
// Drain again → fresh hold #2 = current up curve %.
await step(ps, 0, 0.05);
const hold2 = ps._shiftHoldValue;
assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
} finally {
ps._restore();
}
});