Slotting Architecture · 19 min read

Threshold Optimization for Re-slotting

A slotting plan is correct for exactly one demand distribution — the one it was computed against. The moment velocity drifts, every static plan starts leaking travel time: a promoted SKU sits two aisles deep, a decayed one squats in the golden zone, and the re-slotting queue grows faster than labor can drain it. This guide is part of the Location Assignment & ABC Classification Algorithms system, and it covers the when-to-move layer specifically: how you replace calendar-based refresh cycles with data-driven triggers that fire a relocation only when velocity drift is both statistically real and economically worth the labor. Where ABC Classification Tuning decides which tier a SKU belongs to, threshold optimization decides whether that paper reclassification is allowed to become a physical move.

What Re-slotting Threshold Optimization Is

Re-slotting thresholds are not arbitrary velocity cutoffs. They are two coupled gates a candidate SKU must clear before floor labor is dispatched:

  1. A drift gate — evidence that the SKU’s velocity has genuinely shifted, not just wobbled inside its normal transactional noise band. This isolates a real level change from a promotional blip or a one-week stockout artifact.
  2. A break-even gate — evidence that the projected travel-time savings from moving the SKU to a better-matched location exceed the one-time cost of the move itself (labor to pick up, transport, put away, and re-scan).

A relocation is executed only when both gates pass. Optimizing the thresholds means choosing the sensitivity of the drift detector and the payback horizon of the break-even model so the system moves early enough to capture savings but not so eagerly that it thrashes — relocating the same SKU back and forth as its velocity oscillates across a boundary.

Three details separate a production trigger engine from a periodic full re-slot:

  • Drift detection, not raw threshold crossing. A single day above a cutoff means nothing. Sequential detectors — EWMA control charts or a CUSUM accumulator — separate a sustained level shift from background variance, so the trigger reflects a durable demand change rather than a spike.
  • Break-even, not blanket refresh. Every move has a fixed handling cost. The trigger compares the discounted travel-savings stream against that cost and suppresses moves whose payback horizon exceeds the tuned limit. A SKU that would save three seconds per pick at two picks a day is not worth a fifteen-minute relocation.
  • Hysteresis, not symmetric firing. The velocity band that promotes a SKU into a faster zone is deliberately wider than the band that would demote it straight back, so a SKU parked near a boundary does not ping-pong every evaluation cycle.

The distinction from tuning matters: tuning can flip a SKU’s class on paper every cycle for free; threshold optimization is the economic circuit-breaker that stops those paper flips from turning into a relocation task list no crew can finish.

Re-slotting trigger: two economic gates with a suppression branch A boustrophedon pipeline. Top row, left to right: WMS pick history (VelocitySample rows over a 14 to 28 day window) flows into a rolling velocity and drift detector (EWMA or CUSUM producing a Z-score), then into diamond gate one which tests whether drift is significant (absolute Z above the threshold). Before gate one, a coral dashed branch diverts SKUs whose drift coincides with a promotion, stockout or phantom-inventory event into a "Diverted — no move" sink. Candidates that pass gate one drop to the bottom row and flow right to left: a break-even model compares projected travel savings against the one-time move cost, then diamond gate two tests whether payback lands within the horizon, and an asymmetric hysteresis margin (promote wider than demote) gates the final committed candidate handed to the constraint solver. Only candidates clearing both gates dispatch labor. Score daily · dispatch weekly pass ✓ divert WMS pick history VelocitySample · 14–28d Rolling velocity + drift EWMA / CUSUM · Z-score Break-even model Δsavings vs move cost Committed candidate → constraint solver Diverted — no move promo · stockout · phantom Drift significant? |Z| > z_threshold Payback ≤ horizon? days ≤ payback_horizon hysteresis promote > demote
Two coupled gates stand between a velocity signal and a floor task. Gate one asks whether the drift is statistically real; the break-even model then prices the move, gate two asks whether it pays back inside the horizon, and an asymmetric hysteresis margin blocks boundary ping-pong — while a suppression branch diverts promo, stockout and phantom-inventory spikes before they ever reach the economics.

