Files
EVOLV/test/mgc-overactive-demand-serialization.integration.test.js
Rene De Ren c4d75809cd
Some checks failed
CI / lint-and-test (push) Has been cancelled
Bump machineGroupControl@df74ea0 — serialize handleInput dispatches
Adds the _dispatchInFlight gate that mirrors rotatingMachine
state.delayedMove. Before this, PS at 1 Hz overran in-flight pump
ramps via concurrent handleInput entries, producing the live thrash:
120 aborts / 2 min, pump_b clamped at minFlow.

Includes regression test:
test/mgc-overactive-demand-serialization.integration.test.js
covering concurrent-burst serialization (30 calls → ≤ 5 aborts) and
latest-wins semantic.

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

122 lines
5.7 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.
// Regression: MGC must serialize concurrent handleInput calls.
//
// Live observation (2026-05-09): PS sends a fresh demand% every 1 s as
// basin level drifts. Each MGC.handleInput unconditionally calls
// abortActiveMovements — so an in-flight pump ramp gets killed, the
// pump's setpoint is replaced, the new ramp gets killed by the next
// tick, and the loop never settles. Real symptom: 120
// "Aborting active movements ..." log lines per 2 min while a single
// pump randomly leads (138 m³/h) and the others are clamped at minFlow
// (60 m³/h, "near_curve_edge").
//
// Proper design (mirrors rotatingMachine state.delayedMove): when a
// handleInput is already in-flight (pumps still moving), save the new
// demand to a delayed slot and return. When the in-flight dispatch
// finishes, pick up the latest delayed demand. Latest-wins —
// intermediate values are stomped because they were obsolete by the
// time the pumps were ready for them.
//
// Fail mode this catches: any future change that re-introduces
// concurrent handleInput entries (or removes the gate) will explode the
// abort count and leave pumps unbalanced.
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
test('MGC serializes overactive demand — one dispatch in flight at a time, latest queued for pickup', async () => {
const plant = buildPlant({ initialBasinLevel: 2.6 });
const { mgc, pumps, restore } = plant;
try {
// Realistic ramp time so the in-flight window is wide enough that
// multiple PS calls land during it.
for (const p of pumps) {
p.state.config.time = { starting: 1, warmingup: 1, stopping: 1, coolingdown: 1 };
injectPumpPressure(p, 19620, 117720);
}
// Bring pumps to operational once so the test focuses on
// STEADY-STATE thrash (not startup).
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
// Count how many times MGC actually issues an abort. Wrap the
// existing method so the contract is enforced regardless of
// implementation details.
let abortCount = 0;
const originalAbort = mgc.abortActiveMovements.bind(mgc);
mgc.abortActiveMovements = async (reason) => {
abortCount += 1;
return originalAbort(reason);
};
// Simulate PS jitter: 30 demand calls fired back-to-back without
// awaiting (mirrors PS._applyMachineGroupLevelControl firing into
// an MGC whose previous handleInput is still settling pumps).
// Tiny percControl drift around 14 % matches what we observed live.
const calls = [];
const baseDemand = 14;
for (let i = 0; i < 30; i++) {
const d = baseDemand + (i % 5) * 0.05;
// Fire-and-forget — the gate must absorb the burst.
calls.push(mgc.handleInput('parent', d).catch(() => {}));
}
await Promise.all(calls);
// Let any deferred pickup settle.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
// Contract: with a serialization gate, concurrent burst yields at
// most TWO real dispatches (the first that wins entry, plus one
// queued pickup carrying the latest value). Without the gate, every
// call aborts → abortCount equals call count.
//
// We assert ≤ 5 to allow for legitimate sequential dispatch that
// span a few ticks but block the runaway-thrash mode.
assert.ok(abortCount <= 5,
`MGC issued ${abortCount} aborts for 30 concurrent demand calls — gate not serializing dispatches (live system showed 1 abort/sec / 120 per 2 min with this exact bug).`);
// Whatever combination the optimizer picks, all selected pumps
// must reach a non-floor ctrl. Pump_b stuck at the curve floor was
// the live failure — the gate fixes it because the ramp completes
// before the next demand starts.
const finalCtrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
const activePumps = finalCtrls.filter((c) => c > 0);
if (activePumps.length >= 2) {
const min = Math.min(...activePumps);
const max = Math.max(...activePumps);
assert.ok(max / Math.max(min, 1) < 3,
`active pump ctrls=${activePumps.map((c) => c.toFixed(1))} — disparity > 3× indicates one pump clamped at curve floor (the live "138 vs 60" symptom).`);
}
} finally {
restore();
}
});
test('MGC serialization preserves latest-wins semantic — intermediate values stomped, last value applied', async () => {
const plant = buildPlant({ initialBasinLevel: 2.6 });
const { mgc, pumps, restore } = plant;
try {
for (const p of pumps) {
p.state.config.time = { starting: 1, warmingup: 1, stopping: 1, coolingdown: 1 };
injectPumpPressure(p, 19620, 117720);
}
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
// Fire 10 demands quickly: 25, 50, 25, 50, ..., 100. Final must be 100.
const sequence = [25, 50, 25, 50, 25, 50, 25, 50, 25, 100];
const calls = sequence.map((d) => mgc.handleInput('parent', d).catch(() => {}));
await Promise.all(calls);
// Allow one extra event-loop turn for the deferred pickup.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
// After settling, the LATEST demand (100 %) wins — pumps should be
// at high ctrl, not stuck on the first burst-leader value.
const finalCtrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
const maxCtrl = Math.max(...finalCtrls);
assert.ok(maxCtrl > 70,
`latest demand was 100 % but max pump ctrl=${maxCtrl.toFixed(1)} — gate is dropping the queued value instead of picking it up.`);
} finally {
restore();
}
});