Slotting Architecture · 19 min read

Designing an SKU Velocity Taxonomy for Slotting Engines

A velocity taxonomy is the deterministic scoring layer that turns raw pick history into the A/B/C/D tier labels a slotting engine assigns real storage against. This guide is part of the Core Slotting Architecture & Velocity Taxonomies architecture, and it owns the stage between the ingested transaction feed and the physical assignment math: how to normalize throughput, blend it with volume and handling cost into a composite score, decay it over time, and cut it into stable tiers that downstream systems can trust. Get this layer wrong and every symptom shows up in the aisles — fast movers stranded in reserve, dead stock squatting in golden-zone faces, and a re-slot queue that never converges.

What a Velocity Taxonomy Actually Is

A velocity taxonomy is a repeatable function that maps each SKU to exactly one velocity tier per scoring cycle, where the tier encodes both how often the item is picked and how expensive it is to handle in a given position. It is not a raw pick-count sort. Two SKUs with identical pick frequency belong in different tiers when one is a single-each cosmetic and the other is a 40-kg pallet-only item, because their travel and replenishment economics differ. The taxonomy therefore has three jobs: (1) reduce noisy, spiky demand to a stable signal, (2) fold in the handling and cube penalties that make a pick cheap or expensive, and (3) discretize the continuous score into a small number of bands the assignment layer can reason about.

Industry practice uses several variants. The classic three-band A/B/C split (roughly 80/15/5 of pick volume) is the minimum; most high-throughput facilities add a D / dead band so stagnant inventory can be swept out of prime slots without contaminating the C threshold, and some add a fifth HYPER band for the handful of SKUs that justify a dedicated forward-pick face or pick-to-light lane. The number of bands is a facility decision, not a universal constant — it should equal the number of physically distinct storage zones the Location Hierarchy Mapping exposes, because a tier that maps to no distinct zone carries no directive. Edge cases the definition must handle explicitly: brand-new SKUs with no history (cold start), promotional SKUs whose spike is not durable demand, and discontinued SKUs whose trailing picks must not keep them warm.

The SKU velocity taxonomy scoring pipeline A left-to-right pipeline of five stages. Normalized pick events become a raw daily velocity of picks divided by active days, are smoothed by an exponential half-life decay, are blended into a composite score of velocity times volume factor times handling penalty, and are cut into A, B, C and D tiers on score percentiles. A zoomed panel below shows the tier cut as a horizontal score axis: low scores on the left fall in D, then C, B and A toward high scores on the right, with a shaded hysteresis margin straddling each boundary where a SKU holds its prior tier instead of switching. The A band emits a published tier label to the slot assignment engine. 1 Pick events normalized feed clears schema check 2 Raw velocity picks ÷ active days IQR-capped / day 3 Time decay EWM · half-life recent > stale 4 Composite velocity × volume × handling penalty 5 Tier cut score percentiles → A / B / C / D zoom: how the cut resists boundary thrash Tier cut with hysteresis a SKU must clear a boundary by the margin to switch tier — inside the band it holds its prior tier D C B A ± hysteresis margin low score high score tier label → slot assignment
The scoring pipeline: normalized pick events become a raw daily velocity, are decayed by half-life, blended with volume and handling into one composite score, then cut into A/B/C/D tiers on percentiles. The zoomed panel shows why the cut is stable — a hysteresis margin straddles every boundary, so a SKU on the line holds its prior tier rather than thrashing each cycle before the label is published to the assignment engine.

Input Data Requirements

The taxonomy consumes one normalized pick-history frame per SKU. The feed itself is produced upstream — polling cadence and delta emission are owned by the WMS/ERP Polling Strategy, and every field below must clear Schema Validation for Inventory Feeds before it reaches the scoring engine. Feeding unvalidated rows into the scorer is the single most common cause of silent tier corruption: one renamed column zeroes a scoring input and the whole assortment drifts.

Field Type Precondition
sku_id str Stable, immutable; one row per SKU per scoring run
picks_window int Count of genuine outbound picks in the window; internal transfers, cycle counts, and QA holds stripped
days_in_stock int Active availability days in window; clipped to ≥ 1 to avoid divide-by-zero
avg_cube float Unit cube in m³, > 0; the volume penalty term
pick_type int Handling class enum: 1=case, 2=each, 3=pallet
last_pick_ts datetime UTC; drives the dormancy / dead-stock rule
is_active bool Discontinued SKUs flagged so trailing picks do not keep them warm

