Slotting Architecture · 15 min read

Location Assignment & ABC Classification Algorithms for Warehouse Slotting

Warehouse slotting fails quietly. There is no crash, no alarm — just a picker walking an extra fifteen feet per line, a golden-zone bin holding a dead SKU, and a re-slotting task queue that grows faster than the labor budget can drain it. When location assignment and ABC classification are decoupled from live operational data, the symptoms compound: inflated travel time, aisle congestion during peak waves, and relocation churn that costs more labor than the travel savings it chases. This guide is the reference architecture for the location-assignment layer of the warehouse-slotting knowledge base — a production-ready, Python-driven approach to velocity classification and constraint-aware placement built for direct integration into a modern WMS. It sits alongside the Core Slotting Architecture & Velocity Taxonomies and Velocity Data Ingestion & WMS Sync Pipelines systems, consuming their velocity feeds and returning committed slot assignments.

The engineering thesis is simple: velocity decides priority, physics decides placement, and hysteresis decides when to move. Every section below grounds that thesis in a specific data structure, threshold, or code path you can adapt directly.

Architecture Overview

The assignment layer is a closed loop, not a batch report. Normalized pick transactions flow in from the ingestion pipeline, a scoring engine converts them to velocity tiers, a constraint solver maps tiers to physically feasible bins, and committed assignments are pushed back to the WMS — whose subsequent pick confirmations become the next cycle’s input. Reading the data-flow left to right makes the failure surfaces obvious: a stale window upstream poisons every tier downstream, and a missing capacity update turns a “feasible” bin into a receiving jam.

Location assignment closed-loop data flow A serpentine pipeline. Row one, left to right: WMS transaction logs, velocity aggregation (compute_velocity_metrics), ABC classification (classify_abc), and the constraint solver (evaluate_bin_feasibility). The flow drops down into row two, running right to left through affinity re-ranking (apply_affinity_filter) and the fallback resolver (resolve_fallback_assignment) to the WMS assignment push. A coral feedback arrow returns pick confirmations from the assignment push, through a hysteresis gate of plus or minus fifteen percent sustained over two cycles, back to the transaction logs. The whole loop is recalculated nightly. Nightly recalculation cycle feasible candidates pick confirmations Hysteresis gate ±15% · 2 cycles WMS transaction logs raw picks · putaways Velocity aggregation compute_velocity_metrics() ABC classification classify_abc() Constraint solver evaluate_bin_feasibility() Affinity re-ranking apply_affinity_filter() Fallback resolver resolve_fallback_assignment() WMS assignment push committed slots
The assignment layer as a closed loop: velocity sets priority, the constraint solver enforces physics, affinity re-ranks feasible bins, and a hysteresis gate throttles re-slotting churn before pick confirmations re-enter the pipeline on the next nightly cycle.

The remainder of this document walks each stage of that loop: the canonical data model, the four algorithmic components (each with its own deep-dive guide), a runnable end-to-end pipeline, the operational parameters that tune it, and the failure modes that break it in production.

Velocity Data Foundation & Schema Design

Slotting algorithms fail when velocity metrics are inconsistent or stale, so the assignment layer defines a canonical record set before any scoring runs. The foundation captures pick frequency, order-line penetration, and physical footprint in typed structures that are cheap to serialize and deterministic to compare. This layer consumes the normalized feeds produced by the Velocity Data Ingestion & WMS Sync Pipelines system and inherits its tiering conventions from SKU Velocity Taxonomy Design; keeping the record definitions here identical to those upstream contracts is what prevents silent schema drift between ingestion and assignment.

from dataclasses import dataclass
from typing import Optional

@dataclass
class SKUProfile:
    sku_id: str
    velocity_picks_90d: int
    velocity_lines_90d: int
    cube_per_unit: float        # cubic feet
    weight_per_unit: float      # lbs
    family_group: str
    hazard_code: Optional[str] = None
    current_location: Optional[str] = None

@dataclass
class BinProfile:
    bin_id: str
    zone: str
    level: int
    max_weight: float           # lbs
    max_cube: float             # cubic feet
    current_weight: float = 0.0
    current_cube: float = 0.0
    is_golden_zone: bool = False
    reserved_for_family: Optional[str] = None

Velocity aggregation runs as a nightly batch job pulling from WMS transaction logs, order management extracts, and inventory snapshots. Vectorized grouping — not row-by-row iteration — keeps runtime linear in SKU count; a facility with 250,000 active SKUs should aggregate in seconds, not minutes.

import logging
import pandas as pd

logger = logging.getLogger("slotting.velocity")

