Files
machineGroupControl/test/basic/movementScheduler.basic.test.js
znetsixe 551ee6d70e fix(mgc): just-in-time startup in rendezvous planner (kill staging flow bump)
Delay a startup's execsequence by (t* − eta) instead of firing it at tick 0.
Previously the ladder fired immediately for every starting pump; a
faster-than-slowest startup then reached `operational` early and sat at its
minimum flow (calcFlow at min position is non-zero) from warmup-end until its
delayed ramp — leaking ~one pump's minimum flow into the group total before
the rendezvous instant t* (the 207→309 staging bump observed live).

Now the whole startup (ladder + ramp) is delayed: the ladder begins at
(t* − eta), completes at (t* − rampS), then the queued flowmovement ramps to
finish exactly at t*. The slowest pump (eta == t*) still fires at tick 0.
Sum-of-flows is monotonic through the transition.

Updated movementScheduler.basic.test.js mixed-speed multi-startup assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:22:32 +02:00

303 lines
14 KiB
JavaScript
Raw Permalink 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}`);
// Just-in-time: the WHOLE startup (ladder + ramp) is delayed by (tStar
// eta), so both execsequence and flowmovement fire at the same delayed
// tick. eta_A = 30 + 33.33 ≈ 63.33, eta_B = 40, eta_C = 130.
// A: round(130 63.33) = 67
// B: round(130 40) = 90
// C: round(130 130) = 0 (slowest — defines tStar, fires now)
const delays = { A: Math.round(130 - (30 + 100 / 3)), B: 90, C: 0 };
for (const id of ['A', 'B', 'C']) {
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
const flow = out.commands.find((c) => c.machineId === id && c.action === 'flowmovement');
assert.ok(exec, `${id} execsequence present`);
assert.ok(flow, `${id} flowmovement present`);
assert.equal(exec.fireAtTickN, delays[id], `${id} ladder delayed to land at tStar`);
assert.equal(flow.fireAtTickN, delays[id], `${id} flowmovement fires with the ladder`);
}
// Sanity: with the ladder delayed, each pump reaches `operational` only at
// (delay + ladderS) and its ramp ends at the same wall-clock instant ≈ 130.
// A: 67 + 30 (op) + 33.33 ≈ 130.33
// B: 90 + 30 (op) + 10 = 130
// C: 0 + 30 (op) + 100 = 130
// No pump sits at `operational` (and minimum flow) before its ramp — that
// early min-flow was the staging bump this just-in-time start removes.
});
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
});