Slotting Architecture · 19 min read

Wave & Batch Pick Sequencing

A perfectly slotted warehouse still bleeds labor at the release door. If you drop 1,200 orders onto the floor as 1,200 independent picker trips, every A-mover you carefully placed in the golden zone gets walked to once per order instead of once per batch, and the travel savings from slotting evaporate into redundant round-trips. Wave and batch sequencing is the release-layer discipline that recovers those savings: it decides which orders travel together, in what order the aisles are visited, and when each wave hits the floor so that aggregate travel drops and labor stays level across the shift. This guide is part of the Pick-Path Optimization & Space Utilization system, and it covers the sequencing layer specifically — the algorithms that turn a flat order pool into a timed sequence of proximity-grouped batches.

The engineering thesis is that release order is an optimization variable, not a FIFO queue. Slotting fixes where inventory lives; sequencing fixes how the demand stream consumes it. Every section below grounds that in a concrete cost function, batching heuristic, or scheduling constraint you can run directly.

What Wave & Batch Sequencing Is

Three distinct decisions hide inside the phrase “release the orders,” and conflating them is the most common source of a congested, uneven pick floor.

  • Batching groups multiple orders into one picker tour so a single walk of an aisle serves several customers. A multi-tote pick cart with 12 positions lets one picker satisfy 12 orders on one pass; the batching algorithm decides which 12 orders share the cart to minimize the combined route.
  • Wave planning partitions the whole order pool into time-boxed release groups — a wave — each sized to the labor and dock capacity available in its window. A wave is the unit of release; a batch is the unit of travel inside it.
  • Zone picking partitions the facility rather than the orders: each picker owns a zone and only ever walks it, and a multi-line order is stitched back together downstream. Zone picking bounds travel by construction but shifts the cost to consolidation.

These compose rather than compete. A mature operation runs waves of zone-bounded batches: the wave sets the release window, the zone assignment bounds each picker’s walk, and batching packs compatible orders onto shared carts within each zone. The distinction that matters operationally is between pick-and-pass (an order flows zone to zone accumulating lines) and pick-and-sort (everything is picked in bulk then sorted to orders downstream); the batching math is identical, only the consolidation cost differs.

The objective is always the same: minimize total picker-hours subject to cutoff times and capacity. Two orders sharing an aisle should share a cart; a wave that overloads the packing station at 2pm should be resized or resequenced; a batch whose route doubles back on itself should be reordered. The rest of this guide is the machinery that computes those decisions.

Order pool to batches to waves to a labor-leveled shift timeline A left-to-right pipeline in four columns. Column one, the order pool, is a stack of six order tickets. An arrow labeled savings-algorithm batching leads to column two, three batch cards, each grouping two or three orders that share aisles onto one cart. An arrow labeled sequence and level leads to column three, three wave cards labeled Wave 1, Wave 2 and Wave 3, each holding one or two batches. A final arrow leads to column four, a horizontal shift timeline from 8am to 4pm divided into three equal-height blocks showing balanced picker-hours per wave under a dashed cutoff line, illustrating that the waves are sequenced so labor stays level and no wave breaches its cutoff. Order pool Proximity batches Sequenced waves Leveled shift order 1 order 2 order 3 order 4 order 5 order 6 savings batching Batch A orders 1,4 · aisle 3 Batch B orders 2,5 · aisle 7 Batch C orders 3,6 · aisle 11 sequence + level Wave 1 Batch A · ~4.0 h Wave 2 Batch B · ~4.1 h Wave 3 Batch C · ~3.9 h W1 picker-hrs W2 picker-hrs W3 picker-hrs cutoff line
The sequencing pipeline: a flat order pool is packed into proximity batches by a savings heuristic, the batches are grouped into time-boxed waves, and the waves are sequenced so picker-hours stay level across the shift and every wave clears its cutoff — the difference between a smooth floor and a 2pm pile-up at packing.

Input Data Requirements

Sequencing consumes two joined streams: the open order pool and a per-location travel model. Every order line must resolve to a committed slot so its aisle and travel cost are known before batching; an unslotted line has no position and silently drops out of the route math. The travel cost per location pair comes from the upstream matrix — treat its presence as a hard precondition, because without it batching degenerates to grouping by order-id and saves nothing.

Field Type Precondition
order_id str Non-null, unique per customer order; the batching atom
line_id str Unique per order line; duplicates double-count travel
location_id str Committed slot resolvable in the travel matrix; no unslotted lines
aisle str Derived from location_id; the coarse proximity key for seed batching
units int > 0; drives pick-time estimate per line
cutoff_ts datetime Carrier/SLA deadline; the hard constraint on wave assignment
cart_capacity int Tote positions per cart; the batch size ceiling
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class OrderLine:
    """One pickable line, resolved to a committed slot — the atomic input to batching."""
    order_id: str
    line_id: str
    location_id: str
    aisle: str
    units: int
    cutoff_ts: datetime