def compute_velocity_metrics(transactions_df: pd.DataFrame) -> pd.DataFrame:
    """Aggregate 90-day pick and line velocity from raw WMS logs."""
    metrics = (
        transactions_df.groupby("sku_id")
        .agg(
            picks_90d=("transaction_type", lambda x: (x == "PICK").sum()),
            lines_90d=("order_id", "nunique"),
            cube_demand=("units_picked", "sum"),
        )
        .reset_index()
    )
    logger.info("Aggregated velocity for %d SKUs", len(metrics))
    return metrics

ABC Classification Engine

ABC classification for slotting must reflect operational impact, not accounting revenue. The tier of a SKU is a function of how often pickers touch it and how many distinct orders it appears in — a cheap, high-frequency consumable outranks an expensive item that ships twice a quarter. The engine computes a weighted velocity score, sorts descending, and cuts tiers at cumulative contribution thresholds. Getting those cutoffs right is a discipline of its own: calibrate them against seasonal demand curves and you avoid tier thrashing, the churn detailed in ABC Classification Tuning, where thresholds are fitted to the shape of your Pareto curve rather than borrowed from a textbook 80/15/5 default.

The weighting below lets pick frequency dominate while line frequency corrects for high-quantity/low-order-count bias — a bulk SKU picked in large units on rare orders should not masquerade as an A-mover.

import logging
import numpy as np
import pandas as pd

logger = logging.getLogger("slotting.abc")

def classify_abc(velocity_df: pd.DataFrame,
                 a_cutoff: float = 0.70,
                 b_cutoff: float = 0.90) -> pd.DataFrame:
    """Assign A/B/C tiers by cumulative weighted-velocity contribution."""
    df = velocity_df.copy()
    df["velocity_score"] = df["picks_90d"] * 0.6 + df["lines_90d"] * 0.4
    df = df.sort_values("velocity_score", ascending=False)

    total_score = df["velocity_score"].sum()
    df["cumulative_pct"] = (
        df["velocity_score"].cumsum() / total_score if total_score > 0 else 0.0
    )

    conditions = [
        df["cumulative_pct"] <= a_cutoff,
        (df["cumulative_pct"] > a_cutoff) & (df["cumulative_pct"] <= b_cutoff),
    ]
    df["abc_tier"] = np.select(conditions, ["A", "B"], default="C")
    logger.info("Classified %d SKUs into ABC tiers", len(df))
    return df

Tiers are advisory inputs to placement, never direct commands. An A-tier assignment still has to survive the constraint solver before a bin is reserved.

Constraint-Aware Location Assignment

Velocity dictates priority, but physics dictates placement. Routing a high-velocity SKU into a structurally inadequate bin creates safety violations and stuck picks — a hyper-mover assigned to a top-shelf pallet position generates a ladder event on every line. The assignment engine evaluates remaining weight and cube capacity against the SKU footprint before it commits, and it applies a safety buffer so that operational variance never pushes a bin over its rated limit. The full treatment of load ratings, crush limits, and level-height rules lives in Weight & Volume Constraint Modeling; the feasibility gate below is the runtime enforcement of those rules.

import logging

logger = logging.getLogger("slotting.constraints")

def evaluate_bin_feasibility(sku: SKUProfile, bin: BinProfile,
                             buffer: float = 0.15) -> bool:
    """Return True only if a SKU can physically and safely occupy a bin."""
    usable = 1.0 - buffer
    weight_remaining = bin.max_weight - bin.current_weight
    cube_remaining = bin.max_cube - bin.current_cube

    if sku.weight_per_unit > weight_remaining * usable:
        logger.debug("Reject %s -> %s: weight over buffer", sku.sku_id, bin.bin_id)
        return False
    if sku.cube_per_unit > cube_remaining * usable:
        logger.debug("Reject %s -> %s: cube over buffer", sku.sku_id, bin.bin_id)
        return False

    # Hazard segregation: hazardous SKUs may only enter bins reserved for their class
    if sku.hazard_code and bin.reserved_for_family != sku.hazard_code:
        logger.debug("Reject %s -> %s: hazard segregation", sku.sku_id, bin.bin_id)
        return False
    return True

Capacity is never static. Cycle counts, partial picks, and putaways continuously alter free space, so the feasibility check must read event-driven bin state rather than a nightly snapshot. Wiring capacity updates to WMS pick confirmations and putaway transactions — the same delta-event stream configured in the WMS & ERP Polling Strategies layer — is what stops the solver from routing items into theoretically open but practically full locations.

Affinity & Co-Location Logic

Pure velocity optimization ignores how orders are actually composed. Two SKUs that ship together on 40% of orders generate compound travel cost every time they sit in distant zones, even when each is perfectly slotted in isolation. A secondary scoring layer re-ranks feasible bins to favor zones that already hold a SKU’s frequently co-picked partners. The association-rule mining that produces those partner sets — Apriori or FP-Growth over historical order baskets — and the translation of lift scores into zone-reservation flags is covered in Family & Affinity Grouping. Here, affinity is applied as a stable re-sort that never overrides feasibility.

