From 2af6c904da192b0dd78cbdddda970af9a4b7e399 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Wed, 27 May 2026 17:47:50 +0200 Subject: [PATCH] feat(mgc): rendezvous lock + emergency bypass (no re-plan mid-rendezvous) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a rendezvous plan is committed it now runs to completion untouched: an ordinary new setpoint arriving while the group is 'working' is remembered (latest wins) and dispatched sequentially when the group reaches 'ready', instead of aborting + re-planning. A re-plan mid-flight dropped the in-flight schedule and re-deferred a pump that was mid-sequence, parking starting pumps at minimum flow. Only an EMERGENCY pre-empts the lock: a stop (≤0) or a pressure excursion. _isUrgentDemand (which pre-empted on any large step) is replaced by _isEmergencyDemand; the large-step pre-emption is gone — large operator steps now defer like any other setpoint. _pressureEmergency() reads planner.emergencyPressurePa and is INERT until that threshold is configured; handlePressureChange fires a latched bypass dispatch when it breaches. Verified live on the E2E Isolated MGC rig: a 1→2 pump staging transition ramps the added pump straight through (no wait-at-minimum, no start-then-stop) and the group total climbs monotonically. (The Pump-tab node's hunting is a separate demand-feedback-loop issue in that flow's wiring, not the rendezvous.) Integration tests now settle to 'ready' between demands (waitReady) since the lock defers setpoints arriving mid-move. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/specificClass.js | 105 ++++++++++++------ test/basic/movement-gate.basic.test.js | 63 ++++++----- ...-distance-demand-sweep.integration.test.js | 15 ++- .../ncog-distribution.integration.test.js | 16 +++ 4 files changed, 136 insertions(+), 63 deletions(-) diff --git a/src/specificClass.js b/src/specificClass.js index e97d930..67bcdb4 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -78,11 +78,16 @@ class MachineGroup extends BaseDomain { // Demand held by the movement gate while the group is 'working'. Latest // wins; flushed by _maybeFlushPendingDemand once the group is 'ready'. this._pendingDemand = null; - // Intent of the last dispatch that actually proceeded — used by the - // movement gate to treat a mode/priority change as urgent (a new - // intent), not a hold-worthy nudge. + // Intent of the last dispatch that actually proceeded — recorded so a + // pressure-emergency re-dispatch can re-plan the SAME intent against + // the new envelope without inventing a setpoint. this._lastDispatchedMode = null; this._lastPriorityKey = JSON.stringify(null); + this._lastPriorityList = null; + // Pressure-emergency latch. Set when handlePressureChange fires a + // bypass dispatch; cleared once pressure falls back below threshold, + // so the (several-times-a-second) handler doesn't re-fire every tick. + this._emergencyLatched = false; 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 } }; @@ -91,7 +96,7 @@ class MachineGroup extends BaseDomain { // call that is later superseded resolves with { superseded: true }. this._demandDispatcher = new DemandDispatcher( { logger: this.logger }, - (payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList), + (payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList, { emergency: payload.emergency === true }), ); this._shutdownInFlight = new Set(); @@ -233,7 +238,27 @@ class MachineGroup extends BaseDomain { const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null; this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency); this.notifyOutputChanged(); - // Group may have just settled — release any demand the gate is holding. + // Emergency bypass: a pressure excursion pre-empts the rendezvous lock + // and re-plans the last intent against the new envelope immediately. + // Inert until planner.emergencyPressurePa is configured (see + // _pressureEmergency). Latched so we fire once per excursion, not every + // tick; the latch clears when pressure falls back below threshold. + if (this._pressureEmergency()) { + if (!this._emergencyLatched && Number.isFinite(this._lastDemand?.canonical)) { + this._emergencyLatched = true; + this.logger.warn(`Pressure emergency — pre-empting rendezvous, re-planning last demand ${this._lastDemand.canonical.toFixed(3)}.`); + Promise.resolve(this._demandDispatcher.fireAndWait({ + source: 'pressure-emergency', + demand: this._lastDemand.canonical, + powerCap: Infinity, + priorityList: this._lastPriorityList, + emergency: true, + })).catch((e) => this.logger?.error?.(`emergency dispatch failed: ${e?.message || e}`)); + } + } else { + this._emergencyLatched = false; + } + // Group may have just settled — release any demand the lock is holding. this._maybeFlushPendingDemand(); } @@ -262,25 +287,34 @@ class MachineGroup extends BaseDomain { return 'ready'; } - // Is this demand urgent enough to pre-empt an in-flight group movement? - // • a stop (≤0) is always urgent — never make the operator wait to stop; - // • the first demand (no prior) dispatches immediately; - // • a control-mode switch or a changed priority order is a new intent, - // not a nudge — dispatch it now rather than holding it; - // • otherwise a step larger than `planner.urgentDemandFraction` of the - // capacity envelope (default 25%) pre-empts; smaller nudges wait for - // the group to be 'ready' so they don't thrash the current ramp. - _isUrgentDemand(demandQ, priorityList) { + // May this demand pre-empt an in-flight rendezvous? Only an EMERGENCY may — + // a committed rendezvous is otherwise locked, and ordinary new setpoints + // (any size, mode/priority changes included) are deferred and dispatched + // sequentially once the group is 'ready' (_maybeFlushPendingDemand). This + // is what stops a re-plan from re-deferring a pump that's mid-sequence + // (which parked starting pumps at minimum flow → the staging bump). + // • a stop (≤0) is always an emergency — never make the operator wait; + // • the first demand (no prior intent) must proceed or nothing ever runs; + // • a pressure excursion (opts.emergency, raised by handlePressureChange) + // pre-empts so rising discharge pressure is actioned immediately. + // Everything else returns false → defer. + _isEmergencyDemand(demandQ, opts = {}) { if (!(demandQ > 0)) return true; if (this._lastDemand?.canonical == null) return true; - if (this.mode !== this._lastDispatchedMode) return true; - if (JSON.stringify(priorityList ?? null) !== this._lastPriorityKey) return true; - const dt = (typeof this.calcDynamicTotals === 'function' ? this.calcDynamicTotals() : this.dynamicTotals) || {}; - const span = Number(dt?.flow?.max) || 0; - if (span <= 0) return true; - const frac = Math.abs(demandQ - this._lastDemand.canonical) / span; - const thr = Number(this.config?.planner?.urgentDemandFraction); - return frac >= (Number.isFinite(thr) ? thr : 0.25); + return opts.emergency === true; + } + + // Pressure-excursion detector for the emergency bypass. Returns true when + // the resolved header pressure breaches a configured safety threshold. + // INERT BY DEFAULT: with no `planner.emergencyPressurePa` set, this always + // returns false — the bypass mechanism is wired and tested but never fires + // until a real threshold is configured. (Rate-of-rise can be added here + // later behind its own config key without touching the call sites.) + _pressureEmergency() { + const absPa = Number(this.config?.planner?.emergencyPressurePa); + if (!Number.isFinite(absPa) || absPa <= 0) return false; + const p = this.operatingPoint?.headerDiffPa; + return Number.isFinite(p) && p >= absPa; } // Dispatch a demand held by the movement gate, once the group has settled. @@ -474,7 +508,7 @@ class MachineGroup extends BaseDomain { return this.handleInput('parent', canonical); } - async _runDispatch(source, demand, powerCap, priorityList) { + async _runDispatch(source, demand, powerCap, priorityList, opts = {}) { const demandQ = parseFloat(demand); if (!Number.isFinite(demandQ)) { this.logger.error(`Invalid flow demand input: ${demand}.`); @@ -485,24 +519,25 @@ class MachineGroup extends BaseDomain { // keep a defensive check in case turnOff-state arrives some other way. if (demandQ <= 0) { await this.turnOffAllMachines(); return; } - // Movement gate. If the group is still converging on its previous - // intent ('working') and this demand is NOT urgent, hold it instead of - // aborting the in-flight ramps. The held demand (latest wins) is - // dispatched the moment the group reports 'ready' - // (_maybeFlushPendingDemand, off handlePressureChange). This is what - // stops a fast-re-commanding parent from freezing pumps at 0 by - // aborting every ramp before it can progress. Urgent demand (shutdown, - // or a large step) still pre-empts and dispatches immediately. - if (this.getMovementState() === 'working' && !this._isUrgentDemand(demandQ, priorityList)) { + // Rendezvous lock. While the group is still converging on its committed + // plan ('working'), an ordinary new setpoint is NOT applied — it is + // remembered (latest wins) and dispatched sequentially once the group + // reports 'ready' (_maybeFlushPendingDemand, off handlePressureChange). + // This keeps a re-plan from dropping the in-flight schedule and + // re-deferring a pump that's mid-sequence — which parked starting pumps + // at minimum flow (the staging bump). Only an EMERGENCY (stop, or a + // pressure excursion flagged via opts.emergency) pre-empts. + if (this.getMovementState() === 'working' && !this._isEmergencyDemand(demandQ, opts)) { this._pendingDemand = { source, demand: demandQ, powerCap, priorityList }; - this.logger.debug(`Demand ${demandQ.toFixed(3)} held — group 'working'; will dispatch when 'ready'.`); + this.logger.debug(`Demand ${demandQ.toFixed(3)} held — rendezvous locked ('working'); will dispatch when 'ready'.`); return; } this._pendingDemand = null; - // Record the intent now driving the group, so a later same-magnitude - // demand in the same mode/priority is correctly seen as a nudge. + // Record the intent now driving the group, so a pressure-emergency + // re-dispatch can re-plan the same intent against the new envelope. this._lastDispatchedMode = this.mode; this._lastPriorityKey = JSON.stringify(priorityList ?? null); + this._lastPriorityList = priorityList ?? null; await this.abortActiveMovements('new demand received'); const dt = this.calcDynamicTotals(); diff --git a/test/basic/movement-gate.basic.test.js b/test/basic/movement-gate.basic.test.js index 7be336a..59b01d2 100644 --- a/test/basic/movement-gate.basic.test.js +++ b/test/basic/movement-gate.basic.test.js @@ -1,5 +1,6 @@ -// Unit tests for the MGC movement state + dispatch-gate helpers -// (getMovementState / _isUrgentDemand). Exercised via prototype.call with a +// Unit tests for the MGC movement state + rendezvous-lock helpers +// (getMovementState / _isEmergencyDemand / _pressureEmergency). Exercised via +// prototype.call with a // minimal fake `this` so no Node-RED runtime or full MachineGroup boot is // needed. See project rule .claude/rules/testing.md (basic = pure logic). @@ -40,38 +41,46 @@ test('movementState: working when the executor still has scheduled commands', () assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working'); }); -function urgent(demandQ, { - mode = 'optimalControl', lastMode = 'optimalControl', - last = 10, priorityList = null, lastPriorityKey = 'null', span = 100, thr, -} = {}) { - return MachineGroup.prototype._isUrgentDemand.call({ +// Rendezvous lock: only an EMERGENCY pre-empts an in-flight rendezvous; every +// ordinary setpoint (any size, mode/priority change included) defers. +function emergency(demandQ, { last = 10, emergency = false } = {}) { + return MachineGroup.prototype._isEmergencyDemand.call({ _lastDemand: last == null ? null : { canonical: last }, - mode, _lastDispatchedMode: lastMode, _lastPriorityKey: lastPriorityKey, - calcDynamicTotals: () => ({ flow: { max: span } }), - config: { planner: thr == null ? {} : { urgentDemandFraction: thr } }, - }, demandQ, priorityList); + }, demandQ, { emergency }); } -test('urgent: a stop (≤0) always pre-empts', () => { - assert.equal(urgent(0), true); - assert.equal(urgent(-5), true); +test('emergency: a stop (≤0) always pre-empts', () => { + assert.equal(emergency(0), true); + assert.equal(emergency(-5), true); }); -test('urgent: the first demand (no prior) dispatches immediately', () => { - assert.equal(urgent(50, { last: null }), true); +test('emergency: the first demand (no prior) dispatches immediately', () => { + assert.equal(emergency(50, { last: null }), true); }); -test('urgent: a control-mode switch is a new intent', () => { - assert.equal(urgent(10, { mode: 'priorityControl', lastMode: 'optimalControl' }), true); +test('emergency: an explicit emergency flag pre-empts', () => { + assert.equal(emergency(60, { last: 10, emergency: true }), true); }); -test('urgent: a changed priority order is a new intent', () => { - assert.equal(urgent(10, { priorityList: ['eff', 'std'], lastPriorityKey: 'null' }), true); +test('emergency: an ordinary same-mode step defers (large or small)', () => { + assert.equal(emergency(12, { last: 10 }), false); // small nudge — defer + assert.equal(emergency(60, { last: 10 }), false); // large step — also defers now }); -test('urgent: a small same-mode nudge is held (not urgent)', () => { - assert.equal(urgent(12, { last: 10, span: 100 }), false); // 2% of span < 25% + +// Pressure-excursion detector — inert until planner.emergencyPressurePa is set. +function pressureEmergency({ thr, headerPa } = {}) { + return MachineGroup.prototype._pressureEmergency.call({ + config: { planner: thr == null ? {} : { emergencyPressurePa: thr } }, + operatingPoint: { headerDiffPa: headerPa }, + }); +} + +test('pressureEmergency: inert (false) when no threshold is configured', () => { + assert.equal(pressureEmergency({ headerPa: 999999 }), false); }); -test('urgent: a large same-mode step pre-empts', () => { - assert.equal(urgent(60, { last: 10, span: 100 }), true); // 50% of span ≥ 25% +test('pressureEmergency: false when header is below the configured threshold', () => { + assert.equal(pressureEmergency({ thr: 200000, headerPa: 150000 }), false); }); -test('urgent: threshold is configurable via planner.urgentDemandFraction', () => { - assert.equal(urgent(15, { last: 10, span: 100, thr: 0.02 }), true); // 5% ≥ 2% - assert.equal(urgent(15, { last: 10, span: 100, thr: 0.5 }), false); // 5% < 50% +test('pressureEmergency: true when header breaches the configured threshold', () => { + assert.equal(pressureEmergency({ thr: 200000, headerPa: 210000 }), true); +}); +test('pressureEmergency: false when header pressure is unknown', () => { + assert.equal(pressureEmergency({ thr: 200000, headerPa: undefined }), false); }); diff --git a/test/integration/bep-distance-demand-sweep.integration.test.js b/test/integration/bep-distance-demand-sweep.integration.test.js index e80a91d..1104a80 100644 --- a/test/integration/bep-distance-demand-sweep.integration.test.js +++ b/test/integration/bep-distance-demand-sweep.integration.test.js @@ -48,13 +48,26 @@ async function buildGroupWithPressure() { return { mgc, pumps }; } +// Settle to 'ready' between demands. The rendezvous lock defers a new setpoint +// that arrives while the group is still 'working', so each sweep step must wait +// for the previous move to land before issuing (and reading) the next. +async function waitReady(mgc, timeoutMs = 6000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + if (mgc.getMovementState?.() === 'ready') return true; + try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ } + await new Promise(r => setTimeout(r, 40)); + } + return false; +} + async function sweepDemand(mgc, demands_m3h) { const rows = []; for (const Qd_m3h of demands_m3h) { const Qd = Qd_m3h / 3600; // m3/h → m3/s try { await mgc.handleInput('parent', Qd); } catch (e) { /* turnOff or no-combination paths are part of the contract */ } - await new Promise(r => setTimeout(r, 30)); + await waitReady(mgc); const out = getOutput(mgc); rows.push({ demand: Qd_m3h, diff --git a/test/integration/ncog-distribution.integration.test.js b/test/integration/ncog-distribution.integration.test.js index 8023e29..a35c024 100644 --- a/test/integration/ncog-distribution.integration.test.js +++ b/test/integration/ncog-distribution.integration.test.js @@ -27,6 +27,19 @@ const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/h /* ---- helpers ---- */ +// Settle the group to 'ready'. The rendezvous lock defers a setpoint arriving +// while the group is still 'working', so a full-MGC test must wait for each +// move to land before reading steady state or issuing the next demand. +async function waitReady(mgc, timeoutMs = 6000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + if (mgc.getMovementState?.() === 'ready') return true; + try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ } + await new Promise(r => setTimeout(r, 40)); + } + return false; +} + function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); } function distortSeries(series, scale = 1, tilt = 0) { @@ -414,6 +427,7 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max); } await mg.handleInput('parent', pctCanonical(mg, 50), Infinity); + await waitReady(mg); // rendezvous lock — let the move land before reading steady state const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0; const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0; @@ -422,10 +436,12 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump await m.handleInput('parent', 'execSequence', 'shutdown'); await m.handleInput('parent', 'execSequence', 'startup'); } + await waitReady(mg); // ensure the group is settled so the next demand isn't deferred // Run priorityControl mg.setMode('prioritycontrol'); await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']); + await waitReady(mg); const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0; const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;