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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user