Bump machineGroupControl@df74ea0 — serialize handleInput dispatches
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
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>
This commit is contained in:
121
test/mgc-overactive-demand-serialization.integration.test.js
Normal file
121
test/mgc-overactive-demand-serialization.integration.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user