// End-to-end test: PS + MGC + 3 pumps wired exactly like the // pumpingstation-complete-example demo, driven by a controllable clock. // // Verifies: // 1. Basin starts low (below stopLevel) — pumps OFF. // 2. Basin fills to startLevel — first pump engages. // 3. Basin drains through the dead band [stopLevel, startLevel] — // pump stays engaged at minimum flow. // 4. Basin reaches stopLevel — pump disengages, basin refills. // 5. Storm inflow → all 3 pumps engage at high flow. const test = require('node:test'); const { buildPlant, injectPumpPressure } = require('./lib/wiring'); const { attachRecorder, snapshotFull, snapshotMachineState } = require('./lib/recorder'); const TICK_MS = 1000; const STATIC_HEAD_M = 12; const RHO_G = 9810; const DYN_HEAD_M_AT_FULL_FLOW = 12; const TOTAL_FLOW_MAX_M3H = 300; const OUTFLOW_LEVEL_M = 0.3; function physics({ basinLevelM, totalPumpFlow_m3h }) { const headM = Math.max(0, basinLevelM - OUTFLOW_LEVEL_M); const upstreamPa = RHO_G * headM; const ratio = Math.min(1, totalPumpFlow_m3h / TOTAL_FLOW_MAX_M3H); const downstreamPa = RHO_G * (STATIC_HEAD_M + ratio * ratio * DYN_HEAD_M_AT_FULL_FLOW); return { upstreamPa, downstreamPa }; } function totalPumpFlow_m3h(pumps) { let s = 0; for (const p of pumps) { const f = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h') || 0; s += Number(f); } return s; } async function tick(plant, { qIn_m3s }) { const { ps, pumps, advance } = plant; const basinLevelM = ps.measurements.type('level').variant('predicted') .position('atequipment').getCurrentValue('m') ?? 0; const tot = totalPumpFlow_m3h(pumps); const { upstreamPa, downstreamPa } = physics({ basinLevelM, totalPumpFlow_m3h: tot }); for (const p of pumps) injectPumpPressure(p, upstreamPa, downstreamPa); ps.setManualInflow(qIn_m3s, Date.now(), 'm3/s'); advance(TICK_MS); ps.tick(); await new Promise((r) => setImmediate(r)); } test('PS + MGC + 3 pumps — full hysteresis cycle (5/15 nominal)', async () => { // Start at 2.4 m — just below startLevel(2.5) — so we see the rising // edge in a few minutes instead of 30. Then observe the full cycle. const plant = buildPlant({ initialBasinLevel: 2.4 }); const rec = attachRecorder(plant); const { ps, mgc, pumps, restore } = plant; try { console.log('\n========================================================='); console.log(' POST-WIRING SNAPSHOT'); console.log('========================================================='); const initSnap = snapshotFull(ps, mgc, pumps); console.log(JSON.stringify(initSnap, null, 2)); console.log('\nMGC absoluteTotals (m³/h):', `min=${(mgc.absoluteTotals.flow.min*3600).toFixed(0)}, max=${(mgc.absoluteTotals.flow.max*3600).toFixed(0)}`); console.log('MGC dynamicTotals (m³/h):', `min=${(mgc.dynamicTotals.flow.min*3600).toFixed(0)}, max=${(mgc.dynamicTotals.flow.max*3600).toFixed(0)}`); // Phase 1: nominal inflow ≈ 25 m³/h → expect cycle ~5 on / ~15 off. const NOMINAL_QIN = 25 / 3600; // m³/s console.log('\n========================================================='); console.log(' PHASE 1: nominal inflow 25 m³/h — observe one full cycle.'); console.log(' Expected: basin rises from 1.5 m to 2.5 m (off, ~?? min), pump kicks on, drains to 2.0 m (on, ~5 min), repeats.'); console.log('========================================================='); const phase1Trace = []; let firstEngageTick = null; let firstDisengageTick = null; let secondEngageTick = null; for (let i = 0; i < 1800; i++) { // 30 min sim await tick(plant, { qIn_m3s: NOMINAL_QIN }); const snap = snapshotFull(ps, mgc, pumps); const tickIdx = i + 1; phase1Trace.push({ s: tickIdx, ...snap }); const anyEngaged = pumps.some(p => ['operational', 'starting', 'warmingup', 'accelerating'].includes(p.state.getCurrentState()) ); if (anyEngaged && firstEngageTick == null) firstEngageTick = tickIdx; if (firstEngageTick != null && firstDisengageTick == null && !anyEngaged) firstDisengageTick = tickIdx; if (firstDisengageTick != null && secondEngageTick == null && anyEngaged) secondEngageTick = tickIdx; // Stop after we observe a full off→on→off→on cycle so we can measure both phases. if (secondEngageTick != null && tickIdx > secondEngageTick + 60) break; } printCompactTrace(decimateTrace(phase1Trace, 30)); console.log('\n-- cycle landmarks --'); console.log(`First pump engage : tick ${firstEngageTick} (level=${phase1Trace[firstEngageTick - 1]?.psLevel})`); console.log(`First pump disengage: tick ${firstDisengageTick} (level=${phase1Trace[firstDisengageTick - 1]?.psLevel})`); console.log(`Second engage : tick ${secondEngageTick} (level=${phase1Trace[secondEngageTick - 1]?.psLevel})`); if (firstEngageTick && firstDisengageTick) { const onMin = (firstDisengageTick - firstEngageTick) / 60; console.log(`On phase duration : ${onMin.toFixed(1)} min (target ≈ 5 min)`); } if (firstDisengageTick && secondEngageTick) { const offMin = (secondEngageTick - firstDisengageTick) / 60; console.log(`Off phase duration : ${offMin.toFixed(1)} min (target ≈ 15 min)`); } // Phase 2: storm inflow → all 3 pumps should engage. console.log('\n========================================================='); console.log(' PHASE 2: storm inflow 250 m³/h — expect all 3 pumps engaged.'); console.log('========================================================='); const STORM_QIN = 250 / 3600; const phase2Trace = []; for (let i = 0; i < 600; i++) { // 10 min storm await tick(plant, { qIn_m3s: STORM_QIN }); const snap = snapshotFull(ps, mgc, pumps); phase2Trace.push({ s: phase1Trace.length + i + 1, ...snap }); } printCompactTrace(decimateTrace(phase2Trace, 30)); const peak = phase2Trace.reduce((acc, s) => { const running = Object.values(s.pumps).filter(p => ['operational', 'accelerating', 'warmingup', 'starting'].includes(p.state) ).length; return Math.max(acc, running); }, 0); console.log(`\nPeak concurrent running pumps during storm: ${peak} / 3`); const maxLvl = phase2Trace.reduce((acc, s) => Math.max(acc, s.psLevel ?? 0), 0); console.log(`Max basin level during storm: ${maxLvl.toFixed(2)} m`); // Phase 3: inflow drops back to nominal — expect graceful unwind. console.log('\n========================================================='); console.log(' PHASE 3: storm subsides → 25 m³/h. Expect graceful unwind.'); console.log('========================================================='); const phase3Trace = []; for (let i = 0; i < 900; i++) { await tick(plant, { qIn_m3s: NOMINAL_QIN }); const snap = snapshotFull(ps, mgc, pumps); phase3Trace.push({ s: phase1Trace.length + phase2Trace.length + i + 1, ...snap }); const anyEngaged = pumps.some(p => ['operational', 'starting'].includes(p.state.getCurrentState()) ); if (!anyEngaged) break; } printCompactTrace(decimateTrace(phase3Trace, 30)); // Diagnostics summary. console.log('\n========================================================='); console.log(' SUMMARY'); console.log('========================================================='); const ctrlAnomalies = phase1Trace.filter(s => Object.values(s.pumps).some(p => p.state === 'operational' && (p.ctrl_pct === 0 || p.ctrl_pct == null) && p.flow_m3h > 1 ) ).length; console.log(`Bug 3 leftover (ctrl=0 while operational delivering flow): ${ctrlAnomalies} ticks`); const optimalEvents = rec.events.filter(e => e.kind === 'mgc.optimalControl.out' && e.Qd > 0); console.log(`MGC optimalControl invocations with Qd>0: ${optimalEvents.length}`); } finally { restore(); } }); // Reduce noise by sampling every Nth tick + always include first/last. function decimateTrace(rows, step) { if (rows.length <= step * 2) return rows; const out = [rows[0]]; for (let i = step; i < rows.length - 1; i += step) out.push(rows[i]); out.push(rows[rows.length - 1]); return out; } function printCompactTrace(rows) { if (rows.length === 0) { console.log('(empty)'); return; } console.log(' s level vol dir pct d_min d_max pumpA pumpB pumpC'); console.log(' ─ ───── ───── ──────── ─────── ───── ───── ─────────────── ─────────────── ───────────────'); for (const r of rows) { const fmtPump = (p) => { if (!p) return ''.padEnd(15); return `${(p.state ?? '?').slice(0,8).padEnd(8)} c${(p.ctrl_pct ?? 0).toFixed(0).padStart(3)} f${(p.flow_m3h ?? 0).toFixed(0).padStart(3)}`.padEnd(15); }; const a = fmtPump(r.pumps.pump_a); const b = fmtPump(r.pumps.pump_b); const c = fmtPump(r.pumps.pump_c); console.log( `${String(r.s).padStart(4)} ${(r.psLevel ?? 0).toFixed(3)} ${(r.psVolume ?? 0).toFixed(2).padStart(5)} ${(r.psDirection ?? '?').padEnd(8)} ${(r.psPercControl ?? 0).toFixed(2).padStart(7)} ${(r.mgc?.dynamicMin_m3h ?? 0).toFixed(0).padStart(5)} ${(r.mgc?.dynamicMax_m3h ?? 0).toFixed(0).padStart(5)} ${a} ${b} ${c}` ); } }