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:
Submodule nodes/machineGroupControl updated: 96b84d3124...df74ea0fac
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