Two quality preconditions dominate everything downstream. First, the observation window must be consistent across all SKUs in a run — mixing a 30-day and a 90-day denominator makes scores incomparable and tiers meaningless. Second, promotional and return spikes must be capped before aggregation (an IQR or percentile clamp), or a single Black-Friday day will promote a SKU that is otherwise a C-mover for the next six weeks.

Step-by-Step Implementation

The pipeline runs in four stages. Each stage is a pure function over a pandas frame so a failed run can be replayed against the same window without side effects, matching the idempotent contract the rest of the architecture assumes.

1. Normalize raw throughput to a comparable daily velocity

Divide picks by active days in stock so a SKU that was only sellable for 10 days of a 90-day window is not scored as a slow mover. Clip the denominator to avoid divide-by-zero, and cap outliers so promo days do not dominate.

from __future__ import annotations
import logging
import numpy as np
import pandas as pd

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

def normalize_velocity(df: pd.DataFrame, iqr_multiplier: float = 1.5) -> pd.DataFrame:
    """Compute outlier-capped daily pick velocity per SKU."""
    out = df.copy()
    out["velocity_raw"] = out["picks_window"] / out["days_in_stock"].clip(lower=1)

    q1, q3 = out["velocity_raw"].quantile([0.25, 0.75])
    upper = q3 + iqr_multiplier * (q3 - q1)
    capped = (out["velocity_raw"] > upper).sum()
    out["velocity_raw"] = out["velocity_raw"].clip(upper=upper)
    logger.info("Normalized %d SKUs; capped %d outliers at %.3f/day", len(out), capped, upper)
    return out

2. Apply exponential time decay

Recent picks should weigh more than old ones so a cooling SKU demotes smoothly rather than clinging to a prime slot until the window rolls off. Convert a business-legible half-life into the per-period smoothing weight alpha, then apply an exponentially weighted mean. A 45-day half-life demotes a stagnant SKU by roughly one tier within six weeks of inactivity — aligned to a monthly replenishment review — while a half-life under 14 days causes churn and over 90 days traps dead stock.

def apply_decay(df: pd.DataFrame, half_life_days: float = 45.0) -> pd.DataFrame:
    """Weight recent velocity above stale velocity via an EWM keyed on half-life."""
    out = df.copy()
    alpha = 1.0 - np.exp(-np.log(2) / half_life_days)
    out["velocity_decayed"] = (
        out["velocity_raw"].ewm(alpha=alpha, adjust=False).mean()
    )
    logger.info("Applied decay: half_life=%.0fd -> alpha=%.4f", half_life_days, alpha)
    return out

3. Blend the composite score

Fold handling economics into the decayed velocity. volume_factor normalizes cube against the 95th percentile so bulky items carry a proportionate penalty without a single monster SKU flattening the scale; handling_penalty rewards dense case picks and discounts pallet-only moves that rarely belong in a forward face. The exact multipliers are facility-specific and should be time-studied, but the structure is portable.

def composite_score(df: pd.DataFrame) -> pd.DataFrame:
    """velocity_decayed x volume_factor x handling_penalty -> single comparable score."""
    out = df.copy()
    cube_ref = out["avg_cube"].quantile(0.95)
    out["volume_factor"] = out["avg_cube"] / max(cube_ref, 1e-9)
    out["handling_penalty"] = out["pick_type"].map({1: 1.0, 2: 1.3, 3: 0.8}).fillna(1.0)
    out["composite"] = (
        out["velocity_decayed"] * out["volume_factor"] * out["handling_penalty"]
    )
    logger.info("Composite scored %d SKUs (cube_ref=%.4f)", len(out), cube_ref)
    return out

4. Cut tiers with hysteresis

Percentile thresholds turn the continuous score into bands. The trap is boundary thrashing: a SKU sitting on the A/B line oscillates every cycle, generating relocation cost with no net travel gain. Guard each boundary with a hysteresis margin — a SKU must clear the threshold by a margin to promote, and fall below it by the same margin to demote — carrying its previous tier when it sits inside the dead zone.

