From df74ea0facd750a37ae7027dac40412079015097 Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Sat, 9 May 2026 09:14:59 +0200 Subject: [PATCH] Serialize handleInput dispatches via _dispatchInFlight gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors rotatingMachine state.delayedMove. PS ticks demand into MGC at 1 Hz but a real pump ramp takes several seconds; before this gate every PS tick aborted the in-flight optimalControl and started a new one, so pumps never reached their setpoint. Live observation: 120 aborts / 2 min, pump_a drifting to 138 m³/h while pump_b stayed clamped at minFlow 60 m³/h ("near_curve_edge"). While a dispatch is in flight, the latest {source, demand, powerCap, priorityList} is parked in _delayedCall and the new call returns. The in-flight dispatch's finally block picks up the latest delayed value when it settles. Latest-wins — intermediate demands are stomped because they were obsolete by the time the pumps were ready for them. Regression test in superproject: test/mgc-overactive-demand-serialization.integration.test.js 30 concurrent demand calls now produce ≤ 5 aborts (was 30). All existing tests still pass: 21 MGC integration + 7 cross-node integration (incl. realistic-startup-timing, inflow-overcapacity- stability, ps-mgc-flow-contract, idle-startup-deadlock). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/specificClass.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/specificClass.js b/src/specificClass.js index 7dcc278..2979856 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -74,6 +74,16 @@ class MachineGroup { // Combination curve data this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } , NCog : 0}; this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }}; + + // Dispatch serialization. PS ticks demand into MGC at 1 Hz, but + // a real pump ramp takes several seconds — without this gate + // every PS tick aborts the in-flight dispatch and starts a new + // one, so pumps never reach their setpoint. Mirrors + // rotatingMachine state.delayedMove: while a dispatch is in + // flight the latest demand is parked here for pickup when the + // current dispatch settles. Latest-wins. + this._dispatchInFlight = false; + this._delayedCall = null; //this always last in the constructor this.childRegistrationUtils = new childRegistrationUtils(this); @@ -1274,6 +1284,37 @@ class MachineGroup { async handleInput(source, demand, powerCap = Infinity, priorityList = null) { + // Serialize dispatches: if a previous handleInput is still + // awaiting pump movements, park the latest demand and return. + // The in-flight dispatch's `finally` block will pick it up. + // See rotatingMachine state.delayedMove for the analogous + // pattern at the pump level. + if (this._dispatchInFlight) { + this._delayedCall = { source, demand, powerCap, priorityList }; + this.logger.debug(`Dispatch in flight; deferring demand=${demand} until current pump moves complete.`); + return; + } + + this._dispatchInFlight = true; + try { + return await this._runDispatch(source, demand, powerCap, priorityList); + } finally { + this._dispatchInFlight = false; + // Pick up the latest deferred call (intermediate values were + // stomped while we were busy — only the last one matters). + if (this._delayedCall) { + const next = this._delayedCall; + this._delayedCall = null; + this.logger.debug(`Dispatch finished; picking up deferred demand=${next.demand}.`); + // Recursive call re-enters the gate; safe because + // _dispatchInFlight has been reset to false above. + await this.handleInput(next.source, next.demand, next.powerCap, next.priorityList); + } + } + } + + async _runDispatch(source, demand, powerCap = Infinity, priorityList = null) { + const demandQ = parseFloat(demand); if(!Number.isFinite(demandQ)){