Slotting Architecture · 11 min read

How to Schedule Re-Slotting Windows to Minimize Disruption

You have a queue of approved moves and cannot execute them all at once. Relocate too many SKUs in one shift and you starve picking of labor, jam aisles, and create fill-before-vacate deadlocks where two moves each wait for the other’s slot. Scheduling is the step that turns an approved batch into a safe execution plan: it packs moves into low-activity windows under a budget, orders every move so a slot is vacated before anything fills it, and keeps churn out of aisles that are still being picked. This page builds that scheduler. It is the execution-planning step of the Threshold Optimization for Re-slotting guide, under the Location Assignment & ABC Classification Algorithms architecture.

A week-long shift timeline with three low-activity re-slotting windows and their move budgets A horizontal timeline across three days. Narrow shaded blocks mark active picking shifts (Monday, Tuesday and Wednesday day shifts) where re-slotting churn is avoided. Between them, three wider blocks mark low-activity night windows numbered one, two and three, into which approved moves are scheduled. Below each night window is a budget bar out of a six-move budget: window one is full at six of six, window two holds five of six, and window three holds three of six. A note states that moves within each window are ordered vacate before fill, and that the congested aisle A12 is capped at one move per window. Re-slotting schedule — windows under a move budget active shift (no churn) low-activity window Mon shift Tue shift Wed shift night window ① night window ② night window ③ 6 / 6 moves 5 / 6 moves 3 / 6 moves per-window move budget → Within each window, moves run vacate → fill; overflow rolls to the next window. Congested aisle A12 capped at 1 move/window to protect active picking.
Approved moves are packed into low-activity night windows, never into active shifts. Each window holds up to its move budget; moves are ordered so a slot is emptied before anything is placed into it, and congested aisles get a tighter per-window cap. Whatever does not fit rolls to the next window.

Prerequisites

  • Python 3.10+ — the scheduler uses dataclass records, dict/list generics, and structured logging.
  • A queue of approved moves — each already cleared by How to Calculate Re-Slotting ROI and Break-Even, so scheduling only sequences moves that already pay for themselves.
  • Slot coordinates per move — a from_slot, a to_slot, and the aisle each sits in, so vacate-before-fill dependencies and per-aisle caps can be computed.
  • A window calendar — the ordered list of low-activity windows (typically off-shift or night) available this cycle.
  • A congestion read — which aisles are still hot enough to protect, sourced from Congestion Modeling & Zone Routing so the per-aisle cap reflects real pick density, not a guess.

Configuration Block

The budget and the caps are the levers. window_move_budget bounds labor per window; max_per_aisle_per_window bounds concurrent churn in any one aisle; congested aisles get a tighter cap.

# reslot_schedule.yaml — one profile per facility per cycle
reslot_schedule:
  window_move_budget: 6            # max moves executed in one low-activity window
  allowed_windows:                 # ordered; moves fill earliest-first
    - night-mon
    - night-tue
    - night-wed
  max_per_aisle_per_window: 3      # cap churn in any single aisle per window
  congested_aisles: [A12]          # aisles still being picked; protect them
  congested_aisle_cap: 1           # tighter per-window cap for congested aisles
# Equivalent Python config dict consumed by the scheduler
RESLOT_SCHEDULE = {
    "window_move_budget": 6,
    "allowed_windows": ["night-mon", "night-tue", "night-wed"],
    "max_per_aisle_per_window": 3,
    "congested_aisles": ["A12"],
    "congested_aisle_cap": 1,
}

Implementation

The scheduler runs in two phases. First it topologically orders the queue so every vacating move precedes the move that fills the freed slot, failing loudly on a cycle (a deadlock that needs a staging slot). Then it greedily packs moves into the earliest window that has budget and aisle headroom, never placing a move before any of its dependencies.

from __future__ import annotations

import logging
from collections import deque
from dataclasses import dataclass, field

logger = logging.getLogger("reslot.schedule")


@dataclass(frozen=True)
class Move:
    move_id: str
    sku_id: str
    from_slot: str
    to_slot: str
    aisle: str


@dataclass
class ScheduledWindow:
    label: str
    move_ids: list[str] = field(default_factory=list)


