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>
137 lines
5.5 KiB
JavaScript
137 lines
5.5 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const MovementExecutor = require('../../src/movement/movementExecutor');
|
|
|
|
function mkSchedule(commands, tStarS = 0, tickS = 1) {
|
|
return { tStarS, tickS, commands };
|
|
}
|
|
|
|
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
|
|
test('executor: throws if fireCommand callback missing', () => {
|
|
assert.throws(() => new MovementExecutor({}), TypeError);
|
|
});
|
|
|
|
test('executor: fires commands whose fireAtTickN <= cursor', async () => {
|
|
const fired = [];
|
|
const ex = new MovementExecutor({
|
|
fireCommand: (c) => fired.push(c),
|
|
logger: noopLogger,
|
|
});
|
|
ex.replan(mkSchedule([
|
|
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
|
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 2, eta: 2 },
|
|
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 5, eta: 5 },
|
|
]));
|
|
let firedThisTick = await ex.tick();
|
|
assert.equal(firedThisTick.length, 1);
|
|
assert.equal(firedThisTick[0].machineId, 'A');
|
|
firedThisTick = await ex.tick();
|
|
assert.equal(firedThisTick.length, 0);
|
|
firedThisTick = await ex.tick();
|
|
assert.equal(firedThisTick.length, 1);
|
|
assert.equal(firedThisTick[0].machineId, 'B');
|
|
await ex.tick(); await ex.tick();
|
|
firedThisTick = await ex.tick();
|
|
assert.equal(firedThisTick.length, 1);
|
|
assert.equal(firedThisTick[0].machineId, 'C');
|
|
|
|
assert.deepEqual(fired.map((c) => c.machineId), ['A', 'B', 'C']);
|
|
assert.equal(ex.pending(), 0);
|
|
});
|
|
|
|
test('executor: replan drops unfired commands and resets cursor', async () => {
|
|
const fired = [];
|
|
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
|
|
|
|
ex.replan(mkSchedule([
|
|
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
|
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 10, eta: 10 },
|
|
]));
|
|
await ex.tick(); // A fires
|
|
assert.deepEqual(fired, ['A']);
|
|
assert.equal(ex.pending(), 1);
|
|
|
|
ex.replan(mkSchedule([
|
|
{ machineId: 'X', action: 'flowmovement', flow: 80, fireAtTickN: 0, eta: 0 },
|
|
{ machineId: 'Y', action: 'flowmovement', flow: 20, fireAtTickN: 3, eta: 3 },
|
|
]));
|
|
assert.equal(ex.cursor(), 0, 'cursor reset on replan');
|
|
await ex.tick(); // X fires
|
|
assert.deepEqual(fired, ['A', 'X']);
|
|
await ex.tick(); await ex.tick(); await ex.tick();
|
|
assert.ok(!fired.includes('B'), 'old B move was dropped by replan');
|
|
assert.ok(fired.includes('Y'), 'new Y move fired after delay');
|
|
});
|
|
|
|
test('executor: fires only once per command even across many ticks', async () => {
|
|
const fired = [];
|
|
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
|
|
ex.replan(mkSchedule([
|
|
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
|
]));
|
|
for (let i = 0; i < 5; i++) await ex.tick();
|
|
assert.deepEqual(fired, ['A']);
|
|
});
|
|
|
|
test('executor: catches fireCommand errors and continues', async () => {
|
|
const fired = [];
|
|
const ex = new MovementExecutor({
|
|
fireCommand: (c) => {
|
|
if (c.machineId === 'B') throw new Error('boom');
|
|
fired.push(c.machineId);
|
|
},
|
|
logger: noopLogger,
|
|
});
|
|
ex.replan(mkSchedule([
|
|
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
|
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 0, eta: 0 },
|
|
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 0, eta: 0 },
|
|
]));
|
|
await ex.tick();
|
|
// B's error must not block A or C.
|
|
assert.deepEqual(fired, ['A', 'C']);
|
|
});
|
|
|
|
test('executor: empty / null schedule is safe to tick', async () => {
|
|
const ex = new MovementExecutor({ fireCommand: () => {}, logger: noopLogger });
|
|
assert.deepEqual(await ex.tick(), []);
|
|
ex.replan({ commands: [] });
|
|
assert.deepEqual(await ex.tick(), []);
|
|
});
|
|
|
|
test('executor: tick fires commands synchronously and does NOT await their promises', async () => {
|
|
// Contract: tick() returns as soon as every due fireCommand has been
|
|
// invoked. It does NOT wait for the returned promises to resolve.
|
|
// This matters because a flowmovement-after-startup resolves only
|
|
// after the pump's entire ramp completes — awaiting it would freeze
|
|
// the executor's wall-clock progression and drag every delayed
|
|
// command in the schedule forward by that duration.
|
|
const order = [];
|
|
let resolveFire;
|
|
const firePromise = new Promise((r) => { resolveFire = r; });
|
|
const ex = new MovementExecutor({
|
|
fireCommand: (c) => {
|
|
order.push(`fire-start-${c.machineId}`);
|
|
return firePromise.then(() => { order.push(`fire-end-${c.machineId}`); });
|
|
},
|
|
logger: noopLogger,
|
|
});
|
|
ex.replan(mkSchedule([
|
|
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
|
]));
|
|
const tickPromise = ex.tick().then(() => order.push('tick-resolved'));
|
|
// Wait one microtask cycle: tick should already have resolved even
|
|
// though fire is still pending.
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.deepEqual(order, ['fire-start-A', 'tick-resolved'],
|
|
'tick must resolve immediately after invoking fireCommand — not wait for its promise');
|
|
resolveFire();
|
|
await tickPromise;
|
|
// The fire's tail runs in the background and lands after tick resolved.
|
|
assert.deepEqual(order, ['fire-start-A', 'tick-resolved', 'fire-end-A']);
|
|
});
|