193 lines
9.3 KiB
JavaScript
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}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|