Compare commits

...

1 Commits

Author SHA1 Message Date
Rene De Ren
8e684203a8 Test: add full-cycle and up/down-sweep regression scenarios
Scenario 5 covers 100% → 0% → 100% with the second 100% landing
mid-shutdown (stopping/coolingdown) — exercises the path where
delayedMove must NOT be saved on a non-idle non-residue state without
a follow-up startup, since transitionToState('idle') doesn't fire it.

Scenario 6 walks 10%→100%→10% monotonically and asserts the down-sweep's
final demand is honoured (catches the user's observed "stuck around
60% going up, no reaction going down" symptom — where pumps would
otherwise freeze at a stale setpoint from the up-sweep).

Both pass with the current MGC dispatch fix.

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

View File

@@ -211,6 +211,113 @@ test('Scenario 3 — pumps with NO pressure measurements injected', async () =>
console.log(' (Scenario 3 is exploratory — no asserts; review the snapshot above.)');
});
// ---------------------------------------------------------------------------
test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
// Hypothesis E: when demand goes 100% → 0% → 100% (basin fills, drains
// past stopLevel, then refills), pumps pass through stopping →
// coolingdown → idle. If a fresh flow>0 demand arrives while a pump is
// mid-shutdown, the current MGC dispatch saves flowmovement to
// delayedMove (good) but doesn't issue execsequence-startup because
// state !== 'idle' (bug). The pump completes shutdown, reaches 'idle',
// and stays there because transitionToState('idle') doesn't fire
// delayedMove — only the transition INTO 'operational' does. Pump is
// stuck with delayedMove orphaned.
const { mgc, pumps } = buildGroup();
console.log('\n[Scenario 5] cycle: 100% → 0% → 100% with mid-shutdown re-engage');
printSnapshots('before any handleInput', pumps);
// Phase 1: drive up to 100% from idle.
await mgc.handleInput('parent', 100);
await sleep(5000); // full startup + ramp
printSnapshots('after settle at 100%', pumps);
for (const p of pumps) {
assert.equal(p.state.getCurrentState(), 'operational',
`Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
}
// Phase 2: demand drops to 0% — pumps begin shutdown sequence.
// FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which
// awaits the full per-pump shutdown sequence. We need the next 100%
// demand to arrive WHILE pumps are still in stopping/coolingdown,
// not after they've reached idle.
mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`));
// Wait briefly so the shutdown sequence enters but does NOT complete.
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
// coolingdown=2s. 500ms puts us solidly inside 'stopping'.
await sleep(500);
printSnapshots('mid-shutdown (pumps should be in stopping/coolingdown)', pumps);
const midShutdownStates = pumps.map(p => p.state.getCurrentState());
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
// Phase 3: demand returns to 100% while pumps are mid-shutdown.
await mgc.handleInput('parent', 100);
// Generous: full coolingdown remaining + full startup + ramp.
await sleep(8000);
printSnapshots('after re-engage to 100%', pumps);
expectAllRunningAt100(pumps, 'Scenario 5');
});
// ---------------------------------------------------------------------------
test('Scenario 6 — full up sweep then full down sweep', async () => {
// Hypothesis F: the user observed "going up stuck ~60%, going down
// not reacting". Mirror that with an explicit up-then-down monotonic
// sweep, every step holding 600 ms (slightly longer than DWELL on
// production basin model). After the sweep, we expect the LATEST
// demand (the final value of the down-sweep, which is 10%) to be
// honoured: pumps either at 1-pump combo's split or all idle if that
// demand falls below the per-pump minimum.
const { mgc, pumps } = buildGroup();
console.log('\n[Scenario 6] up-sweep 10%→100% then down-sweep 100%→10%, each step 600 ms');
printSnapshots('before any handleInput', pumps);
const upSteps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
const downSteps = [90, 80, 70, 60, 50, 40, 30, 20, 10];
console.log(' --- up sweep ---');
for (const pct of upSteps) {
mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
await sleep(600);
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
}
printSnapshots('top of up-sweep (cmd=100%) after full settle', pumps);
await sleep(2000);
printSnapshots('top of up-sweep + 2s drain', pumps);
console.log(' --- down sweep ---');
for (const pct of downSteps) {
mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
await sleep(600);
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
}
printSnapshots('bottom of down-sweep (cmd=10%) after sequence', pumps);
await sleep(3000);
printSnapshots('bottom of down-sweep + 3s drain', pumps);
// Final demand was 10% (≈ 148 m³/h). At head 1100 mbar with per-pump
// min ≈ 89.5, this is solvable by a 1-pump combo near 148 m³/h.
// Optimizer typically picks the 1-pump combo. Either way, pumps are
// NOT supposed to be stuck at the prior up-sweep's 100% setpoint.
const flowMin_m3h = mgc.calcDynamicTotals().flow.min * 3600;
const flowMax_m3h = mgc.calcDynamicTotals().flow.max * 3600;
const expectedQ_m3h = flowMin_m3h + (flowMax_m3h - flowMin_m3h) * 0.10; // 10% scaled
console.log(` expected total flow at 10%: ~${expectedQ_m3h.toFixed(1)} m³/h`);
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
// Loose: total within 30 m³/h of expectation. Catches the obvious
// stuck-at-old-position regression.
assert.ok(Math.abs(totalQ - expectedQ_m3h) < 30,
`Scenario 6: total flow ${totalQ.toFixed(1)} m³/h diverged from expected ${expectedQ_m3h.toFixed(1)} after down-sweep — pumps did not adopt latest demand. Per-pump: ${snaps.map(s => `${s.state}@${s.ctrl.toFixed(0)}%`).join(', ')}`);
});
// ---------------------------------------------------------------------------
test('Scenario 4 — varying demand during startup (combo flips)', async () => {
// Hypothesis D: in production the demand is NOT constant — as basin