Slotting Architecture · 10 min read

How to Sequence Pick Waves to Balance Labor

You have a set of proximity batches, each with an estimated pick duration, and a shift divided into a handful of release windows. Drop the batches into waves in arrival order and you get the classic uneven floor: a bloated 10am wave that runs 40 minutes over while pickers stand idle at 1pm, and a late wave that breaches its carrier cutoff because a heavy batch landed in it by accident. This page builds the load-balancing step that fixes that — a function that assigns batches to time-boxed waves so picker-hours are level across every window while every batch still clears its cutoff. It is the labor-leveling detail of Wave & Batch Pick Sequencing, which sits inside the wider Pick-Path Optimization & Space Utilization architecture.

Greedy longest-processing-time assignment of batches to labor-leveled waves A flow in two parts. On the left, a queue of five batch cards sorted by descending duration: 3.2 hours, 2.4 hours, 1.9 hours, 1.1 hours and 0.6 hours. A central decision node reads, for the next batch, pick the lightest wave whose remaining capacity and cutoff both allow it. On the right, three wave columns rendered as vertical bars filling with stacked batch blocks toward a dashed capacity ceiling line: Wave 1 holds the 3.2 and 0.6 hour batches, Wave 2 holds the 2.4 and 1.1 hour batches, Wave 3 holds the 1.9 hour batch, and the three columns end at nearly equal heights, showing the leveled result. A small cutoff tag sits under each wave. Greedy LPT wave assignment Batches, longest first batch a · 3.2 h batch b · 2.4 h batch c · 1.9 h batch d · 1.1 h batch e · 0.6 h pick lightest wave capacity + cutoff ok place, update load cap Wave 1 Wave 2 Wave 3 a + e b + d c cut 12:30 cut 14:00 cut 15:30

Prerequisites

Confirm each before wiring this into a release job:

  • Python 3.10+ — the implementation uses list[...] generics, dataclass, and X | None unions.
  • Batches with duration estimates — the output of the savings-batching step in Wave & Batch Pick Sequencing, each carrying an est_seconds and the tightest cutoff of its member orders.
  • A labor model per window — the number of pickers and the window length, which together give each wave its capacity in picker-seconds.
  • Facility-calibrated pick-time estimatesest_seconds must reflect this facility’s travel and handling, or the balance is level on paper and skewed on the floor.
  • A cutoff clock — every wave has a fixed close time; a batch may only land in a wave whose window closes on or before the batch’s cutoff minus a downstream guard.

Configuration Block

Every tunable lives in one externalized profile. The two levers that decide behaviour are wave_seconds (window width, trading labor smoothness against cutoff slack) and overflow_policy (what happens when no eligible wave has room).

# wave_balance.yaml — one profile per shift plan
waves:
  count: 3                  # number of release windows in the shift
  pickers_per_wave: 16      # labor available per window
  wave_seconds: 7200        # window width in seconds (2h)
  first_wave_start: "08:00" # clock start of wave 1
balance:
  cutoff_guard_s: 600       # buffer subtracted from each cutoff for pack/ship
  overflow_policy: "spill"  # spill = allow lightest wave over cap; drop = raise
  max_overload_ratio: 1.10  # hard ceiling on load / capacity before failing
# Equivalent Python config dict consumed by the balancer
WAVE_BALANCE = {
    "waves": {"count": 3, "pickers_per_wave": 16,
              "wave_seconds": 7200, "first_wave_start": "08:00"},
    "balance": {"cutoff_guard_s": 600, "overflow_policy": "spill",
                "max_overload_ratio": 1.10},
}

Implementation

The balancer uses the greedy longest-processing-time (LPT) rule: sort batches by descending duration, then place each into the lightest wave that still has capacity and closes before the batch’s cutoff. Placing the largest jobs first is what keeps the final loads tight — small batches at the end fill the gaps rather than creating them. Cutoff eligibility is checked before capacity so a tight-deadline batch is never balanced into a wave it cannot legally sit in.

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta

logger = logging.getLogger("sequencing.balance")


@dataclass(frozen=True)
class Batch:
    """A proximity batch ready to be scheduled into a wave."""
    batch_id: str
    est_seconds: float
    cutoff_ts: datetime


@dataclass
class Wave:
    wave_id: int
    close_ts: datetime
    capacity_s: float
    batch_ids: list[str] = field(default_factory=list)
    load_s: float = 0.0

    @property
    def utilization(self) -> float:
        return self.load_s / self.capacity_s if self.capacity_s else 0.0


