diff --git a/examples/basic-dashboard.flow.json b/examples/basic-dashboard.flow.json index 7323bce..9eaddea 100644 --- a/examples/basic-dashboard.flow.json +++ b/examples/basic-dashboard.flow.json @@ -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": "", diff --git a/pumpingStation.html b/pumpingStation.html index 92f664b..35dbcda 100644 --- a/pumpingStation.html +++ b/pumpingStation.html @@ -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 @@ m +
from basin above
— m @@ -461,6 +467,10 @@
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(); + } +});