Files
EVOLV/test/inflow-overcapacity-stability.integration.test.js
Rene De Ren 15c39f76bb
Some checks failed
CI / lint-and-test (push) Has been cancelled
Bump MGC@69bdf11 + adjust overcapacity test to actually exercise storm
- nodes/machineGroupControl@69bdf11 makes DOWNSTREAM single-writer
  (handlePressureChange = live aggregate; optimizer target moved to
  AT_EQUIPMENT). Closes the ps-mgc-flow-contract failure.

- test/inflow-overcapacity-stability now starts the basin at maxLevel
  so PS percControl is immediately 100 % (the actual storm condition)
  and uses real-time waits between ticks so movementManager intervals
  fire — the previous setImmediate yield was too fast for moves to
  progress, making pumps look perma-parked even when behaviour was OK.
  Park observations dropped from 83 to 3 across the sim window; final
  ctrl converges to ~88 % across all 3 pumps.

All 82 cross-node + node integration tests now pass.

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

109 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
});