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>
143 lines
5.9 KiB
JavaScript
143 lines
5.9 KiB
JavaScript
'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 + (target−min)/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 = |target−position|/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 = |target−position|/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 + (target−min)/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 + (target−min)/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
|
||
});
|