Input Data Requirements

Threshold evaluation consumes the same normalized pick stream that feeds classification, plus the current committed slot map and a small cost table. It reads velocity as a per-SKU daily series, so a bounded rolling window (14- or 28-day working default) must exist before any trigger can fire; a shorter window makes the drift detector jumpy, a longer one makes it slow to react to real shifts. Missing move-cost or travel-cost inputs silently collapse the break-even gate into an always-pass, so treat those as hard preconditions.

Field Type Precondition
sku_id str Non-null, stable across the window; no merged/retired aliases
pick_date datetime Timezone-normalized to the facility; no multi-day feed gaps in the window
daily_picks int >= 0; zero-pick days present (not dropped) so drift sees decay
current_slot_id str The committed location; joins to the travel-cost model
current_travel_cost float Seconds per pick at the present slot; drives the savings numerator
move_cost_seconds float > 0; one-time handling seconds to relocate this SKU
channel str Fulfillment path (ecom, b2b, …) for velocity weighting
from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class VelocitySample:
    """One SKU-day of pick activity — the atomic input to drift detection."""
    sku_id: str
    pick_date: datetime
    daily_picks: int
    channel: str


@dataclass(frozen=True)
class SlotCostRow:
    """Per-SKU economics used by the break-even gate."""
    sku_id: str
    current_slot_id: str
    current_travel_cost: float   # seconds per pick at present slot
    move_cost_seconds: float     # one-time handling cost to relocate

The quality gate matters more than the schema. Zero-pick days must be present, not filtered — a SKU that stops moving reads as decayed only if the detector sees the zeros. Promotional spikes and stockout gaps both masquerade as drift, so the suppression stage (below) runs before the drift gate, not after.

Step-by-Step Implementation

The pipeline is a deterministic four-stage flow: aggregate the rolling velocity series, detect drift, apply the break-even and hysteresis gates, and emit committed candidates. Each stage is a pure function so it can be unit-tested in isolation and reordered without side effects. The upstream feed contract is owned by the Velocity Data Ingestion & WMS Sync Pipelines system; this pipeline assumes those rows arrive already normalized and windowed.

1. Aggregate the Rolling Velocity Series

Collapse raw pick lines into a per-SKU daily series and apply channel weighting. Omnichannel facilities cannot compare raw pick counts across paths: a single-line e-commerce pick costs more handling per unit than a full-case B2B pick, so e-commerce velocity is weighted 1.3–1.5× to reflect that cost before any threshold is applied.

import logging
import pandas as pd

logger = logging.getLogger("reslotting.aggregate")

CHANNEL_WEIGHTS: dict[str, float] = {"ecom": 1.4, "b2b": 1.0, "retail": 1.1}


def build_velocity_series(picks: pd.DataFrame) -> pd.DataFrame:
    """Return a per-SKU daily series with channel-weighted picks."""
    df = picks.copy()
    df["weight"] = df["channel"].map(CHANNEL_WEIGHTS).fillna(1.0)
    df["weighted_picks"] = df["daily_picks"] * df["weight"]
    series = (
        df.groupby(["sku_id", "pick_date"], as_index=False)["weighted_picks"]
        .sum()
        .sort_values(["sku_id", "pick_date"])
    )
    logger.info("built velocity series: %d SKU-days across %d SKUs",
                len(series), series["sku_id"].nunique())
    return series

2. Detect Velocity Drift

A raw threshold crossing is noise; a sustained level shift is signal. This stage computes an EWMA of the weighted series and a rolling standard deviation, then scores each day as a Z-distance from the smoothed mean. The alpha smoothing factor is the primary sensitivity dial — higher alpha reacts faster but admits more noise.

import logging
import pandas as pd

logger = logging.getLogger("reslotting.drift")


