How to Group Complementary Products into Adjacent Pick Slots
You already know which items travel together — the affinity engine emitted a ranked list of high-lift pairs — and now you need those pairs physically seated in neighbouring bins so a picker reaches one and steps to the next instead of walking half an aisle between them. This page solves that last, concrete step: given a scored list of complementary SKU pairs and a set of open forward-zone bins, assign each pair to the closest pair of adjacent slots. It is the placement playbook for the Family & Affinity Grouping layer, which produced the pairs; here we turn those scores into slot commitments. The scoring itself (co-occurrence, lift, community detection) is upstream — this page assumes it is done and focuses purely on greedy adjacent placement.
Prerequisites
- A ranked pair list from the affinity stage:
(sku_a, sku_b, lift)tuples, lift already normalised so a value of1.0means independent co-occurrence. - A forward-zone bin map with a
pick_sequenceinteger per bin — the bin’s position along the traversal order, not its rack label. If you have not built one, generate it from the Pick-Path Modeling Frameworks sequence so adjacency means travel adjacency, not label adjacency. - Zone eligibility already resolved by ABC Classification Tuning: only Class-A/B movers should reach the forward-zone bin pool this function consumes.
- Physical feasibility pre-cleared through Weight & Volume Constraint Modeling — this function commits slots and does not re-check crush, fill, or level rules.
- Python 3.10+, standard library only (
dataclasses,logging,itertools). No pandas or numpy is required for the placement step itself.
Configuration
Externalise every threshold so a re-tune never touches code. min_lift is the qualification floor; a pair below it is not worth the forward-zone real estate. max_walk_bins defines how many pick_sequence steps still count as “adjacent” — set it to the number of bins a picker can service without a walk. max_pairs caps how many placements one wave commits, bounding churn in the golden zone.
complementary_slotting:
min_lift: 1.6 # pair must clear this lift to earn adjacent slots
max_walk_bins: 2 # pick_sequence gap still counted as "adjacent"
max_pairs: 500 # ceiling on placements committed per wave
CONFIG = {
"complementary_slotting": {
"min_lift": 1.6, # pair must clear this lift to earn adjacent slots
"max_walk_bins": 2, # pick_sequence gap still counted as "adjacent"
"max_pairs": 500, # ceiling on placements committed per wave
}
}
Implementation
The function greedily walks the qualified pairs from strongest lift down, and for each one finds the earliest open bin as an anchor, then the nearest open bin within max_walk_bins of it. Strongest pairs are placed first, so they land closest to the pack-out end of the traversal.
from __future__ import annotations
import logging
from dataclasses import dataclass
logger = logging.getLogger("slotting.complementary")
@dataclass(frozen=True)
class ComplementaryPair:
"""A scored complementary pair emitted by the affinity engine."""
sku_a: str
sku_b: str
lift: float
@dataclass
class ForwardBin:
"""One open forward-zone bin, keyed by its position along the pick path."""
bin_id: str
pick_sequence: int # traversal order, not the rack label
open: bool = True
def slot_complementary_pairs(
pairs: list[ComplementaryPair],
forward_bins: list[ForwardBin],
cfg: dict[str, float],
) -> list[dict[str, object]]:
"""Place high-lift complementary pairs into adjacent forward-zone bins.
Assigns the strongest pairs first to the closest available pair of bins
whose ``pick_sequence`` gap is within ``max_walk_bins``. Returns one
placement record per successfully slotted pair.
"""
min_lift = cfg["min_lift"]
max_walk = cfg["max_walk_bins"]
qualified = sorted(
(p for p in pairs if p.lift >= min_lift),
key=lambda p: p.lift, reverse=True,
)[: int(cfg["max_pairs"])]
bins_by_seq = sorted(forward_bins, key=lambda b: b.pick_sequence)
placements: list[dict[str, object]] = []
unplaced = 0
for pair in qualified:
anchor = next((b for b in bins_by_seq if b.open), None)
if anchor is None:
logger.warning("forward zone exhausted before %s/%s", pair.sku_a, pair.sku_b)
break
neighbour = next(
(b for b in bins_by_seq
if b.open and b is not anchor
and abs(b.pick_sequence - anchor.pick_sequence) <= max_walk),
None,
)
if neighbour is None:
unplaced += 1
continue # no adjacent slot near the anchor — leave pair for next wave
anchor.open = neighbour.open = False
placements.append({
"sku_a": pair.sku_a, "bin_a": anchor.bin_id,
"sku_b": pair.sku_b, "bin_b": neighbour.bin_id,
"walk_gap": abs(anchor.pick_sequence - neighbour.pick_sequence),
"lift": round(pair.lift, 3),
})
logger.info("placed %d pairs, %d left unadjacent", len(placements), unplaced)
return placements
Step-by-Step Walkthrough
- Qualify and rank. Pairs below
min_liftare dropped — they co-occur too close to chance to justify forward-zone slots — and the survivors are sorted by descending lift so the tightest relationships are placed first. - Cap the wave. The ranked list is truncated to
max_pairs, keeping any single re-slot from thrashing the golden zone; the rest roll to the next cycle. - Order bins by travel.
bins_by_seqsorts open bins bypick_sequence, so “next bin” always means the next stop on the route, never the next rack label. - Anchor the pair. For each pair, the earliest still-open bin becomes the anchor. Because pairs are lift-ordered, the strongest pairs anchor nearest the front of the traversal — closest to pack-out.
- Find the neighbour. The function scans for the nearest open bin whose
pick_sequencegap from the anchor is withinmax_walk_bins. If none qualifies, the pair is counted as unplaced and left for the next wave rather than split across a walk. - Commit and record. Both bins are marked closed and a placement record captures the two bin assignments, the realised
walk_gap, and the lift — a compact audit trail for the WMS push.
Verification
Assert the property that matters: every committed pair sits within the adjacency window, and no bin is double-booked. The fixture below places two strong pairs and confirms the walk gaps stay inside max_walk_bins.
def test_pairs_land_adjacent():
cfg = {"min_lift": 1.6, "max_walk_bins": 2, "max_pairs": 500}
pairs = [
ComplementaryPair("A1", "A2", 3.4),
ComplementaryPair("B1", "B2", 2.1),
ComplementaryPair("C1", "C2", 1.2), # below floor, must be skipped
]
bins = [ForwardBin(f"F{i}", pick_sequence=i) for i in range(6)]
result = slot_complementary_pairs(pairs, bins, cfg)
assert len(result) == 2, "only pairs clearing min_lift are placed"
assert all(r["walk_gap"] <= cfg["max_walk_bins"] for r in result)
used = [r["bin_a"] for r in result] + [r["bin_b"] for r in result]
assert len(used) == len(set(used)), "no bin is assigned twice"
print(result[0])
Expected output for the first placement (strongest pair anchored nearest the front of the route):
{'sku_a': 'A1', 'bin_a': 'F0', 'sku_b': 'A2', 'bin_b': 'F1', 'walk_gap': 1, 'lift': 3.4}
Common Pitfalls
- Adjacency by label, not by travel. Bins
F0andF1may be physically back-to-back across an aisle, adding a full cross-aisle walk. Always derivepick_sequencefrom the routed traversal order, not from the rack-numbering scheme. - Greedy front-loading starves later pairs. Because the strongest pairs claim the earliest bins, a long ranked list can exhaust the front of the zone and push mid-lift pairs past the
max_walk_binswindow. Watch theunplacedcount in the log and widen the forward-zone bin pool before looseningmin_lift. - Re-running placement every wave churns the floor. This function commits slots deterministically, but feeding it a freshly recomputed pair list each cycle relocates the same SKUs back and forth. Gate the moves through Threshold Optimization for Re-slotting so a pair only physically moves when the travel saving clears the move cost.
- Splitting a pair silently defeats the point. When no neighbour is found the function deliberately leaves the pair unplaced rather than dropping one member into a distant bin — never “patch” that by relaxing the neighbour check to fall back to any open bin, or you reintroduce the walk you were trying to remove.
Related
- Family & Affinity Grouping — the parent guide that scores co-occurrence and emits the pair list this page places.
- Weight & Volume Constraint Modeling — the feasibility gate that must clear a bin before this function commits it.
- Threshold Optimization for Re-slotting — the move-cost gate that stops per-wave placement from churning the floor.
- Calculating Optimal ABC Thresholds for Seasonal SKUs — how the velocity tiers feeding the forward-zone pool are set.