@dataclass(frozen=True)
class Order:
    """An order collapsed to its lines and its hard release deadline."""
    order_id: str
    lines: tuple[OrderLine, ...]
    cutoff_ts: datetime

The quality gate that matters most is cutoff integrity. A single order carrying a stale or default cutoff (a far-future timestamp from a mis-mapped feed) will float freely across every wave and quietly starve the waves that actually needed the labor. Assert that every order’s cutoff falls inside the planning horizon before you batch, and reject the pool otherwise.

Step-by-Step Implementation

The pipeline runs in three passes: estimate the travel and time cost of each order, batch compatible orders to minimize aggregate travel, then group the batches into sequenced waves. Each pass is a pure function so the whole thing is deterministic and testable.

1. Estimate Per-Order Travel & Pick Time

Every downstream decision needs a cost per order. Pick time is the sum of a fixed per-line handling constant and a travel component read from the Travel-Distance & Pick-Path Cost Modeling matrix, which returns the cost of walking between two committed slots. Keeping the estimator honest here is what makes the batching savings real rather than notional.

import logging
from collections.abc import Callable

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

# travel_cost(loc_a, loc_b) -> seconds, sourced from the pick-path cost matrix
TravelFn = Callable[[str, str], float]


def order_pick_seconds(order: Order, travel_cost: TravelFn,
                       depot: str = "DOCK", handle_s: float = 12.0) -> float:
    """Estimate pick seconds for one order: depot -> line route -> depot, plus handling."""
    stops = [depot] + [ln.location_id for ln in order.lines] + [depot]
    travel = sum(travel_cost(a, b) for a, b in zip(stops, stops[1:]))
    handling = handle_s * sum(ln.units for ln in order.lines)
    total = travel + handling
    logger.debug("order %s: travel=%.0fs handling=%.0fs", order.order_id, travel, handling)
    return total

2. Batch Orders by Proximity with the Savings Algorithm

The core move is the Clarke-Wright savings heuristic adapted to picking. For every pair of orders, compute the savings of serving them on one cart instead of two separate depot round-trips: s(i,j) = d(depot,i) + d(depot,j) - d(i,j). Two orders in the same aisle have a large positive saving; two on opposite ends of the facility have almost none. You sort pairs by descending saving and greedily merge them into batches, respecting the cart capacity ceiling. A family-affinity bonus nudges co-picked SKUs into the same batch even when their raw distance saving is marginal, reusing the lift scores from Family & Affinity Grouping.

import logging
from dataclasses import dataclass, field

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


@dataclass
class Batch:
    """A set of orders assigned to one cart tour."""
    batch_id: str
    order_ids: list[str] = field(default_factory=list)
    est_seconds: float = 0.0


def order_centroid(order: Order) -> str:
    """Representative aisle for an order: its most-visited aisle (coarse proximity key)."""
    counts: dict[str, int] = {}
    for ln in order.lines:
        counts[ln.aisle] = counts.get(ln.aisle, 0) + 1
    return max(counts, key=counts.get)


def savings_batch(orders: list[Order], travel_cost: TravelFn,
                  cart_capacity: int = 12,
                  affinity: dict[str, set[str]] | None = None,
                  depot: str = "DOCK") -> list[Batch]:
    """Clarke-Wright savings batching: merge order pairs by descending travel saving."""
    affinity = affinity or {}
    centroid = {o.order_id: order_centroid(o) for o in orders}
    by_id = {o.order_id: o for o in orders}

    # Pairwise savings on aisle centroids; affinity adds a fractional bonus.
    pairs: list[tuple[float, str, str]] = []
    ids = list(by_id)
    for a_idx, oa in enumerate(ids):
        for ob in ids[a_idx + 1:]:
            ca, cb = centroid[oa], centroid[ob]
            saving = (travel_cost(depot, ca) + travel_cost(depot, cb)
                      - travel_cost(ca, cb))
            if cb in affinity.get(ca, set()):
                saving *= 1.15
            pairs.append((saving, oa, ob))
    pairs.sort(reverse=True)

    # Greedy merge under the cart-capacity ceiling.
    assign: dict[str, str] = {}
    batches: dict[str, Batch] = {}
    for saving, oa, ob in pairs:
        if saving <= 0:
            break
        ba, bb = assign.get(oa), assign.get(ob)
        if ba and bb and ba != bb:
            continue  # would merge two full tours; skip for simplicity
        target = ba or bb
        members = {oa, ob} | ({o for o, b in assign.items() if b == target} if target else set())
        if len(members) > cart_capacity:
            continue
        bid = target or f"B{len(batches) + 1:03d}"
        batch = batches.setdefault(bid, Batch(bid))
        for oid in (oa, ob):
            if assign.get(oid) != bid:
                assign[oid] = bid
                batch.order_ids.append(oid)

    # Singletons: any order never merged becomes its own batch.
    for oid in ids:
        if oid not in assign:
            bid = f"B{len(batches) + 1:03d}"
            batches[bid] = Batch(bid, [oid])
            assign[oid] = bid

    for b in batches.values():
        b.est_seconds = sum(order_pick_seconds(by_id[o], travel_cost) for o in b.order_ids)
    logger.info("Batched %d orders into %d carts", len(orders), len(batches))
    return list(batches.values())

