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.
Prerequisites
Confirm each before wiring this into a release job:
- Python 3.10+ — the implementation uses
list[...]generics,dataclass, andX | Noneunions. - Batches with duration estimates — the output of the savings-batching step in Wave & Batch Pick Sequencing, each carrying an
est_secondsand 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 estimates —
est_secondsmust 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
- Build the waves from the labor model. Each wave’s
capacity_sispickers_per_wave × wave_secondsand itsclose_tsis computed fromfirst_wave_start. Capacity is expressed in picker-seconds so a batch’sest_secondscompares directly. - 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. - Filter by cutoff before capacity.
eligiblekeeps only waves whose close time plus thecutoff_guard_sbuffer 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. - 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. - Handle overflow explicitly. When no eligible wave has clean room,
overflow_policydecides:spillplaces the batch in the lightest eligible wave and logs a warning (bounded bymax_overload_ratio), whiledropraises 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_syou 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_wavemisstates 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
spilllog 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.
Related
- Wave & Batch Pick Sequencing — the parent guide covering savings-algorithm batching that produces the batches this balancer schedules.
- Congestion Modeling & Zone Routing — reconcile a leveled wave plan against time-varying aisle load before release.
- Travel-Distance & Pick-Path Cost Modeling — the cost model behind each batch’s duration estimate.
- Pick-Path Optimization & Space Utilization — the parent architecture this labor-leveling step feeds.