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>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user