def schedule_moves(moves: list[Move], cfg: dict) -> tuple[list[ScheduledWindow], list[str]]:
    """Bin approved moves into low-activity windows, vacate-before-fill, under budgets."""
    id_map = {m.move_id: m for m in moves}
    # Edge m -> n when m frees the slot that n fills: m must run no later than n.
    fills = {m.to_slot: m.move_id for m in moves}
    adj: dict[str, list[str]] = {m.move_id: [] for m in moves}
    indeg: dict[str, int] = {m.move_id: 0 for m in moves}
    for m in moves:
        dependent = fills.get(m.from_slot)
        if dependent and dependent != m.move_id:
            adj[m.move_id].append(dependent)
            indeg[dependent] += 1

    # Kahn topological sort; a leftover node means a fill-before-vacate cycle.
    ready = deque(sorted(mid for mid, d in indeg.items() if d == 0))
    order: list[str] = []
    while ready:
        mid = ready.popleft()
        order.append(mid)
        for nxt in adj[mid]:
            indeg[nxt] -= 1
            if indeg[nxt] == 0:
                ready.append(nxt)
    if len(order) != len(moves):
        stuck = [mid for mid, d in indeg.items() if d > 0]
        logger.error("fill-before-vacate cycle among %s; insert a staging slot", stuck)
        raise ValueError(f"dependency cycle: {stuck}")

    # Greedy pack into earliest feasible window, respecting dependency ordering.
    windows = [ScheduledWindow(w) for w in cfg["allowed_windows"]]
    aisle_load: list[dict[str, int]] = [{} for _ in windows]
    placed_at: dict[str, int] = {}
    unplaced: list[str] = []
    congested = set(cfg["congested_aisles"])

    for mid in order:
        m = id_map[mid]
        preds = [p for p in adj if mid in adj[p]]
        if any(p not in placed_at for p in preds):
            unplaced.append(mid)  # a slot-freeing predecessor was deferred
            logger.warning("deferring %s: a vacate dependency did not fit", mid)
            continue
        earliest = max((placed_at[p] for p in preds), default=0)
        cap = cfg["congested_aisle_cap"] if m.aisle in congested else cfg["max_per_aisle_per_window"]
        for wi in range(earliest, len(windows)):
            win, load = windows[wi], aisle_load[wi]
            if len(win.move_ids) >= cfg["window_move_budget"]:
                continue
            if load.get(m.aisle, 0) >= cap:
                continue
            win.move_ids.append(mid)
            load[m.aisle] = load.get(m.aisle, 0) + 1
            placed_at[mid] = wi
            break
        else:
            unplaced.append(mid)
            logger.warning("no window fits %s (aisle %s); rolls to next cycle", mid, m.aisle)

    logger.info(
        "Scheduled %d/%d moves across %d windows; %d deferred",
        len(placed_at), len(moves), len(windows), len(unplaced),
    )
    return windows, unplaced

Step-by-Step Walkthrough

  1. Build the vacate-before-fill graph. fills maps each target slot to the move that fills it. For every move, if some other move fills the slot this one is vacating (fills.get(m.from_slot)), an edge is added so the vacating move is ordered first. This is the dependency the timeline annotation shows.
  2. Topologically sort with Kahn’s algorithm. Moves with no unmet dependency are ready; draining the queue yields a safe order. Sorting the ready set keeps the output deterministic across runs — important when the plan is reviewed by hand.
  3. Detect the deadlock. If order is shorter than the input, some moves never reached in-degree zero: a fill-before-vacate cycle (A wants B’s slot, B wants A’s). The scheduler raises rather than emitting an impossible plan; break the cycle with a temporary staging slot.
  4. Compute each move’s earliest window. earliest is one past the latest window any predecessor landed in, so a dependent move can never be scheduled before the move that frees its slot — the packing respects the ordering, not just the topological list.
  5. Pack under budget and cap. Starting at earliest, the move takes the first window with room under window_move_budget and under the aisle cap (congested_aisle_cap for a congested aisle, else max_per_aisle_per_window). Anything that fits nowhere is deferred to unplaced and rolls to the next cycle instead of over-packing a window.

Verification

Feed a small queue that includes a genuine dependency — one move fills the slot another vacates — plus enough moves to exceed a window budget, and confirm ordering, budget, and overflow all hold.

import logging

logging.basicConfig(level=logging.INFO)

cfg = {
    "window_move_budget": 2,
    "allowed_windows": ["night-mon", "night-tue"],
    "max_per_aisle_per_window": 2,
    "congested_aisles": ["A12"],
    "congested_aisle_cap": 1,
}

moves = [
    Move("m1", "SKU-A", from_slot="A05", to_slot="A09", aisle="A05"),  # vacates A05
    Move("m2", "SKU-B", from_slot="B01", to_slot="A05", aisle="B01"),  # fills A05, needs m1 first
    Move("m3", "SKU-C", from_slot="C02", to_slot="C07", aisle="A12"),  # congested aisle
    Move("m4", "SKU-D", from_slot="C03", to_slot="C08", aisle="A12"),  # congested aisle
]

windows, unplaced = schedule_moves(moves, cfg)
plan = {w.label: w.move_ids for w in windows}

# m1 (vacates A05) must be scheduled no later than m2 (fills A05).
w_of = {mid: i for i, w in enumerate(windows) for mid in w.move_ids}
assert w_of["m1"] <= w_of["m2"]
# Congested aisle A12 caps at 1/window, so m3 and m4 cannot share a window.
assert w_of["m3"] != w_of["m4"]
print(plan, "deferred:", unplaced)

Sample expected output:

INFO:reslot.schedule:Scheduled 4/4 moves across 2 windows; 0 deferred
{'night-mon': ['m1', 'm3'], 'night-tue': ['m4', 'm2']} deferred: []

The plan reads correctly: m1 frees A05 on Monday night before m2 fills it on Tuesday, and the two congested-aisle moves m3/m4 land in separate windows because A12’s cap is 1. Tightening window_move_budget to 1 would push moves into unplaced, exactly the overflow the scheduler is designed to surface rather than swallow.

Common Pitfalls

  • Fill-before-vacate deadlock. Two moves that each need the other’s slot form a cycle no ordering can satisfy. The scheduler raises instead of hanging; resolve it by routing one SKU through a temporary staging slot so the cycle becomes a chain.
  • Over-packing a single window. Setting window_move_budget to whatever the queue happens to be ignores the labor actually available that night. Size the budget to crew hours, and let overflow roll forward — a window that runs long eats into the next shift’s picking.
  • Ignoring replenishment. Re-slotting competes with put-away and replenishment for the same aisles and the same crew. If the schedule does not net out replenishment already booked into a window, the budget is fiction; reserve headroom for it before packing moves.
  • Treating all aisles as equal. A blanket max_per_aisle_per_window lets churn pile into an aisle that is still hot. Keep congested_aisles current from the congestion model so the tighter cap protects the aisles that are actually being picked.