feat(mgc): rendezvous lock + emergency bypass (no re-plan mid-rendezvous)

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) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-27 17:47:50 +02:00
parent f41e319b30
commit 2af6c904da
4 changed files with 136 additions and 63 deletions

View File

@@ -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();