Some checks failed
CI / lint-and-test (push) Has been cancelled
- 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>
109 lines
5.1 KiB
JavaScript
109 lines
5.1 KiB
JavaScript
// 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();
|
||
}
|
||
});
|