import logging

logger = logging.getLogger("slotting.affinity")

def apply_affinity_filter(candidate_bins: list[BinProfile], sku: SKUProfile,
                          affinity_matrix: dict[str, set[str]]) -> list[BinProfile]:
    """Stable-rank feasible bins to prefer zones holding co-picked families."""
    preferred = affinity_matrix.get(sku.family_group)
    if not preferred:
        return candidate_bins

    ranked = sorted(candidate_bins, key=lambda b: 0 if b.zone in preferred else 1)
    logger.info("Affinity re-ranked %d bins for family %s",
                len(ranked), sku.family_group)
    return ranked

Because affinity only reorders an already-feasible candidate list, a co-location preference can never smuggle a SKU into an unsafe bin — the worst case is a slightly longer travel path, not a constraint breach.

Re-slotting Triggers & Hysteresis

Over-optimization is its own failure mode. Relocating an A-item every time its velocity wobbles by 2% burns labor that dwarfs the theoretical travel saving. The fix is hysteresis: a SKU must cross a sustained velocity delta — for example ±15% over two consecutive evaluation cycles — before a relocation task is emitted. Setting those bands, and modeling the break-even between move cost and travel savings, is the subject of Threshold Optimization for Re-slotting.

When ideal locations are exhausted, the engine must degrade gracefully rather than block inbound receiving. A deterministic fallback chain defines the priority queue: golden-zone overflow (same tier, adjacent level) first, then a secondary zone at an accepted travel penalty, then bulk reserve with an auto-generated replenishment task.

import logging
from typing import Optional

logger = logging.getLogger("slotting.fallback")

def resolve_fallback_assignment(sku: SKUProfile,
                                available_bins: list[BinProfile]) -> Optional[str]:
    """Cascade through the fallback chain when the primary slot is unavailable."""
    # Prefer lower levels (ergonomic, load-safe), then most remaining cube
    ordered = sorted(
        available_bins,
        key=lambda b: (b.level, -(b.max_cube - b.current_cube)),
    )
    for bin in ordered:
        if evaluate_bin_feasibility(sku, bin):
            logger.info("Fallback slotted %s -> %s", sku.sku_id, bin.bin_id)
            return bin.bin_id

    logger.warning("No feasible bin for %s; routing to bulk reserve", sku.sku_id)
    return None

Production Implementation: End-to-End Assignment Pipeline

The components above compose into a single deterministic pass: score, classify, then for each SKU in priority order find the best feasible, affinity-ranked bin, falling back gracefully and never throwing on an unassignable SKU. The orchestrator below ties them together with structured logging and defensive error handling so a single bad record cannot abort a full nightly run.

import logging
from dataclasses import dataclass
from typing import Optional

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("slotting.pipeline")

@dataclass
class Assignment:
    sku_id: str
    bin_id: Optional[str]
    abc_tier: str
    status: str  # ASSIGNED | FALLBACK | UNASSIGNED

def select_bin(sku: SKUProfile, tier: str, bins: list[BinProfile],
               affinity_matrix: dict[str, set[str]]) -> Optional[str]:
    """Constraint-filter, affinity-rank, and pick a bin for one SKU."""
    target_golden = tier == "A"
    feasible = [
        b for b in bins
        if b.is_golden_zone == target_golden and evaluate_bin_feasibility(sku, b)
    ]
    if not feasible:
        return None
    ranked = apply_affinity_filter(feasible, sku, affinity_matrix)
    return ranked[0].bin_id

def run_assignment_pipeline(profiles: list[SKUProfile],
                            velocity_df,
                            bins: list[BinProfile],
                            affinity_matrix: dict[str, set[str]]) -> list[Assignment]:
    """Full pass: classify SKUs, then assign each in descending velocity order."""
    classified = classify_abc(velocity_df)
    tier_by_sku = dict(zip(classified["sku_id"], classified["abc_tier"]))
    order = classified.sort_values("velocity_score", ascending=False)["sku_id"]
    profile_by_sku = {p.sku_id: p for p in profiles}

    bin_index = {b.bin_id: b for b in bins}
    results: list[Assignment] = []

    for sku_id in order:
        sku = profile_by_sku.get(sku_id)
        if sku is None:
            logger.error("No profile for scored SKU %s; skipping", sku_id)
            continue
        tier = tier_by_sku.get(sku_id, "C")
        try:
            bin_id = select_bin(sku, tier, list(bin_index.values()), affinity_matrix)
            if bin_id is None:
                bin_id = resolve_fallback_assignment(sku, list(bin_index.values()))
                status = "FALLBACK" if bin_id else "UNASSIGNED"
            else:
                status = "ASSIGNED"
        except Exception:  # never let one SKU abort the batch
            logger.exception("Assignment failed for %s", sku_id)
            bin_id, status = None, "UNASSIGNED"

        if bin_id:  # reserve capacity so later SKUs see the update
            chosen = bin_index[bin_id]
            chosen.current_weight += sku.weight_per_unit
            chosen.current_cube += sku.cube_per_unit
        results.append(Assignment(sku_id, bin_id, tier, status))

    assigned = sum(1 for r in results if r.status == "ASSIGNED")
    logger.info("Pipeline complete: %d/%d directly assigned", assigned, len(results))
    return results