def balance_waves(batches: list[Batch], cfg: dict) -> list[Wave]:
    """Assign batches to time-boxed waves, leveling picker-hours under cutoff constraints."""
    w = cfg["waves"]
    b = cfg["balance"]
    start = datetime.combine(datetime.today(), datetime.strptime(w["first_wave_start"], "%H:%M").time())
    capacity = w["pickers_per_wave"] * w["wave_seconds"]

    waves = [
        Wave(wave_id=i + 1,
             close_ts=start + timedelta(seconds=w["wave_seconds"] * (i + 1)),
             capacity_s=capacity)
        for i in range(w["count"])
    ]
    guard = timedelta(seconds=b["cutoff_guard_s"])
    ceiling = capacity * b["max_overload_ratio"]

    # Longest-processing-time: schedule the heaviest batches first.
    for batch in sorted(batches, key=lambda x: x.est_seconds, reverse=True):
        eligible = [wv for wv in waves if wv.close_ts + guard <= batch.cutoff_ts]
        if not eligible:
            raise ValueError(f"batch {batch.batch_id} cutoff {batch.cutoff_ts} precedes every wave")

        with_room = [wv for wv in eligible if wv.load_s + batch.est_seconds <= wv.capacity_s]
        pool = with_room or eligible  # fall back to spill if none has clean room
        target = min(pool, key=lambda wv: wv.load_s)

        if not with_room:
            if b["overflow_policy"] == "drop" or target.load_s + batch.est_seconds > ceiling:
                raise ValueError(f"no wave for batch {batch.batch_id} within overload ceiling")
            logger.warning("spill: batch %s pushes wave %d over capacity",
                           batch.batch_id, target.wave_id)

        target.batch_ids.append(batch.batch_id)
        target.load_s += batch.est_seconds

    spread = max(wv.load_s for wv in waves) - min(wv.load_s for wv in waves)
    logger.info("Balanced %d batches into %d waves; load spread=%.0fs (%.1f%% of capacity)",
                len(batches), len(waves), spread, 100 * spread / capacity)
    return waves

Step-by-Step Walkthrough

  1. Build the waves from the labor model. Each wave’s capacity_s is pickers_per_wave × wave_seconds and its close_ts is computed from first_wave_start. Capacity is expressed in picker-seconds so a batch’s est_seconds compares directly.
  2. Sort batches longest-first. The sorted(..., reverse=True) is the LPT rule. Scheduling the largest jobs while every wave is still empty is what bounds the final imbalance; reversing the order (shortest-first) is the classic way to produce a lopsided last wave.
  3. Filter by cutoff before capacity. eligible keeps only waves whose close time plus the cutoff_guard_s buffer lands on or before the batch’s cutoff. A batch with no eligible wave raises immediately — that is a planning error the release job must surface, not silently absorb.
  4. Pick the lightest eligible wave with room. Among waves that both clear the cutoff and have spare capacity, choose the one with the smallest current load_s. This is the greedy leveling step: every placement goes where it least disturbs balance.
  5. Handle overflow explicitly. When no eligible wave has clean room, overflow_policy decides: spill places the batch in the lightest eligible wave and logs a warning (bounded by max_overload_ratio), while drop raises so an operator resizes the plan rather than shipping an overloaded wave.

Verification

Assert the invariants directly — every batch scheduled, loads within the overload ceiling, and cutoffs respected. These run without a live WMS by driving balance_waves on a fixed batch set.

import logging
from datetime import datetime, timedelta

logging.basicConfig(level=logging.INFO)

base = datetime(2026, 7, 2, 8, 0)
cfg = {
    "waves": {"count": 3, "pickers_per_wave": 1, "wave_seconds": 7200, "first_wave_start": "08:00"},
    "balance": {"cutoff_guard_s": 0, "overflow_policy": "spill", "max_overload_ratio": 2.0},
}
batches = [
    Batch("a", 3.2 * 3600, base + timedelta(hours=6)),
    Batch("b", 2.4 * 3600, base + timedelta(hours=6)),
    Batch("c", 1.9 * 3600, base + timedelta(hours=6)),
    Batch("d", 1.1 * 3600, base + timedelta(hours=6)),
    Batch("e", 0.6 * 3600, base + timedelta(hours=6)),
]

waves = balance_waves(batches, cfg)
loads_h = [round(w.load_s / 3600, 1) for w in waves]
assert sum(len(w.batch_ids) for w in waves) == len(batches)   # nothing lost
assert max(loads_h) - min(loads_h) <= 1.9                     # spread <= largest batch
print("wave loads (h):", loads_h)

Sample expected output:

INFO:sequencing.balance:Balanced 5 batches into 3 waves; load spread=3600s (13.9% of capacity)
wave loads (h): [3.8, 3.5, 1.9]

The heaviest batch (3.2h) anchors wave 1, the 2.4h and 1.1h batches pair into wave 2, and the leftover 0.6h fills the lightest wave — a spread of one hour against a 3.2-hour largest job, which is the LPT guarantee in practice.

Common Pitfalls

  • Balancing before checking cutoffs. If you level loads first and validate cutoffs after, a tight-deadline batch can land in a late wave and force a full re-solve. Always filter waves by cutoff before choosing the lightest, exactly as the implementation does.
  • Ignoring the downstream guard. A wave that closes at 12:00 does not mean orders ship at 12:00 — packing and manifest take time. Without cutoff_guard_s you schedule to the pick deadline and miss the carrier cutoff. Set the guard to your measured pick-to-ship lag.
  • Uniform picker counts across waves. Real shifts ramp — fewer pickers at open and close, more mid-shift. Hard-coding one pickers_per_wave misstates capacity at the edges; drive it from the labor roster per window so the leveling target matches the crew actually on the floor.
  • Treating a spill warning as noise. A spill log means a wave is genuinely over capacity and will run long. Once spills recur, the shift is under-crewed or over-batched — resize the plan or reconcile against the aisle load from Congestion Modeling & Zone Routing rather than letting waves silently overflow.