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

@@ -22,17 +22,44 @@ function makeLogger() {
};
}
function makeSource({ name = 'mgc-1', handleInputResult = undefined, dt = { flow: { min: 0, max: 100 } } } = {}) {
function makeSource({
name = 'mgc-1',
handleInputResult = undefined,
dt = { flow: { min: 0, max: 100 } },
// Initial mode for the fake. Defaults to optimalControl so gates pass for
// the historical tests; per-test override via the returned `source.mode = …`.
mode = 'optimalControl',
// Override the gate decisions. Default-true matches the no-gating world
// tests assumed before this change; negative-path tests pass functions that
// return false for specific actions / sources.
isValidActionForMode = () => true,
isValidSourceForMode = () => true,
} = {}) {
const calls = {
setMode: [],
handleInput: [],
registerChild: [],
turnOffAllMachines: 0,
gateAction: [],
gateSource: [],
};
const source = {
logger: makeLogger(),
config: { general: { name } },
setMode: (m) => calls.setMode.push(m),
mode,
setMode: (m) => { calls.setMode.push(m); /* keep fake.mode unchanged unless test does it */ },
isValidActionForMode: (action, m) => {
const ok = isValidActionForMode(action, m);
calls.gateAction.push({ action, mode: m, ok });
if (!ok) source.logger.warn(`action '${action}' not allowed in mode '${m}'`);
return ok;
},
isValidSourceForMode: (src, m) => {
const ok = isValidSourceForMode(src, m);
calls.gateSource.push({ src, mode: m, ok });
if (!ok) source.logger.warn(`source '${src}' not allowed in mode '${m}'`);
return ok;
},
handleInput: async (src, demand) => {
calls.handleInput.push({ src, demand });
if (handleInputResult instanceof Error) throw handleInputResult;
@@ -192,3 +219,124 @@ test('child.register with unknown child id logs warn and does not throw', async
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});
// --- mode gate tests -------------------------------------------------------
test('gate: set.demand in maintenance mode is dropped (action not allowed)', async () => {
// Mirror schema: maintenance allows only statusCheck. The dispatch action
// for a positive demand under optimalControl/priorityControl is
// execOptimalCombination / execSequentialControl — neither in maintenance.
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: (action) => action === 'statusCheck',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx());
assert.equal(calls.handleInput.length, 0, 'handleInput must not be invoked');
assert.equal(calls.turnOffAllMachines, 0, 'turnOffAllMachines must not be invoked');
assert.ok(
source.logger.calls.warn.some((m) => m.includes('not allowed')),
`expected warn about action not allowed in maintenance, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
test("gate: set.demand from msg.source 'physical' in maintenance is dropped (source not allowed)", async () => {
// Maintenance accepts sources ['parent','GUI'] per schema. Physical/HMI is
// rejected by the source gate even before we ask which action to perform.
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: () => true, // pretend action is allowed; source gate must still reject
isValidSourceForMode: (src) => src === 'parent' || src === 'GUI',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50, source: 'physical' }, source, makeCtx());
assert.equal(calls.handleInput.length, 0);
assert.equal(calls.turnOffAllMachines, 0);
assert.ok(
source.logger.calls.warn.some((m) => m.includes("'physical'") && m.includes('not allowed')),
`expected warn about physical source not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
test('gate: set.demand from msg.source GUI in optimalControl reaches handleInput', async () => {
const { source, calls } = makeSource({
mode: 'optimalControl',
isValidActionForMode: (action) =>
['statusCheck', 'execOptimalCombination', 'balanceLoad', 'emergencyStop'].includes(action),
isValidSourceForMode: (src) => ['parent', 'GUI', 'physical', 'API'].includes(src),
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 25, source: 'GUI' }, source, makeCtx());
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 25 });
// Sanity check on the gate plumbing: both gates were consulted with the
// expected (action, source, mode) tuple.
assert.ok(calls.gateAction.some((g) => g.action === 'execOptimalCombination' && g.mode === 'optimalControl' && g.ok));
assert.ok(calls.gateSource.some((g) => g.src === 'GUI' && g.mode === 'optimalControl' && g.ok));
});
test('gate: emergencyStop (negative demand) gated by mode → maintenance blocks the stop-all', async () => {
// A negative demand is the operator stop-all signal. The schema declares
// emergencyStop in optimalControl/priorityControl but NOT in maintenance,
// so this should be rejected too — maintenance is "monitor only", which
// includes "no dispatch decisions, even shutdowns".
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: (action) => action === 'statusCheck',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx());
assert.equal(calls.turnOffAllMachines, 0, 'turnOff must be gated');
assert.ok(
source.logger.calls.warn.some((m) => m.includes('emergencyStop') && m.includes('not allowed')),
`expected warn about emergencyStop not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
// --- mode-string normalisation (specificClass internals) --------------------
const { _normaliseMode, ALLOWED_MODES } = require('../../src/specificClass');
test('mode normalisation: camelCase pass-through, lowercase accepted, garbage rejected', () => {
assert.equal(_normaliseMode('optimalControl'), 'optimalControl');
assert.equal(_normaliseMode('optimalcontrol'), 'optimalControl');
assert.equal(_normaliseMode('OPTIMALCONTROL'), 'optimalControl');
assert.equal(_normaliseMode('priorityControl'), 'priorityControl');
assert.equal(_normaliseMode('prioritycontrol'), 'priorityControl');
assert.equal(_normaliseMode('maintenance'), 'maintenance');
assert.equal(_normaliseMode('MAINTENANCE'), 'maintenance');
assert.equal(_normaliseMode('wat'), null);
assert.equal(_normaliseMode(''), null);
assert.equal(_normaliseMode(null), null);
assert.equal(_normaliseMode(undefined), null);
assert.deepEqual(ALLOWED_MODES, ['optimalControl', 'priorityControl', 'maintenance']);
});
// --- schema-shape regression -----------------------------------------------
test('schema regression: allowedSources keys are camelCase for all three modes', () => {
// Read the JSON directly — generalFunctions' package.json `exports` map
// doesn't expose the configs subpath, and we don't want to add it just for
// a test. Path is repo-relative from this test file.
const fs = require('node:fs');
const path = require('node:path');
const schemaPath = path.resolve(__dirname, '../../../generalFunctions/src/configs/machineGroupControl.json');
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
const allowedSourcesSchema = schema.mode.allowedSources.rules.schema;
assert.ok(allowedSourcesSchema.optimalControl, 'optimalControl key must exist on allowedSources');
assert.ok(allowedSourcesSchema.priorityControl, 'priorityControl key must exist on allowedSources');
assert.ok(allowedSourcesSchema.maintenance, 'maintenance key must exist on allowedSources');
// Maintenance is monitor-only: parent + GUI permitted, physical/API rejected.
const mDefaults = allowedSourcesSchema.maintenance.default;
assert.ok(mDefaults.includes('parent'), `maintenance default should permit parent, got ${mDefaults}`);
assert.ok(mDefaults.includes('GUI'), `maintenance default should permit GUI, got ${mDefaults}`);
assert.ok(!mDefaults.includes('physical'), 'maintenance must NOT permit physical writes');
assert.ok(!mDefaults.includes('API'), 'maintenance must NOT permit API writes');
// Catch a regression to lowercase keys.
assert.equal(allowedSourcesSchema.optimalcontrol, undefined, 'lowercase optimalcontrol key must NOT exist');
assert.equal(allowedSourcesSchema.prioritycontrol, undefined, 'lowercase prioritycontrol key must NOT exist');
});

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
});

View File

@@ -0,0 +1,136 @@
'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']);
});

View File

@@ -0,0 +1,307 @@
'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
});