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>
This commit is contained in:
znetsixe
2026-05-27 16:22:32 +02:00
parent b59d8e60f7
commit 551ee6d70e
2 changed files with 43 additions and 50 deletions

View File

@@ -14,11 +14,16 @@
// (stopping / coolingdown / unknown) are skipped. // (stopping / coolingdown / unknown) are skipped.
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The // 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
// slowest move (typically a startup ladder + ramp) sets the deadline. // slowest move (typically a startup ladder + ramp) sets the deadline.
// 4. Every command is delayed by (t* eta_j) so it FINISHES at t*. // 4. Every command — including a startup's `execsequence` — is delayed by
// Exception: a startup's `execsequence` command must fire NOW so the // (t* eta_j) so its move FINISHES at t*. A startup is delayed as a
// ladder can begin — its own duration is what defines eta and thus // whole: its ladder begins at (t* eta) and completes at (t* rampS),
// t* — but the startup's queued flowmovement (held in the pump's // then the queued flowmovement (held in the pump's delayedMove) ramps to
// delayedMove) lands at t* by construction. // finish at t*. The slowest mover (t* eta == 0) fires immediately.
// Delaying the ladder — rather than firing it at tick 0 — is what keeps a
// faster-than-slowest startup from reaching `operational` early and
// sitting at its MINIMUM flow before t* (calcFlow at min position is not
// zero), which otherwise leaks ~min-flow into the group total ahead of
// the rendezvous (the staging bump).
// //
// Net effect: ALL pumps reach their per-pump flow target at the same // Net effect: ALL pumps reach their per-pump flow target at the same
// wall-clock instant t*. Sum-of-flows is monotonic during the transition // wall-clock instant t*. Sum-of-flows is monotonic during the transition
@@ -177,38 +182,31 @@ function plan(profiles, combination, currentPressure, options = {}) {
const isUnchanged = q.direction === 'unchanged'; const isUnchanged = q.direction === 'unchanged';
if (q.action === 'startup') { if (q.action === 'startup') {
// execsequence MUST begin NOW — the ladder duration is // Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
// baked into eta and can't be compressed. // by (t* eta), so the warmup ladder finishes (and the ramp
// begins) at (t* rampS) and the flow lands exactly at t*.
//
// The ladder duration can't be compressed, but it CAN be delayed.
// Firing the execsequence at tick 0 (the old behaviour) made a
// faster-than-slowest startup reach `operational` early and sit at
// its minimum flow from warmup-end until its delayed ramp — leaking
// ~min-flow into the group total before t* (the staging bump). For
// the slowest pump (eta == t*) fireAtTickNDelayed is 0, so it still
// fires immediately. The flowmovement fires on the same tick; the
// pump holds it in delayedMove through the ladder, then ramps over
// rampS to finish at t*.
commands.push({ commands.push({
machineId: q.machineId, machineId: q.machineId,
action: 'execsequence', action: 'execsequence',
sequence: 'startup', sequence: 'startup',
fireAtTickN: 0, fireAtTickN: fireAtTickNDelayed,
eta: q.eta, eta: q.eta,
}); });
// flowmovement timing.
//
// Default behaviour: queue it at tick 0; the pump's
// delayedMove holds it until warmup completes, after which
// the pump ramps at its own velocity. That ramp finishes at
// ladderS + rampS = eta. For a single pump (eta == tStar)
// this naturally lands at tStar — no extra delay needed.
//
// Mixed-speed multi-startup: if this pump is FASTER than
// the slowest one, its natural landing (at its own eta)
// is EARLIER than tStar. Delay the flowmovement so the
// ramp starts at (tStar rampS), making the ramp finish
// at tStar regardless of per-pump speed.
const naturalRampStartS = q.ladderS;
const rendezvousRampStartS = tStar - q.rampS;
const flowMoveFireAtS = rendezvousRampStartS > naturalRampStartS
? rendezvousRampStartS
: 0;
commands.push({ commands.push({
machineId: q.machineId, machineId: q.machineId,
action: 'flowmovement', action: 'flowmovement',
flow: q.targetFlow, flow: q.targetFlow,
fireAtTickN: Math.max(0, Math.round(flowMoveFireAtS / tickS)), fireAtTickN: fireAtTickNDelayed,
eta: q.eta, eta: q.eta,
}); });
} else if (q.action === 'flowmove') { } else if (q.action === 'flowmove') {

View File

@@ -242,34 +242,29 @@ test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar t
// tStar = max(eta_A, eta_B, 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}`); 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). // 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']) { for (const id of ['A', 'B', 'C']) {
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence'); 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(exec, `${id} execsequence present`);
assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`); 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`);
} }
// flowmovement gating — each pump's ramp must FINISH at tStar=130. // Sanity: with the ladder delayed, each pump reaches `operational` only at
const flowA = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement'); // (delay + ladderS) and its ramp ends at the same wall-clock instant ≈ 130.
const flowB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement'); // A: 67 + 30 (op) + 33.33 ≈ 130.33
const flowC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement'); // B: 90 + 30 (op) + 10 = 130
// C: 0 + 30 (op) + 100 = 130
// A (medium): rampStart = 130 33.33 ≈ 96.67 → fireAtTickN = 97. // No pump sits at `operational` (and minimum flow) before its ramp — that
assert.equal(flowA.fireAtTickN, Math.round(130 - 100 / 3)); // early min-flow was the staging bump this just-in-time start removes.
// 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', () => { test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {