feat(mgc): rendezvous planner — same-time landing across all modes

Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.

Architecture (src/movement/):
- machineProfile.js   – pure snapshot of a registered child (state,
                        position, velocityPctPerS, ladder timings,
                        flowAt / positionForFlow). Reads timings from
                        child.state.config.time (the actual storage
                        location — previous fallback paths silently
                        produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js   – seconds-to-target per machine; handles
                        idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
                        command is delayed so its move finishes at t*.
                        Startup execsequence fires at 0; its flowmovement
                        is gated by max(ladderS, t* − rampS) so a fast
                        pump waits before ramping rather than landing
                        early. useRendezvous=false collapses to all
                        fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
                        every command whose fireAtTickN ≤ floor(elapsed/tickS).
                        tick() no longer awaits pending fireCommand
                        promises — the synchronous prologue of
                        handleInput claims the latest-wins gate, which
                        is what race-favouring relies on.

Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
  _optimalControl. Builds profiles, calls movementScheduler.plan,
  replans the executor, ticks once. Reads
  config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
  flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
  Same-time landing now applies in BOTH modes.

Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
  config.planner.useRendezvous. Default ON.

Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
  diagnostic — drives a 3-pump mixed-state dispatch and asserts both
  convergence to the demand setpoint AND same-time landing within
  one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
  assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
  multi-startup case proving the fast pumps wait so all three land
  together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.

Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
  helper; every command now checks isValidActionForMode +
  isValidSourceForMode before dispatching. Status-level commands
  (set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
  Contracts,Examples,Limitations}.md split with _Sidebar.md index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-17 19:43:55 +02:00
parent 26e92b54f7
commit 472402c62d
26 changed files with 3048 additions and 280 deletions

View File

@@ -0,0 +1,142 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const MoveTrajectory = require('../../src/movement/moveTrajectory');
// Reusable profile builder — keeps each test focused on the field(s) it cares
// about. Anything not overridden is in a sane "operational at 0%" baseline.
function makeProfile(over = {}) {
return Object.assign({
id: 'P1',
state: 'operational',
position: 0,
minPosition: 0,
maxPosition: 100,
velocityPctPerS: 2,
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
remainingTransitionS: null,
flowAt: () => null,
}, over);
}
// TC1 — idle, full startup ladder + ramp from min.
test('TC1 idle → target = startingS + warmingupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'idle' }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10 + 20 + 60 / 2); // 60s
});
// TC2 — operational up.
test('TC2 operational up = |targetposition|/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 40 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10);
});
// TC3 — operational down. ETA is positive.
test('TC3 operational down = |targetposition|/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 80 }), { targetPosition: 30 });
assert.equal(t.etaToTargetS(), 25);
});
// TC4 — no-op.
test('TC4 operational, target == position → 0s', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 50 }), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 0);
});
// TC5 — accelerating post-abort residue, same formula as operational.
test('TC5 accelerating residue = operational formula', () => {
const t = new MoveTrajectory(makeProfile({ state: 'accelerating', position: 35 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 12.5);
});
// TC6 — decelerating residue.
test('TC6 decelerating residue = operational formula', () => {
const t = new MoveTrajectory(makeProfile({ state: 'decelerating', position: 70 }), { targetPosition: 40 });
assert.equal(t.etaToTargetS(), 15);
});
// TC7 — warmingup, remaining time from stateManager.
test('TC7 warmingup = remainingWarmupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({
state: 'warmingup',
position: 0,
remainingTransitionS: 12,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 12 + 50 / 2); // 37s
});
// TC7b — warmingup but no remaining-time observation: falls back to full
// configured warmup (worst-case). Kept for resilience when the state machine
// pre-dates the getter.
test('TC7b warmingup fallback to full warmingupS when no remaining provided', () => {
const t = new MoveTrajectory(makeProfile({
state: 'warmingup',
position: 0,
remainingTransitionS: null,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 20 + 50 / 2); // 45s
});
// TC8 — starting: remaining + full warmup + ramp.
test('TC8 starting = remainingStartingS + warmingupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({
state: 'starting',
position: 0,
remainingTransitionS: 8,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 8 + 20 + 50 / 2); // 53s
});
// TC8b — boundary: remaining hits 0 just before the setTimeout fires.
test('TC8b starting with remainingTransitionS=0 still yields positive ETA', () => {
const t = new MoveTrajectory(makeProfile({
state: 'starting',
position: 0,
remainingTransitionS: 0,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 0 + 20 + 50 / 2); // 45s
});
// TC9 — shutdown ladder excluded: returns null so scheduler skips it.
test('TC9a stopping → null', () => {
const t = new MoveTrajectory(makeProfile({ state: 'stopping', position: 30 }), { targetPosition: 0 });
assert.equal(t.etaToTargetS(), null);
});
test('TC9b coolingdown → null', () => {
const t = new MoveTrajectory(makeProfile({ state: 'coolingdown', position: 0 }), { targetPosition: 0 });
assert.equal(t.etaToTargetS(), null);
});
// TC10 — target above max clamps; ETA uses clamped value.
test('TC10 target above maxPosition clamps to max', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, maxPosition: 100 }), { targetPosition: 120 });
assert.equal(t.targetPosition, 100);
assert.equal(t.etaToTargetS(), 50);
});
// TC11 — target below min clamps; ETA zero when already at min.
test('TC11 target below min clamps to min; ETA = 0 when at min', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, minPosition: 0 }), { targetPosition: -5 });
assert.equal(t.targetPosition, 0);
assert.equal(t.etaToTargetS(), 0);
});
// TC12 — zero velocity yields Infinity, not NaN or crash.
test('TC12 zero velocity → Infinity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, velocityPctPerS: 0 }), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), Infinity);
});
// TC13 — non-finite target throws at construction (totality of etaToTargetS).
test('TC13 non-finite target throws at construction', () => {
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: NaN }), TypeError);
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: undefined }), TypeError);
});
// Extra: minPosition above 0 is honoured in ramp distance for startup cases.
test('TC1b idle with minPosition=10 → ramp from 10, not 0', () => {
const t = new MoveTrajectory(makeProfile({ state: 'idle', minPosition: 10 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10 + 20 + (60 - 10) / 2); // 55s
});