def detect_drift(
    series: pd.DataFrame,
    alpha: float = 0.2,
    window: int = 14,
    z_threshold: float = 1.96,
) -> pd.DataFrame:
    """Flag SKU-days whose weighted velocity has drifted beyond noise."""
    df = series.sort_values(["sku_id", "pick_date"]).copy()

    df["ewma"] = df.groupby("sku_id")["weighted_picks"].transform(
        lambda x: x.ewm(alpha=alpha, adjust=False).mean()
    )
    df["vol_std"] = df.groupby("sku_id")["weighted_picks"].transform(
        lambda x: x.rolling(window=window, min_periods=window // 2).std()
    )
    # +1e-6 guards a zero-variance (flat) series from a divide-by-zero
    df["z_score"] = (df["weighted_picks"] - df["ewma"]) / (df["vol_std"] + 1e-6)
    df["drift"] = df["z_score"].abs() > z_threshold

    flagged = int(df["drift"].sum())
    logger.info("drift detector flagged %d SKU-days (alpha=%.2f, z=%.2f)",
                flagged, alpha, z_threshold)
    return df

3. Suppress False Triggers

Promotional spikes, temporary stockouts, and phantom inventory all read as drift but must never dispatch labor. This stage diverts any candidate whose drift day coincides with a known suppression event before it reaches the economic gate. Suppression windows are facility-configurable and should be sourced from the same promo calendar the demand planners use.

import logging
import pandas as pd

logger = logging.getLogger("reslotting.suppress")


def apply_suppression(
    flagged: pd.DataFrame,
    promo_skus: set[str],
    stockout_skus: set[str],
) -> pd.DataFrame:
    """Drop drift flags that coincide with promotions or stockouts."""
    df = flagged.copy()
    suppressed = df["sku_id"].isin(promo_skus | stockout_skus)
    df.loc[suppressed & df["drift"], "drift"] = False
    logger.info("suppressed %d drift flags (promo=%d, stockout=%d)",
                int(suppressed.sum()), len(promo_skus), len(stockout_skus))
    return df

4. Apply the Break-even and Hysteresis Gates

The surviving candidates hit the economics. The break-even model projects the daily travel-time saving from a better slot over a payback horizon and compares it against the one-time move cost. Hysteresis is enforced by requiring a larger drift margin for a promote (into scarcer prime real estate) than for a demote.

import logging
from dataclasses import dataclass

logger = logging.getLogger("reslotting.gate")


@dataclass(frozen=True)
class ReslotDecision:
    sku_id: str
    action: str              # "promote" | "demote" | "hold"
    payback_days: float
    net_saving_seconds: float


def evaluate_move(
    z_score: float,
    daily_picks: float,
    current_travel_cost: float,
    target_travel_cost: float,
    move_cost_seconds: float,
    payback_horizon_days: int = 30,
    promote_margin: float = 2.5,
    demote_margin: float = 1.96,
) -> ReslotDecision:
    """Return a re-slot decision after break-even and hysteresis gates."""
    per_pick_saving = current_travel_cost - target_travel_cost
    daily_saving = per_pick_saving * daily_picks
    if daily_saving <= 0:
        return ReslotDecision("", "hold", float("inf"), 0.0)

    payback_days = move_cost_seconds / daily_saving
    horizon_saving = daily_saving * payback_horizon_days - move_cost_seconds

    # Hysteresis: promotes into scarce prime real estate need a bigger signal
    margin = promote_margin if z_score > 0 else demote_margin
    if abs(z_score) < margin or payback_days > payback_horizon_days:
        logger.debug("hold: z=%.2f payback=%.1fd", z_score, payback_days)
        return ReslotDecision("", "hold", payback_days, horizon_saving)

    action = "promote" if z_score > 0 else "demote"
    logger.info("%s candidate: payback=%.1fd net=%.0fs",
                action, payback_days, horizon_saving)
    return ReslotDecision("", action, payback_days, horizon_saving)

Qualified candidates are then handed downstream: the target slot must still clear Weight & Volume Constraint Modeling before the move is committed, so the trigger engine emits candidates, never final placements.

Tuning & Calibration

Four parameters govern the trigger’s behavior, and every facility lands in a different corner of the space. The dominant trade-off is thrash versus decay: a sensitive detector (high alpha, low z_threshold, short payback_horizon) captures savings early but risks relocating SKUs that revert; a conservative one lets placement decay compound between moves. Start conservative, run in shadow mode, and loosen only where the observed false-positive rate stays under budget.

reslotting_thresholds:
  ewma_alpha: 0.2            # velocity smoothing; higher = faster, noisier
  drift_window_days: 14      # rolling std window feeding the Z-score
  z_threshold: 1.96          # base drift significance (~95%)
  promote_margin: 2.5        # hysteresis: wider band to enter prime zones
  demote_margin: 1.96        # narrower band to leave them
  payback_horizon_days: 30   # max acceptable move-cost payback period
  channel_weights:
    ecom: 1.4
    b2b: 1.0
    retail: 1.1
  evaluation_cadence: daily  # score daily, dispatch on the weekly window
# Python dict equivalent — load once, pass to the pipeline stages
RESLOT_CONFIG: dict = {
    "ewma_alpha": 0.2,
    "drift_window_days": 14,
    "z_threshold": 1.96,
    "promote_margin": 2.5,
    "demote_margin": 1.96,
    "payback_horizon_days": 30,
    "channel_weights": {"ecom": 1.4, "b2b": 1.0, "retail": 1.1},
    "evaluation_cadence": "daily",
}

Cadence is its own calibration axis. Decouple cheap frequent scoring from expensive disruptive dispatch: for most mid-size distribution centers a daily threshold evaluation paired with a weekly physical re-slotting window balances data freshness against labor stability. Running dispatch too often generates operational thrash; running it too rarely lets decay compound. The batch that recomputes velocity for large catalogs belongs in the Async Batch Processing for Velocity layer so scoring never blocks the trigger evaluation.

Validation & Testing

Never let an untested trigger dispatch labor. These assertions lock the two non-negotiable properties: a move with a negative or infinite payback must never fire, and hysteresis must hold a marginal-Z SKU that a symmetric threshold would have flipped.

def test_negative_saving_holds():
    # Target slot is worse than current — must never move.
    d = evaluate_move(z_score=3.0, daily_picks=40,
                      current_travel_cost=8.0, target_travel_cost=9.0,
                      move_cost_seconds=600)
    assert d.action == "hold"


def test_break_even_rejects_slow_mover():
    # Real saving per pick, but too few picks to pay back in the horizon.
    d = evaluate_move(z_score=3.0, daily_picks=2,
                      current_travel_cost=12.0, target_travel_cost=6.0,
                      move_cost_seconds=900, payback_horizon_days=30)
    assert d.action == "hold"
    assert d.payback_days > 30


def test_hysteresis_holds_marginal_promote():
    # z=2.1 clears the demote margin but not the wider promote margin.
    d = evaluate_move(z_score=2.1, daily_picks=50,
                      current_travel_cost=10.0, target_travel_cost=4.0,
                      move_cost_seconds=300,
                      promote_margin=2.5, demote_margin=1.96)
    assert d.action == "hold"


def test_decisive_promote_fires():
    d = evaluate_move(z_score=3.2, daily_picks=60,
                      current_travel_cost=11.0, target_travel_cost=4.0,
                      move_cost_seconds=300, payback_horizon_days=30)
    assert d.action == "promote"
    assert d.payback_days < 30

Run the full pipeline against a held-out month of historical relocations before going live: the drift-plus-break-even decision should agree with the outcomes a planner actually kept. Treat 92% agreement against that history as the bar for promoting the system from shadow mode to active dispatch.

Integration Points

Threshold optimization sits between classification and physical assignment, so it both consumes and feeds sibling systems:

  • From tuning. ABC Classification Tuning emits paper class flips; this layer is the economic gate that decides which of those flips are worth executing. A tier change with no break-even is held, not moved.
  • Into constraint modeling. Every qualified candidate must clear Weight & Volume Constraint Modeling — load limits, crush rules, and bin footprint — before the target slot is committed.
  • Against affinity. Velocity shifts frequently break co-pick relationships, so a promote is cross-referenced with Family & Affinity Grouping matrices to avoid splitting a pair that is picked together; the target travel cost fed into the break-even model already reflects the pick-path geometry defined by Pick-Path Modeling Frameworks.
  • Back to the WMS. Committed moves are pushed as relocation tasks and their pick confirmations become the next window’s input, closing the loop.

Failure Modes & Edge Cases

  • Promotional spikes read as permanent drift. A one-week campaign pushes Z past threshold and the SKU is promoted right before demand collapses. Remediation: run the suppression stage against the promo calendar before the drift gate, and clip outliers beyond the 99th percentile of the trailing window.
  • Stockout gaps read as decay. A fast SKU that was unpickable reads as slow and gets demoted out of the golden zone. Remediation: keep zero-pick days but tag stockout intervals so they are suppressed rather than smoothed into the EWMA.
  • Boundary ping-pong. A SKU parked near a tier line flips promote/demote every cycle, generating paired relocations that net zero. Remediation: widen promote_margin relative to demote_margin and require a minimum days-since-last-move cooldown per SKU.
  • Break-even with a stale travel-cost model. If target_travel_cost is computed against an outdated slot map, the savings numerator is fiction and the gate passes bad moves. Remediation: recompute travel costs from the live committed map on every evaluation, never from a cached snapshot.
  • Flat-series divide-by-zero. A SKU with zero variance in the window yields an undefined Z-score. Remediation: the + 1e-6 epsilon in the denominator holds, but also floor the minimum picks (min_volume) so genuinely dormant SKUs never enter the gate.

FAQ

How is a re-slotting threshold different from an ABC tier threshold?

An ABC tier threshold decides classification — which velocity band a SKU sits in — and can change on paper every cycle at no cost. A re-slotting threshold decides action — whether that paper change is worth the physical labor to execute. The re-slot gate adds an economic break-even and hysteresis on top of the tier decision, so a SKU can change class without being moved if the projected travel saving does not repay the relocation cost inside the payback horizon.

Should I use EWMA or CUSUM for drift detection?

EWMA (used above) is the pragmatic default: it is cheap, streams cleanly per SKU, and its single alpha dial maps intuitively to reaction speed. CUSUM detects small sustained shifts sooner and is worth adopting when you need to catch slow decay before it costs a full tier of travel, but it carries a second parameter (the slack k) to tune and is more sensitive to a mis-set reference mean. Start with EWMA; move to CUSUM only if slow drift is measurably escaping the EWMA band.

What payback horizon should I set?

Anchor it to how long a slot assignment typically stays valid before the next demand shift. Thirty days is a sound starting point for stable catalogs; shorten it toward 14 for volatile e-commerce assortments where placements rarely survive a month, and lengthen it toward 60 for slow, stable B2B ranges. The horizon is a policy dial, not a physical constant — set it, then check the observed re-move rate and adjust.

How do I stop the system from relocating the same SKU repeatedly?

Two controls. Asymmetric hysteresis (a wider promote margin than demote margin) stops boundary ping-pong, and a per-SKU cooldown — a minimum number of days since the last committed move — blocks any second relocation until the first has had time to pay back. Together they cap the churn a single oscillating SKU can generate regardless of how noisy its velocity is.

Why weight e-commerce picks higher than B2B?

Because equal pick counts do not represent equal handling cost. A single-line e-commerce pick incurs the full walk-grab-scan cycle for one unit, while a B2B full-case pick amortizes that cycle across many units. Weighting e-commerce velocity 1.3–1.5× before thresholding makes the drift and break-even gates compare true material-handling effort rather than a raw touch tally, which keeps high-cost single-line SKUs correctly prioritized for prime slots.