— m
@@ -461,6 +467,10 @@
+
+
+ arm%dry runstart
@@ -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. -->
— ramp inlet→max
- — shifted start→shift
+ — shifted (held @100% then ramp shift→start)
diff --git a/src/editor/mode-preview.js b/src/editor/mode-preview.js
index eca021b..bb1f1f4 100644
--- a/src/editor/mode-preview.js
+++ b/src/editor/mode-preview.js
@@ -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) {
diff --git a/src/editor/oneditprepare.js b/src/editor/oneditprepare.js
index 312fc16..a187352 100644
--- a/src/editor/oneditprepare.js
+++ b/src/editor/oneditprepare.js
@@ -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
);
diff --git a/src/editor/oneditsave.js b/src/editor/oneditsave.js
index f9c16e4..6480856 100644
--- a/src/editor/oneditsave.js
+++ b/src/editor/oneditsave.js
@@ -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;
diff --git a/src/nodeClass.js b/src/nodeClass.js
index 9240508..dbeb909 100644
--- a/src/nodeClass.js
+++ b/src/nodeClass.js
@@ -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:{
diff --git a/src/specificClass.js b/src/specificClass.js
index 2f2a466..8caf9d6 100644
--- a/src/specificClass.js
+++ b/src/specificClass.js
@@ -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;
diff --git a/test/basic/specificClass.test.js b/test/basic/specificClass.test.js
index 779045c..e09856a 100644
--- a/test/basic/specificClass.test.js
+++ b/test/basic/specificClass.test.js
@@ -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 () => {
diff --git a/test/integration/basic-dashboard-flow.test.js b/test/integration/basic-dashboard-flow.test.js
new file mode 100644
index 0000000..cfc33de
--- /dev/null
+++ b/test/integration/basic-dashboard-flow.test.js
@@ -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);
+});
diff --git a/test/integration/shifted-ramp-end-to-end.test.js b/test/integration/shifted-ramp-end-to-end.test.js
new file mode 100644
index 0000000..6526d32
--- /dev/null
+++ b/test/integration/shifted-ramp-end-to-end.test.js
@@ -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();
+ }
+});