Files
machineGroupControl/test/basic/movementScheduler.basic.test.js
znetsixe 472402c62d 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>
2026-05-17 19:43:55 +02:00

308 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { plan } = require('../../src/movement/movementScheduler');
// Profile builder — same shape as buildProfile output. positionForFlow
// approximates the inverse curve as a linear mapping over [min,max] for
// flow ∈ [0, maxFlow], which is enough to test scheduler logic without
// dragging real curve math in.
function makeProfile(over = {}) {
const defaults = {
id: 'A',
state: 'operational',
position: 0,
minPosition: 0,
maxPosition: 100,
velocityPctPerS: 2,
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
remainingTransitionS: null,
maxFlow: 100, // synthetic — for the test mapping below
};
const p = Object.assign(defaults, over);
// Linear position-for-flow over [min,max].
p.positionForFlow = (flow) => {
if (!Number.isFinite(flow) || flow <= 0) return p.minPosition;
return p.minPosition + (flow / p.maxFlow) * (p.maxPosition - p.minPosition);
};
// flowAt — inverse of the above.
p.flowAt = (pos /*, pressure */) => {
if (!Number.isFinite(pos)) return 0;
if (p.maxPosition === p.minPosition) return 0;
return ((pos - p.minPosition) / (p.maxPosition - p.minPosition)) * p.maxFlow;
};
return p;
}
// Tick rounding helper — scheduler uses Math.round(eta/tickS).
function tickRound(s, tickS = 1) { return Math.round(s / tickS); }
test('plan: idle → start a single pump (no other pumps online)', () => {
const profiles = [makeProfile({ id: 'A', state: 'idle', position: 0 })];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
// Two commands: execsequence(startup) + flowmovement(60). Both at tick 0.
assert.equal(out.commands.length, 2);
assert.equal(out.commands[0].action, 'execsequence');
assert.equal(out.commands[0].sequence, 'startup');
assert.equal(out.commands[0].fireAtTickN, 0);
assert.equal(out.commands[1].action, 'flowmovement');
assert.equal(out.commands[1].flow, 60);
assert.equal(out.commands[1].fireAtTickN, 0);
// tStar = full startup ladder + ramp from 0 to position-for-60 (= 60%).
// = 10 + 20 + 60/2 = 60s.
assert.equal(out.tStarS, 60);
});
test('plan: operational up-move (no rendezvous partner)', () => {
const profiles = [makeProfile({ id: 'A', state: 'operational', position: 40 })];
// Currently delivering 40 (at maxFlow=100 → linear), targeting 60.
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
assert.equal(out.commands.length, 1);
assert.equal(out.commands[0].action, 'flowmovement');
assert.equal(out.commands[0].flow, 60);
assert.equal(out.commands[0].fireAtTickN, 0);
// eta = |6040|/2 = 10s
assert.equal(out.tStarS, 10);
});
test('plan: rendezvous — startup pump + running pump that needs to shed load', () => {
// A: starting from idle, target 60. eta = 10 + 20 + 60/2 = 60s.
// B: operational at 80 (flow=80), target 40 (down). eta_B = 40/2 = 20s.
// Expectation: A fires at tick 0; B fires at tick (6020) = 40 so B
// FINISHES at the same time A reaches its target.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
];
const combination = [
{ machineId: 'A', flow: 60 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000);
const cmdA_startup = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
const cmdA_flow = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
const cmdB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
assert.ok(cmdA_startup, 'A startup');
assert.ok(cmdA_flow, 'A flowmovement (queued)');
assert.ok(cmdB, 'B flowmovement');
assert.equal(cmdA_startup.fireAtTickN, 0);
assert.equal(cmdA_flow.fireAtTickN, 0);
// B delayed so it finishes at tStar=60 → fires at 6020 = 40.
assert.equal(cmdB.fireAtTickN, 40);
assert.equal(out.tStarS, 60);
});
test('plan: all machines moving down — all land at slowest mover\'s eta', () => {
// Two operational pumps, both reducing flow. tStar = max eta over
// ALL non-noop moves (not just increasing) so the slower pump
// defines the rendezvous and the faster one is delayed to land
// with it. Net effect: same-time landing in pure-down scenarios too,
// sum-of-flows stays at the OLD setpoint until t* then drops cleanly.
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 80, velocityPctPerS: 2 }),
makeProfile({ id: 'B', state: 'operational', position: 70, velocityPctPerS: 2 }),
];
const combination = [
{ machineId: 'A', flow: 40 }, // target position via inverse curve → 40 (identity makeProfile)
{ machineId: 'B', flow: 30 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = |80-40|/2 = 20s, eta_B = |70-30|/2 = 20s → tStar = 20s.
assert.equal(out.tStarS, 20);
// Both pumps have eta == tStar so neither is delayed (fireAtTickN = 0).
for (const c of out.commands) {
assert.equal(c.fireAtTickN, 0, `${c.machineId} should fire at 0 when eta == tStar`);
}
});
test('plan: asymmetric down moves — faster one delayed to land with slower one', () => {
// A and B both reduce flow but A's move is faster. The new
// symmetric-rendezvous semantics delay the faster mover so both land
// at tStar = max eta.
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60, velocityPctPerS: 4 }), // fast
makeProfile({ id: 'B', state: 'operational', position: 80, velocityPctPerS: 2 }), // slow
];
const combination = [
{ machineId: 'A', flow: 40 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = |60-40|/4 = 5s, eta_B = |80-40|/2 = 20s → tStar = 20s.
assert.equal(out.tStarS, 20);
const cA = out.commands.find((c) => c.machineId === 'A');
const cB = out.commands.find((c) => c.machineId === 'B');
assert.equal(cA.fireAtTickN, 15, 'A (fast) delayed by tStar eta_A = 20 5 = 15');
assert.equal(cB.fireAtTickN, 0, 'B (slow) defines tStar — fires immediately');
});
test('plan: shutdown — removed machine gets execsequence(shutdown)', () => {
// A staying at flow 60, B getting shut down (target 0).
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60 }),
makeProfile({ id: 'B', state: 'operational', position: 50 }),
];
const combination = [
{ machineId: 'A', flow: 60 }, // unchanged
{ machineId: 'B', flow: 0 },
];
const out = plan(profiles, combination, 100_000);
const shutdownB = out.commands.find((c) => c.machineId === 'B' && c.action === 'execsequence' && c.sequence === 'shutdown');
assert.ok(shutdownB, 'B shutdown command present');
});
test('plan: noop — machine not in combination and already off does nothing', () => {
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60 }),
makeProfile({ id: 'B', state: 'idle', position: 0 }),
];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
const bAny = out.commands.find((c) => c.machineId === 'B');
assert.equal(bAny, undefined, 'B should be omitted (no-op)');
});
test('plan: rendezvous with three pumps — slowest startup sets the pace', () => {
// A: idle → 50 (full startup, slow).
// B: operational at 80 → 40 (down).
// C: operational at 30 → 50 (up, fast).
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
makeProfile({ id: 'C', state: 'operational', position: 30 }),
];
const combination = [
{ machineId: 'A', flow: 50 },
{ machineId: 'B', flow: 40 },
{ machineId: 'C', flow: 50 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = 10 + 20 + 50/2 = 55s (startup ladder + ramp; defines tStar)
// eta_B = |80-40|/2 = 20s (decreasing)
// eta_C = |50-30|/2 = 10s (increasing)
// tStar = max(55, 20, 10) = 55.
assert.equal(out.tStarS, 55);
const cA = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
const cC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
const cB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
// A's startup must begin NOW; its delayed flowmovement lands at t*
// by construction.
assert.equal(cA.fireAtTickN, 0);
// Symmetric rendezvous: BOTH B and C are delayed to land at t*.
// C (up, fast) gets delayed by t* eta_C = 45.
// B (down, mid) gets delayed by t* eta_B = 35.
assert.equal(cC.fireAtTickN, 55 - 10, 'C delayed to land at tStar (same-time landing)');
assert.equal(cB.fireAtTickN, 55 - 20, 'B delayed to land at tStar (same-time landing)');
});
test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar together', () => {
// Three idle pumps starting from min position. Different per-pump
// velocities → different etas. Without the rampStart gating, each
// pump's delayedMove would fire at warmup-end and ramp at its own
// speed, so the FAST pump lands long before the SLOW one — visible
// on the dashboard as staggered landing curves.
//
// Real-world reproducer: pumpingstation-complete-example with the
// editor's Reaction Speed set to A=3 %/s, B=10 %/s, C=1 %/s.
//
// Velocities here mirror that ratio but scaled for unit-test
// readability. Position range is [0,100] so rampDist = 100.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0, velocityPctPerS: 3 }),
makeProfile({ id: 'B', state: 'idle', position: 0, velocityPctPerS: 10 }),
makeProfile({ id: 'C', state: 'idle', position: 0, velocityPctPerS: 1 }),
];
const combination = [
{ machineId: 'A', flow: 100 },
{ machineId: 'B', flow: 100 },
{ machineId: 'C', flow: 100 },
];
const out = plan(profiles, combination, 100_000);
// Default ladder = starting(10) + warmingup(20) = 30 s.
// ramp_A = 100/3 ≈ 33.33 s → eta_A ≈ 63.33 s
// ramp_B = 100/10 = 10 s → eta_B = 40 s
// ramp_C = 100/1 = 100 s → eta_C = 130 s
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
// execsequence fires at 0 for ALL idle pumps (the ladder must start now).
for (const id of ['A', 'B', 'C']) {
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
assert.ok(exec, `${id} execsequence present`);
assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`);
}
// flowmovement gating — each pump's ramp must FINISH at tStar=130.
const flowA = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
const flowB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
const flowC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
// A (medium): rampStart = 130 33.33 ≈ 96.67 → fireAtTickN = 97.
assert.equal(flowA.fireAtTickN, Math.round(130 - 100 / 3));
// B (fast): rampStart = 130 10 = 120 → fireAtTickN = 120.
assert.equal(flowB.fireAtTickN, 120);
// C (slow, defines tStar): rendezvousRampStart = 130 100 = 30 == ladderS,
// so no extra delay needed — fall back to fireAtTickN=0 and let
// the pump's delayedMove fire it naturally at warmup-end.
assert.equal(flowC.fireAtTickN, 0);
// Sanity: with these schedules, all three pumps' ramps end at the
// same wall-clock instant (within rounding).
// A: 97 + 100/3 ≈ 130.33
// B: 120 + 10 = 130
// C: 30 (delayedMove) + 100 = 130
// Max spread ≈ 0.33 s — far better than the per-eta spread of
// 130 40 = 90 s the planner would produce without this gating.
});
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 0, velocityPctPerS: 0 }),
];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
// Eta is Infinity → filtered out of tStar computation (only finite etas count).
// Command still scheduled; fireAtTickN remains 0 for increasing move.
const c = out.commands.find((c) => c.action === 'flowmovement');
assert.ok(c);
assert.equal(c.fireAtTickN, 0);
assert.equal(out.tStarS, 0); // no finite increasing eta → tStar collapses to 0
});
test('plan: respects custom tickS option', () => {
// Same as the rendezvous test but with tickS=5 → fireAt should be in
// ticks-of-5-seconds, not seconds.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
];
const combination = [
{ machineId: 'A', flow: 60 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000, { tickS: 5 });
const cmdB = out.commands.find((c) => c.machineId === 'B');
assert.equal(out.tStarS, 60);
assert.equal(out.tickS, 5);
assert.equal(cmdB.fireAtTickN, tickRound(60 - 20, 5)); // = 8
});