Files
EVOLV/test/end-to-end-pumpingstation.test.js
Rene De Ren 0cab98c196
Some checks failed
CI / lint-and-test (push) Has been cancelled
Pumping-station demo overhaul + cross-node test harness + bumps
Submodule bumps land the deadlock fix (state.js residue unpark + MGC
optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis.

- Renames examples/pumpingstation-3pumps-dashboard →
  pumpingstation-complete-example with regenerated flow.json. New
  dashboard groups, demand-broadcast wiring, S88 placement rule
  applied, ui-chart trend-split and link-channel naming follow
  .claude/rules/node-red-flow-layout.md.
- New cross-node test harness under test/: end-to-end-pumpingstation
  drives PS + MGC + 3 pumps + physics simulator end-to-end and
  verifies the ~5/15 min cycle.
- Adds Grafana provisioning dashboards (pumping-station.json) and a
  helper sync-example.sh script for export/import to live Node-RED.
- Docker entrypoint + settings + compose tweaks for the persistent
  user dir layout used by the demo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:21:21 +02:00

193 lines
9.3 KiB
JavaScript

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