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.
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
// slowest move (typically a startup ladder + ramp) sets the deadline.
// 4. Every command is delayed by (t* eta_j) so it FINISHES at t*.
// Exception: a startup's `execsequence` command must fire NOW so the
// ladder can begin — its own duration is what defines eta and thus
// t* — but the startup's queued flowmovement (held in the pump's
// delayedMove) lands at t* by construction.
// 4. Every command — including a startup's `execsequence` — is delayed by
// (t* eta_j) so its move FINISHES at t*. A startup is delayed as a
// whole: its ladder begins at (t* eta) and completes at (t* rampS),
// then the queued flowmovement (held in the pump's delayedMove) ramps to
// 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
// 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';
if (q.action === 'startup') {
// execsequence MUST begin NOW — the ladder duration is
// baked into eta and can't be compressed.
// Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
// 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({
machineId: q.machineId,
action: 'execsequence',
sequence: 'startup',
fireAtTickN: 0,
fireAtTickN: fireAtTickNDelayed,
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({
machineId: q.machineId,
action: 'flowmovement',
flow: q.targetFlow,
fireAtTickN: Math.max(0, Math.round(flowMoveFireAtS / tickS)),
fireAtTickN: fireAtTickNDelayed,
eta: q.eta,
});
} else if (q.action === 'flowmove') {