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

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