Slotting Architecture · 9 min read

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 of 1.0 means independent co-occurrence.
  • A forward-zone bin map with a pick_sequence integer 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
    }
}
Before and after: splitting a complementary pair across the route versus seating it in adjacent bins A header states the pair (A, B) has lift 3.4, which clears the min_lift floor of 1.6, so it qualifies for placement. On the left, "before" panel, six forward-zone bins are laid out in pick_sequence order 0 to 5; SKU A occupies bin sequence 1 and SKU B occupies bin sequence 4, three steps apart, joined by a long curved walk arrow. On the right, "after" panel, the same pair is seated in bins sequence 0 and 1, one step apart, joined by a short reach-and-step arrow; a bracket over bins 0 through 2 marks the max_walk_bins = 2 adjacency window that both bins fall inside. pair (A, B) lift 3.4  ≥  min_lift 1.6  → qualifies Before · pair split across the route seq 0seq 1seq 2 seq 3seq 4seq 5 A B long walk gap 3 > max_walk_bins traversal order (pick_sequence) → After · seated one step apart max_walk_bins = 2 window seq 0seq 1seq 2 seq 3seq 4seq 5 A B reach + step walk_gap 1 ≤ 2 traversal order (pick_sequence) →

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

  1. Qualify and rank. Pairs below min_lift are 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.
  2. 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.
  3. Order bins by travel. bins_by_seq sorts open bins by pick_sequence, so “next bin” always means the next stop on the route, never the next rack label.
  4. 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.
  5. Find the neighbour. The function scans for the nearest open bin whose pick_sequence gap from the anchor is within max_walk_bins. If none qualifies, the pair is counted as unplaced and left for the next wave rather than split across a walk.
  6. 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 F0 and F1 may be physically back-to-back across an aisle, adding a full cross-aisle walk. Always derive pick_sequence from 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_bins window. Watch the unplaced count in the log and widen the forward-zone bin pool before loosening min_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.