3. Sequence Batches Into Labor-Leveled Waves

Batches now carry an estimated duration. Waves are time-boxed windows, and the goal is to assign batches to waves so that each wave’s total picker-hours land near the target and no batch is placed in a wave whose window closes after its cutoff. The greedy longest-processing-time (LPT) rule — sort batches longest-first, drop each into the currently-lightest eligible wave — produces near-optimal balance in one pass. The detailed treatment of the balancing objective and its cutoff constraints is the subject of How to Sequence Pick Waves to Balance Labor; here is the top-level pass.

import logging
from dataclasses import dataclass, field

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


@dataclass
class Wave:
    wave_id: int
    batch_ids: list[str] = field(default_factory=list)
    load_seconds: float = 0.0


def sequence_waves(batches: list[Batch], num_waves: int,
                   pickers_per_wave: int, wave_seconds: float) -> list[Wave]:
    """LPT load-balancing: pack longest batches first into the lightest eligible wave."""
    capacity = pickers_per_wave * wave_seconds
    waves = [Wave(w) for w in range(1, num_waves + 1)]
    for batch in sorted(batches, key=lambda b: b.est_seconds, reverse=True):
        candidates = [w for w in waves if w.load_seconds + batch.est_seconds <= capacity]
        target = min(candidates or waves, key=lambda w: w.load_seconds)
        target.batch_ids.append(batch.batch_id)
        target.load_seconds += batch.est_seconds
    spread = max(w.load_seconds for w in waves) - min(w.load_seconds for w in waves)
    logger.info("Sequenced %d batches into %d waves; load spread=%.0fs",
                len(batches), num_waves, spread)
    return waves

Wired end to end the flow is three calls — savings_batch over the order pool, then sequence_waves over the resulting batches — with the only shared state being the travel-cost function. Because congestion is time-varying, feed the wave sequence through the congestion multiplier before it goes live so two heavy batches never land in the same aisle in the same window; that reconciliation belongs to Congestion Modeling & Zone Routing.

Tuning & Calibration

Every lever lives in one externalized profile. The two that move outcomes most are cart_capacity (larger carts cut travel but raise sort complexity downstream and lengthen each tour) and wave_seconds (the window width, which trades labor smoothness against cutoff flexibility). Recalibrate the affinity bonus only when the co-pick lift map is refreshed, and re-fit handle_s from time-study data whenever the pick methodology changes.

# sequencing.yaml — one profile per facility
batching:
  cart_capacity: 12          # tote positions per cart; batch size ceiling
  affinity_bonus: 0.15       # fractional savings uplift for co-picked orders
  handle_seconds: 12.0       # per-unit handling constant in the time estimate
  depot: "DOCK"              # tour start/end node in the travel matrix
waves:
  num_waves: 4               # release windows per shift
  pickers_per_wave: 18       # labor available in each window
  wave_seconds: 6600         # window width in seconds (~1h50m)
  cutoff_guard_s: 600        # buffer subtracted from each cutoff for pack/ship
# Equivalent Python config dict consumed by the sequencer
SEQUENCING = {
    "batching": {"cart_capacity": 12, "affinity_bonus": 0.15,
                 "handle_seconds": 12.0, "depot": "DOCK"},
    "waves": {"num_waves": 4, "pickers_per_wave": 18,
              "wave_seconds": 6600, "cutoff_guard_s": 600},
}

Parameter sensitivity is not uniform. cart_capacity and wave_seconds are structural levers — change them and you change the shape of every wave. affinity_bonus is a fine-tuning lever; keep it small (0.10–0.20) so proximity, not affinity, still dominates batching. cutoff_guard_s is a safety lever that should track your slowest downstream station, never demand.

Validation & Testing

Never release a wave plan without asserting its invariants. Three properties must hold on every run: every order lands in exactly one batch (no lost or duplicated demand), no batch exceeds cart capacity, and no batch is scheduled into a wave that closes after its cutoff. The following pytest checks encode all three.

import pytest


def test_every_order_batched_once(sample_orders, travel_cost) -> None:
    batches = savings_batch(sample_orders, travel_cost, cart_capacity=12)
    assigned = [oid for b in batches for oid in b.order_ids]
    assert sorted(assigned) == sorted(o.order_id for o in sample_orders)
    assert len(assigned) == len(set(assigned)), "an order was duplicated across batches"


