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

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