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.
Prerequisites
- Python 3.10+ — the scheduler uses
dataclassrecords,dict/listgenerics, and structuredlogging. - 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, ato_slot, and theaisleeach 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
- Build the vacate-before-fill graph.
fillsmaps 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. - Topologically sort with Kahn’s algorithm. Moves with no unmet dependency are
ready; draining the queue yields a safeorder. Sorting the ready set keeps the output deterministic across runs — important when the plan is reviewed by hand. - Detect the deadlock. If
orderis 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. - Compute each move’s earliest window.
earliestis 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. - Pack under budget and cap. Starting at
earliest, the move takes the first window with room underwindow_move_budgetand under the aisle cap (congested_aisle_capfor a congested aisle, elsemax_per_aisle_per_window). Anything that fits nowhere is deferred tounplacedand 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_budgetto 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_windowlets churn pile into an aisle that is still hot. Keepcongested_aislescurrent from the congestion model so the tighter cap protects the aisles that are actually being picked.
Related
- Threshold Optimization for Re-slotting — the parent guide: which flips become the approved moves this scheduler sequences.
- How to Calculate Re-Slotting ROI and Break-Even — the sibling that approves a move before it enters this queue.
- Congestion Modeling & Zone Routing — the source of the congested-aisle list that drives the tighter per-window cap.