def test_cart_capacity_respected(sample_orders, travel_cost) -> None:
    batches = savings_batch(sample_orders, travel_cost, cart_capacity=6)
    assert all(len(b.order_ids) <= 6 for b in batches)


def test_wave_load_is_balanced(sample_batches) -> None:
    waves = sequence_waves(sample_batches, num_waves=4,
                           pickers_per_wave=18, wave_seconds=6600)
    loads = [w.load_seconds for w in waves]
    spread = max(loads) - min(loads)
    # Balanced LPT should keep spread under a single batch's duration.
    assert spread <= max(b.est_seconds for b in sample_batches)

A healthy run reports a load spread well under one batch-duration and zero capacity violations. If the spread balloons, either too few waves are absorbing an uneven order pool or a handful of oversized batches are dominating — split the largest batches before resequencing.

Integration Points

Wave sequencing sits downstream of slotting and upstream of the floor, so it consumes and produces across several sibling systems:

  • Travel cost. Batching is only as good as its distance estimates. Every saving computed above reads the pairwise cost from Travel-Distance & Pick-Path Cost Modeling; a mis-scaled matrix inverts the savings ranking and batches orders that should never share a cart.
  • Congestion. Two heavy batches routed into the same aisle in the same window create a jam the static plan cannot see. The wave sequence is reconciled against the time-varying aisle multiplier from Congestion Modeling & Zone Routing before release, which may push a batch to a later wave.
  • Affinity. The batching bonus that keeps co-ordered SKUs on one cart is sourced from Family & Affinity Grouping; refreshing the lift map there directly changes which orders batch together here.
  • Space. Wave sizing has to respect staging and cart-park capacity, not just labor. The cube available to stage picked totes is scored in Space & Cube Utilization Scoring, which bounds how many concurrent waves the floor can physically hold.

Failure Modes & Edge Cases

  • Batching across the whole facility. A savings run with no zone bound can merge two orders whose combined route crosses the building, because the pairwise saving looked positive in isolation. Remediation: bound batching within zones or cap the merged-route length, and treat any batch whose route exceeds a diameter threshold as a split candidate.
  • Cutoff starvation from a stale timestamp. One order with a far-future cutoff floats into a late wave and never ships on time. Remediation: assert cutoffs fall inside the horizon before batching and route defaulted timestamps to an exception queue.
  • Over-wide waves that pile up downstream. A window sized only for pick labor can dump more totes on packing than it can absorb, moving the bottleneck rather than removing it. Remediation: size wave_seconds against the slowest downstream station and monitor pack-station queue depth per wave.
  • Cart capacity set above sort capacity. A 24-position cart cuts travel but can overwhelm put-to-order sortation, trading pick savings for consolidation cost. Remediation: tune cart_capacity against measured sort throughput, not just travel savings.
  • Single-line orders defeating the savings math. A pool dominated by one-line orders has little pairwise saving to capture, so batching adds overhead without benefit. Remediation: detect the single-line fraction and switch those orders to a dedicated bulk-pick-and-sort wave instead of cart batching.

FAQ

When should I batch orders versus using zone picking?

Batch when orders are multi-line and spread across the facility, so a shared cart tour amortizes travel across several customers; use zone picking when order density is high and lines-per-order is low, so bounding each picker to one zone caps travel by construction. Most high-volume operations run both: zone-bounded batches inside a wave. The deciding metric is lines-per-order — below roughly two lines, batching’s pairwise savings thin out and zone-and-sort wins; above it, cart batching recovers more travel.

How do I choose the number of waves per shift?

Start from labor windows, not order volume. Divide the shift into windows narrow enough that a delayed wave still clears its cutoff with the cutoff_guard_s buffer, but wide enough that each window holds enough orders for the savings algorithm to find real proximity pairs. Four to six waves per eight-hour shift is the common landing point; fewer risks cutoff breaches, more thins each wave’s batching opportunity.

What is the savings algorithm actually optimizing?

It maximizes the total travel avoided by merging orders onto shared cart tours. For each order pair it computes the distance of two separate depot round-trips minus the distance of one combined tour; that difference is the saving. Sorting pairs by descending saving and greedily merging under the cart-capacity ceiling gives a near-optimal batching in a single deterministic pass, which is why it scales to thousands of orders where an exact vehicle-routing solve would not.

Does wave sequencing replace good slotting?

No — it compounds it. Slotting decides where inventory lives so any single pick is short; sequencing decides how the demand stream consumes that layout so aggregate travel across all orders is minimized. A well-slotted facility with naive one-order-per-trip release still walks every A-mover once per order. Sequencing is what converts a good static layout into low aggregate travel under real, bursty demand.