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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user