In production this pass runs in shadow mode first — computing assignments against a historical extract without pushing them — then advisory mode, then automated push guarded by a circuit breaker that halts if the relocation count for a cycle exceeds the stability budget defined below.

Operational Parameters

Every tunable lives in one config so a facility can be re-tuned without editing code. Ship it as YAML for planners and load it into the identical Python dict the engine consumes.

# slotting_params.yaml
velocity:
  window_days: 90              # rolling velocity window
  recalc_cadence: "nightly"    # nightly | weekly
  pick_weight: 0.6             # weight of pick frequency in velocity_score
  line_weight: 0.4             # weight of order-line frequency
abc:
  a_cutoff: 0.70               # cumulative contribution boundary for tier A
  b_cutoff: 0.90               # boundary for tier B (remainder is C)
constraints:
  safety_buffer: 0.15          # reserved fraction of bin weight/cube capacity
reslotting:
  velocity_delta: 0.15         # +/- change required to consider a move
  sustain_cycles: 2            # consecutive cycles the delta must persist
  max_relocations_pct: 0.08    # circuit-breaker: max SKUs moved per cycle
SLOTTING_PARAMS = {
    "velocity": {"window_days": 90, "recalc_cadence": "nightly",
                 "pick_weight": 0.6, "line_weight": 0.4},
    "abc": {"a_cutoff": 0.70, "b_cutoff": 0.90},
    "constraints": {"safety_buffer": 0.15},
    "reslotting": {"velocity_delta": 0.15, "sustain_cycles": 2,
                   "max_relocations_pct": 0.08},
}

The two parameters that most change behavior are a_cutoff (widen it and more SKUs claim golden-zone slots, tightening capacity) and velocity_delta (lower it and re-slot churn rises). Treat both as facility-specific and revisit them each time the order profile shifts seasonally.

Failure Modes & Remediation

  • Stale velocity windows. A frozen or misaligned 90-day window mis-tiers SKUs and drags placement toward last quarter’s demand. Remediate by asserting the max event date is within the recalc SLA before scoring, and alerting when the freshest transaction is older than 24 hours.
  • Constraint state lag. If bin capacity is read from a nightly snapshot instead of live delta events, the solver assigns into bins that filled hours ago, producing receiving jams. Drive current_weight/current_cube from WMS pick and putaway confirmations, not batch exports.
  • Tier thrashing. Aggressive cutoffs or a missing hysteresis band relocate the same SKUs every cycle, burning labor. Enforce velocity_delta and sustain_cycles, and trip the max_relocations_pct circuit breaker.
  • Golden-zone starvation. An over-wide a_cutoff promotes too many SKUs and exhausts ergonomic faces, forcing A-movers into fallback. Monitor golden-zone fill rate and keep it below ~90% to preserve surge headroom.
  • Hazard/affinity contradiction. An affinity rule can prefer a zone that hazard segregation forbids. Because affinity only re-ranks an already-feasible list, this is contained — but log the conflict so grouping rules can be corrected upstream.
  • Silent per-SKU failures. One malformed profile aborting the batch takes the whole facility offline. The pipeline catches per-SKU exceptions and marks the record UNASSIGNED rather than raising.

Deployment Checklist

  1. Freeze the canonical SKUProfile / BinProfile schemas against the ingestion contract and add a startup assertion that field names and types match the upstream feed.
  2. Load slotting_params.yaml, validate ranges (0 < a_cutoff < b_cutoff < 1, buffers in [0, 0.5]), and fail closed on out-of-range values.
  3. Run the pipeline in shadow mode over a 30-day historical extract; reconcile constraint rejections against known exceptions.
  4. Promote to advisory mode: surface assignments to planners for approval, measuring override rate as a trust signal.
  5. Enable automated push behind the max_relocations_pct circuit breaker and a per-cycle relocation-cost cap.
  6. Wire live bin-capacity updates to WMS pick/putaway events; verify the feasibility gate reads current state, not the snapshot.
  7. Stand up dashboards for the KPIs below and set alerts at 3σ deviations.

Success is measured against operational baselines, not algorithmic accuracy: target an 18–25% reduction in average picker route length within 60 days, a 12–15% lines-per-hour uplift from A-tier consolidation, a slotting-stability index (SKUs relocated per cycle) held below 8%, and 85–90% golden-zone fill with a 10% surge buffer preserved.