// 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(); } });