def assign_tiers(
    df: pd.DataFrame,
    thresholds: tuple[float, float, float] = (0.85, 0.60, 0.25),
    margin: float = 0.05,
    prev_tier: pd.Series | None = None,
) -> pd.DataFrame:
    """Cut A/B/C/D tiers on score percentiles with a hysteresis band."""
    out = df.copy()
    valid = out["composite"].dropna()
    a_cut, b_cut, c_cut = valid.quantile(thresholds)
    span = max(valid.max() - valid.min(), 1e-9)
    m = margin * span

    def band(score: float) -> str:
        if score >= a_cut + m: return "A"
        if score >= b_cut + m: return "B"
        if score >= c_cut + m: return "C"
        return "D"

    out["velocity_tier"] = out["composite"].fillna(0.0).map(band)

    if prev_tier is not None:
        # Keep prior tier when the new score is inside the hysteresis dead-zone.
        order = {"A": 3, "B": 2, "C": 1, "D": 0}
        near = out["composite"].between(c_cut - m, a_cut + m)
        held = near & prev_tier.reindex(out.index).notna()
        out.loc[held, "velocity_tier"] = prev_tier.reindex(out.index)[held]
        logger.info("Hysteresis held %d SKUs at prior tier", int(held.sum()))

    dist = out["velocity_tier"].value_counts().to_dict()
    logger.info("Tier distribution: %s", dist)
    return out[["sku_id", "velocity_raw", "velocity_decayed", "composite", "velocity_tier"]]

Chaining the four stages — assign_tiers(composite_score(apply_decay(normalize_velocity(df)))) — yields a serializable frame ready for the staging table and message-queue push into the assignment engine.

Tuning & Calibration

Every parameter here trades reaction speed against stability, and the correct value is a function of your assortment volatility and your move-crew capacity, not a universal default. Externalize the whole set so recalibration never requires a redeploy. The YAML and its Python-dict equivalent below are the same contract in two forms.

velocity_taxonomy:
  observation_window_days: 90     # denominator; must be uniform across the run
  half_life_days: 45              # decay: <14 churns, >90 traps dead stock
  iqr_multiplier: 1.5             # outlier cap before aggregation
  tier_thresholds: [0.85, 0.60, 0.25]  # A/B/C percentile cuts (D is the remainder)
  hysteresis_margin: 0.05         # fraction of score span guarding each boundary
  handling_penalty: {case: 1.0, each: 1.3, pallet: 0.8}
  dead_stock_days: 60             # no pick in this many days -> force D
  recalc_cron: "0 2 * * 1"        # weekly, Monday 02:00 local
VELOCITY_TAXONOMY = {
    "observation_window_days": 90,
    "half_life_days": 45,
    "iqr_multiplier": 1.5,
    "tier_thresholds": (0.85, 0.60, 0.25),
    "hysteresis_margin": 0.05,
    "handling_penalty": {"case": 1.0, "each": 1.3, "pallet": 0.8},
    "dead_stock_days": 60,
    "recalc_cron": "0 2 * * 1",
}
Parameter Default Effect of increasing Recalibrate when
observation_window_days 90 Smoother scores, slower to react Seasonal peak/off-peak transitions
half_life_days 45 Older picks retain weight; fewer moves Promo cadence or assortment churn changes
tier_thresholds (0.85,0.60,0.25) Fewer SKUs in top tiers; tighter golden zone Golden-zone slot count changes
hysteresis_margin 0.05 Fewer promotions/demotions; more stable Relocation labor over budget
dead_stock_days 60 Slower to sweep stagnant SKUs Reserve-zone congestion observed

Threshold selection should be driven by slot supply, not a textbook 80/15/5. If the facility has 400 golden-zone faces, set the A cut so roughly 400 SKUs land in A — a threshold that nominates 900 A-SKUs for 400 slots just pushes the overflow into a fallback zone and hides the real constraint. Re-derive the cuts whenever the physical zone inventory changes.

Validation & Testing

Tier output must be regression-tested before it drives a single move. The checks below assert the invariants that matter operationally: bounded scores, monotone tier ordering, determinism across identical runs, and a controlled migration rate between cycles.

import numpy as np
import pandas as pd

def test_velocity_pipeline():
    df = pd.DataFrame({
        "sku_id": [f"S{i}" for i in range(1000)],
        "picks_window": np.random.default_rng(7).poisson(20, 1000),
        "days_in_stock": np.full(1000, 90),
        "avg_cube": np.random.default_rng(8).uniform(0.001, 0.5, 1000),
        "pick_type": np.random.default_rng(9).integers(1, 4, 1000),
    })
    scored = assign_tiers(composite_score(apply_decay(normalize_velocity(df))))

    # 1. Every SKU gets exactly one valid tier.
    assert set(scored["velocity_tier"]).issubset({"A", "B", "C", "D"})
    assert scored["velocity_tier"].notna().all()

    # 2. Higher composite score never lands in a lower tier than a lower score.
    rank = {"A": 3, "B": 2, "C": 1, "D": 0}
    s = scored.sort_values("composite")
    tiers = s["velocity_tier"].map(rank).to_numpy()
    assert np.all(np.diff(tiers) >= 0), "tier ordering must be monotone in score"

    # 3. Determinism: identical input yields identical tiers.
    again = assign_tiers(composite_score(apply_decay(normalize_velocity(df))))
    assert scored["velocity_tier"].equals(again["velocity_tier"])

    # 4. Migration rate between consecutive runs stays bounded (< 15%).
    prev = scored.set_index("sku_id")["velocity_tier"]
    rerun = assign_tiers(
        composite_score(apply_decay(normalize_velocity(df))),
        prev_tier=prev,
    )
    churn = (rerun["velocity_tier"].to_numpy() != prev.to_numpy()).mean()
    assert churn < 0.15, f"excessive tier churn: {churn:.1%}"

