// Stability under inflow > station capacity: storm condition where the // basin overflows continuously. Pumps should run flat-out and the FSM // must NOT thrash through aborts/parks. // // Catches the user's live observation: at 2× capacity inflow, pumps got // stuck mid-flight while demand was still rising. This test runs with // realistic state.time (production defaults) so the abort-during-startup // race window is fully open. const test = require('node:test'); const assert = require('node:assert/strict'); const { buildPlant, injectPumpPressure } = require('./lib/wiring'); const TICK_MS = 1000; // Sim duration kept short — the chronic thrashing pattern shows up // within the first minute. Bigger SIM_MINUTES makes the test wall-time // hostile (each tick awaits async pump moves on real timers). const SIM_SECONDS = 45; test('inflow ≫ capacity: pumps reach steady high-ctrl, no parking, no thrashing', async () => { // Use shorter-than-default state.time so the test runs in reasonable // wall time while still exercising the transient (1 s startup + 2 s // warmup). The race conditions we care about are the same — they're // about ORDER, not absolute duration. // Start at maxLevel so PS percControl is immediately 100 % (the // storm condition). Otherwise the basin needs to fill to maxLevel // first, which on a 2× capacity inflow takes ~2 minutes — longer // than this test's wall time. const plant = buildPlant({ initialBasinLevel: 3.5 }); const { ps, mgc, pumps, advance, restore } = plant; try { // Pre-start pumps to operational so the test focuses on STEADY-STATE // thrashing under chronic over-capacity inflow, not startup race // conditions (those have their own test). This also keeps wall time // manageable — buildPlant's state.time=0 means transitions are // instant once already operational. for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup'); // Inflow set 2× station capacity (~600 m³/h vs ~270 m³/h capacity). const Q_IN = 600 / 3600; let parkObservations = 0; let abortLogObservations = 0; // Drive the loop: every tick, refresh pressures, set inflow, // tick PS (which fires _applyMachineGroupLevelControl). The // settlePerTickMs wait is REAL wall-clock so movementManager's // setInterval timers actually fire between handleInputs — without // it the test runs too fast for moves to progress and pumps look // permanently parked even when production behaviour is fine. const ticks = SIM_SECONDS; const settlePerTickMs = 200; const realSleep = (ms) => new Promise((r) => setTimeout(r, ms)); let lastCtrl = pumps.map(() => 0); let largeJumpTicks = 0; for (let i = 0; i < ticks; i++) { for (const p of pumps) injectPumpPressure(p, 19620, 117720); ps.setManualInflow(Q_IN, Date.now(), 'm3/s'); advance(TICK_MS); ps.tick(); await realSleep(settlePerTickMs); const states = pumps.map((p) => p.state.getCurrentState()); const ctrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0); // Park observation: any pump in 'accelerating'/'decelerating' for // more than 3 consecutive seconds at flat ctrl is parked. Cheap // approximation: count how often we sample those states. for (const s of states) { if (s === 'accelerating' || s === 'decelerating') parkObservations += 1; } // Thrashing observation: ctrl jumping by > 30 % between consecutive // seconds (in either direction) suggests retarget churn. for (let k = 0; k < pumps.length; k++) { if (Math.abs(ctrls[k] - lastCtrl[k]) > 30) largeJumpTicks += 1; } lastCtrl = ctrls; if (i === Math.floor(ticks * 0.66)) { console.log(` tick ${i}/${ticks} states=[${states.join(', ')}] ctrls=[${ctrls.map((c) => c.toFixed(0)).join(', ')}]`); } } // After SIM_MINUTES, system must be in a coherent state: pumps high // ctrl, no one parked. const finalStates = pumps.map((p) => p.state.getCurrentState()); const finalCtrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0); console.log(` FINAL states=[${finalStates.join(', ')}] ctrls=[${finalCtrls.map((c) => c.toFixed(1)).join(', ')}]`); console.log(` Park observations across ${ticks} ticks×3 pumps: ${parkObservations}`); console.log(` Large-jump tick events (>30 % ctrl change s-to-s): ${largeJumpTicks}`); for (const s of finalStates) { assert.equal(s, 'operational', `final state must be operational under steady high demand; one pump in '${s}'`); } for (const c of finalCtrls) { assert.ok(c > 80, `final ctrl must be >80 % under storm inflow; got ${c.toFixed(1)} %`); } // Allow some movement transients but not constant retargeting. // 3 pumps × 180 ticks = 540 samples; >25 % churn is a thrash signal. const maxAllowedJumps = Math.floor(ticks * 3 * 0.25); assert.ok(largeJumpTicks < maxAllowedJumps, `excessive ctrl thrash: ${largeJumpTicks} large-jump events (max ${maxAllowedJumps}) — system isn't converging`); } finally { restore(); } });