122 lines
5.7 KiB
JavaScript
122 lines
5.7 KiB
JavaScript
|
|
// 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();
|
|||
|
|
}
|
|||
|
|
});
|