Integration Points

The taxonomy is upstream of nearly every other decision in the system, so its output contract matters as much as its accuracy. The tier label and composite score are consumed as follows:

  • Location assignment. The tiers become the primary input to the assignment math in Location Assignment & ABC Classification Algorithms; its ABC Classification Tuning layer re-derives zone cut points against actual slot supply before any SKU is committed to a face.
  • Hard constraints. Before a tier can claim a golden-zone slot, the candidate must clear Weight & Volume Constraint Modeling — a fast HYPER SKU that exceeds a shelf’s weight rating is re-routed to a ground-level pallet position regardless of velocity.
  • Spatial topology. Tier-to-zone alignment depends on Location Hierarchy Mapping, which supplies the aisle, level, and equipment metadata that decides whether an A-tier item lands in a waist-height forward face or a mezzanine lane.
  • Travel validation. Any re-slot the tiers imply is replayed through Pick Path Modeling Frameworks so the projected travel-time delta is confirmed negative before directives are published.
  • Scale. For assortments in the millions, the four-stage function runs under Async Batch Processing for Velocity rather than a single synchronous pass, sharded by SKU key.

The full end-to-end classification path — from raw transactions through tier labels — is walked line by line in How to Classify SKUs by Inventory Velocity.

Failure Modes & Edge Cases

  • Cold-start SKUs. A brand-new item has no history and scores 0, dumping it into D and a remote reserve slot it will immediately outgrow. Remediation: seed new SKUs at their forecast tier (or a category median) for the first N cycles, then let real picks take over.
  • Promo-spike inflation. An uncapped promotional day promotes a durable C-mover to A for weeks. Remediation: apply the IQR/percentile cap before aggregation and treat flagged promo windows as a separate signal, not baseline demand.
  • Mixed observation windows. Scoring some SKUs on 30 days and others on 90 makes composite scores non-comparable and tiers arbitrary. Remediation: enforce a single observation_window_days per run and reject rows outside it at ingestion.
  • Zombie dormancy. Trailing returns or cycle-count adjustments keep a discontinued SKU warm in a prime slot. Remediation: honor is_active and force any SKU idle beyond dead_stock_days straight to D regardless of score.
  • Boundary thrashing. Without hysteresis, SKUs on a tier line oscillate every cycle and generate relocation cost with zero travel benefit. Remediation: keep the hysteresis_margin band and monitor tier-migration rate as a first-class KPI.

FAQ

How many velocity tiers should I use?

Use exactly as many as you have physically distinct storage zones to assign against. Three (A/B/C) is the floor; add a D / dead band the moment you need to sweep stagnant SKUs out of prime slots without contaminating the C threshold, and a fifth HYPER band only if a handful of SKUs justify a dedicated forward face or pick-to-light lane. A tier that maps to no distinct zone issues no directive and only adds churn.

How do I pick the decay half-life?

Anchor it to your replenishment review cycle. A 45-day half-life demotes a stagnant SKU by about one tier within six weeks of inactivity, which lines up with a monthly review. Shorter than 14 days makes tiers react to noise and churns the move crew; longer than 90 days leaves cooling inventory in golden-zone faces well past its demand.

Why blend volume and handling into the score instead of ranking on pick frequency?

Because pickers are paid in time and slots are constrained by physics, not pick counts. Two SKUs with identical frequency have different economics when one is a single-each and the other is a 40-kg pallet item. Folding volume_factor and handling_penalty into the composite score is what stops a bulky, slow-to-handle item from claiming a prime each-pick face it does not deserve.

How do I stop SKUs from flip-flopping between tiers every cycle?

Add a hysteresis margin around each boundary: require a score to clear the threshold by the margin to promote and fall below it by the margin to demote, holding the prior tier inside the dead zone. Then track tier-migration rate as a KPI — a healthy weekly run moves well under 15% of the assortment.

Where do new SKUs with no pick history go?

Never straight to D on a zero score, or they land in remote reserve and immediately need re-slotting once demand appears. Seed them at a forecast tier or the category median for the first few cycles, flag them as cold-start, and let real decayed velocity take over once the window fills.