diff --git a/src/movement/movementScheduler.js b/src/movement/movementScheduler.js index 0ba6d93..39956b8 100644 --- a/src/movement/movementScheduler.js +++ b/src/movement/movementScheduler.js @@ -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') { diff --git a/test/basic/movementScheduler.basic.test.js b/test/basic/movementScheduler.basic.test.js index c230d83..13f4c7e 100644 --- a/test/basic/movementScheduler.basic.test.js +++ b/test/basic/movementScheduler.basic.test.js @@ -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. 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']) { 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.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. - 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. + // 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', () => {