Slotting Architecture · 21 min read

Pick Path Modeling Frameworks for Velocity-Driven Slotting

Pick path modeling turns a warehouse layout into a weighted directed graph and asks a harder question than “what is the shortest walk?” — it asks “what is the cheapest ordered traversal for this order, given velocity, equipment, and congestion?” This guide is part of the Core Slotting Architecture & Velocity Taxonomies system, and it exists because slotting decisions are only as good as the routing model that measures their travel cost: a “better” slot that adds cross-aisle backtracking to every wave is not better at all. Here you build a production-grade routing framework — graph construction, velocity-weighted edges, sequence optimization, and fallback routing — that the assignment layer can query to score candidate moves before any physical relocation is authorized.

What a Pick Path Model Is

A pick path model is a directed, weighted graph G = (V, E) plus a solver that produces an ordered pick sequence over a subset of nodes. Nodes (V) are pickable positions and navigational waypoints — bins, pallet positions, cross-dock staging points, aisle endpoints, and conveyor induction points. Edges (E) are the legal moves between them, and every edge carries a weight that is travel time, not distance. That distinction is the whole discipline: a two-metre reach into a congested golden-zone face during a peak wave can cost more elapsed time than a ten-metre walk down an empty reserve aisle.

Three model variants show up in practice, and choosing the wrong one wastes compute:

  • Point-to-point shortest path — Dijkstra or A* between two nodes. Correct for single-line replenishment moves and for precomputing the pairwise distance matrix everything else depends on.
  • Ordered multi-pick routing — a Traveling Salesperson Problem (TSP) variant: visit every pick in an order exactly once and return to a depot (pack station), minimizing total travel time. This is the model that matters for discrete order picking.
  • Constrained batch routing — a Vehicle Routing Problem (VRP) variant layering capacity (cart cube/weight), precedence (heavy-on-bottom, crush-sensitive-last), and time windows (priority or perishable SKUs) onto the multi-pick tour.

Edge weights are never static because the two inputs that dominate them are not static: SKU velocity, which comes from the SKU Velocity Taxonomy Design layer, and physical topology, which comes from Location Hierarchy Mapping. A pick path model without a live velocity signal is just a floor plan with arrows on it.

Velocity-weighted warehouse routing graph with optimal tour and fallback A three-aisle by four-bay grid of pick locations (A1 to C4) plus a pack-station depot, joined by directed edges labelled with travel-time weights in seconds. A solid tour starts at the depot, runs along the bottom aisle through golden-zone picks C2 and C4, climbs the bay-4 cross-aisle, returns along the top aisle through picks A3 and A2, drops down the bay-1 cross-aisle and loops back to the depot. The top-aisle segment between A3 and A2 is marked blocked, so a dashed k-shortest fallback route detours down bay 3, across the middle aisle, and back up bay 2 to reach A2. optimal pick tour k-shortest fallback blocked aisle golden-zone pick PACK depot A1 A4 B1 B2 B3 B4 C1 C3 A2 A3 C2 C4 15s 12s 12s 12s 12s 12s 9s 9s 9s 9s 9s 12s 9s

Figure — the facility as a directed graph: edge weights are travel-time seconds, the solid loop is the solver’s optimal tour through the four golden-zone picks, and the dashed leg is the pre-cached k-shortest fallback that activates when the A3–A2 aisle is blocked.

Input Data Requirements

The model consumes three feeds: a node table (positions and their attributes), an edge table (legal moves and base traversal time), and a per-SKU velocity join used to weight service time at each node. Enforce these preconditions at the ingestion boundary — a NULL velocity class or an unmapped location_code does not degrade routing gracefully, it produces a silently wrong tour.

Field Type Source Quality precondition
location_code str (^[A-Z]{2}-\d{3}$) WMS location master Non-null, unique, maps to exactly one node
aisle / bay / level str / int / int Location hierarchy Present for every pickable node
x, y, z float (metres) Rack survey / CAD Finite; z used for lift-time penalty
access_zone str Security boundary map In the allowed set for the picking equipment
velocity_class Literal["A","B","C","D"] Velocity taxonomy Non-null; drives service-time weight
equip_speed_mps float Equipment profile > 0; per equipment class
congestion_mult float Live telemetry ≥ 1.0; defaults to 1.0 when telemetry stale
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Literal, Optional

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

VelocityClass = Literal["A", "B", "C", "D"]


@dataclass(frozen=True)
class PickNode:
    """One routable position in the facility graph."""
    location_code: str
    aisle: str
    bay: int
    level: int
    x: float
    y: float
    z: float
    access_zone: str
    velocity_class: Optional[VelocityClass] = None


@dataclass
class EdgeWeightConfig:
    """Tunable coefficients for the composite travel-time weight."""
    lift_penalty_s_per_m: float = 1.8      # vertical travel is slower than horizontal
    turn_penalty_s: float = 2.5            # cost of a direction change at a cross-aisle
    congestion_cap: float = 3.0            # clamp for the live congestion multiplier
    service_time_by_class: dict[str, float] = field(
        default_factory=lambda: {"A": 6.0, "B": 8.0, "C": 11.0, "D": 15.0}
    )

Step-by-Step Implementation

1. Ingest and Validate Nodes

Reject before you route. A node missing a velocity class or sitting in an access zone the equipment cannot enter must fail closed at ingestion, not surface as a mysteriously expensive tour three stages later. The velocity join comes straight from the taxonomy layer; the access-zone check comes from Security & Access Boundaries for Slotting.

def load_nodes(rows: list[dict], allowed_zones: set[str]) -> list[PickNode]:
    """Build validated PickNodes, quarantining anything that fails a precondition."""
    nodes, rejected = [], 0
    for r in rows:
        vc = r.get("velocity_class")
        if vc is None or r["access_zone"] not in allowed_zones:
            rejected += 1
            continue
        nodes.append(PickNode(
            location_code=r["location_code"], aisle=r["aisle"], bay=int(r["bay"]),
            level=int(r["level"]), x=float(r["x"]), y=float(r["y"]), z=float(r["z"]),
            access_zone=r["access_zone"], velocity_class=vc,
        ))
    logger.info("loaded %d nodes, rejected %d on precondition failure", len(nodes), rejected)
    if rejected > len(nodes) * 0.02:
        logger.warning("rejection rate %.1f%% exceeds 2%% gate", 100 * rejected / max(len(rows), 1))
    return nodes

2. Construct the Weighted Graph

Model the facility as a networkx.DiGraph. Store the node object as node data, and separate traversal edges (aisle-to-aisle movement) from service cost (dwell at a pick face), so the solver can optimise travel while independently accounting for dwell variance by velocity class. Building a directed graph — rather than an undirected one — is what lets you encode one-way aisles and priority bypass lanes.

import networkx as nx
from math import hypot


def build_graph(nodes: list[PickNode], adjacency: list[tuple[str, str]],
                cfg: EdgeWeightConfig) -> nx.DiGraph:
    """Instantiate a directed graph with base traversal-time edge weights."""
    g = nx.DiGraph()
    index = {n.location_code: n for n in nodes}
    for n in nodes:
        g.add_node(n.location_code, node=n)
    for src, dst in adjacency:
        a, b = index.get(src), index.get(dst)
        if a is None or b is None:
            logger.debug("skipping edge %s->%s: unmapped endpoint", src, dst)
            continue
        horizontal = hypot(a.x - b.x, a.y - b.y)
        vertical = abs(a.z - b.z) * cfg.lift_penalty_s_per_m
        g.add_edge(src, dst, base_time=horizontal + vertical)
    logger.info("graph built: %d nodes, %d edges", g.number_of_nodes(), g.number_of_edges())
    return g

3. Apply Velocity-Weighted, Congestion-Aware Edge Weights

The runtime weight is the composite the solver actually optimises: base traversal time, scaled by the live congestion multiplier (clamped), plus the service time the destination node incurs based on its velocity class. High-velocity faces get lower service time (pickers know them, they are ergonomically placed) while dead stock gets a penalty that naturally discourages routing through Class-D reserve unless a batch mandates it.

def apply_dynamic_weights(g: nx.DiGraph, cfg: EdgeWeightConfig,
                          congestion: dict[str, float]) -> None:
    """Set the 'time' attribute used by the solver from base + congestion + service."""
    for src, dst, data in g.edges(data=True):
        node: PickNode = g.nodes[dst]["node"]
        mult = min(congestion.get(dst, 1.0), cfg.congestion_cap)
        service = cfg.service_time_by_class.get(node.velocity_class or "D", 15.0)
        data["time"] = data["base_time"] * mult + service
    logger.debug("re-weighted %d edges (congestion cap=%.1f)", g.number_of_edges(), cfg.congestion_cap)

4. Precompute the Pairwise Distance Matrix

Multi-pick routing needs the shortest travel time between every pair of pick locations in an order. Compute it once per graph revision with Dijkstra and cache it — recomputing per order is the classic way to blow the solver’s latency budget. Cache the base topology (for example in Redis) and apply delta updates only when slotting changes exceed a threshold, so a handful of moves does not trigger a full recompute during a peak wave.

def distance_matrix(g: nx.DiGraph, picks: list[str]) -> dict[tuple[str, str], float]:
    """All-pairs shortest travel time restricted to the pick set + depot."""
    matrix: dict[tuple[str, str], float] = {}
    for src in picks:
        lengths = nx.single_source_dijkstra_path_length(g, src, weight="time")
        for dst in picks:
            if src != dst:
                matrix[(src, dst)] = lengths.get(dst, float("inf"))
    unreachable = sum(1 for v in matrix.values() if v == float("inf"))
    if unreachable:
        logger.warning("%d unreachable pick pairs — check one-way edges / access zones", unreachable)
    return matrix

5. Solve the Ordered Pick Sequence

For an order of more than a handful of lines, greedy nearest-neighbour leaves 15–25% travel on the table. Use a proper TSP solver over the cached matrix. Google OR-Tools’ routing library handles the precedence and capacity constraints that a raw TSP cannot, and returns a near-optimal tour inside a bounded time limit — essential when you are solving thousands of orders per shift.

from ortools.constraint_solver import pywrapcp, routing_enums_pb2


def solve_tour(picks: list[str], matrix: dict[tuple[str, str], float],
               depot: int = 0, time_limit_s: int = 2) -> list[str]:
    """Return an ordered pick sequence minimising total travel time from the depot."""
    n = len(picks)
    mgr = pywrapcp.RoutingIndexManager(n, 1, depot)
    routing = pywrapcp.RoutingModel(mgr)

    def cost(i: int, j: int) -> int:
        a, b = picks[mgr.IndexToNode(i)], picks[mgr.IndexToNode(j)]
        return int(matrix.get((a, b), 1e9) * 100)  # scale to int centiseconds

    transit = routing.RegisterTransitCallback(cost)
    routing.SetArcCostEvaluatorOfAllVehicles(transit)
    params = pywrapcp.DefaultRoutingSearchParameters()
    params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
    params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
    params.time_limit.seconds = time_limit_s

    sol = routing.SolveWithParameters(params)
    if sol is None:
        logger.error("no tour found for %d picks; falling back to input order", n)
        return picks
    order, idx = [], routing.Start(0)
    while not routing.IsEnd(idx):
        order.append(picks[mgr.IndexToNode(idx)])
        idx = sol.Value(routing.NextVar(idx))
    logger.info("solved tour over %d picks, objective=%d", n, sol.ObjectiveValue())
    return order

6. Precompute Fallback Routes

Production routing must survive a blocked aisle or a stale telemetry feed. Precompute k-shortest paths for the hot node pairs and keep a shadow routing table that activates when an edge’s live weight crosses a failure threshold (for example, a congestion multiplier pinned at the cap for longer than one wave). The solver never stalls waiting on a recompute; it swaps to the shadow path and logs the substitution for the observability layer.

from itertools import islice


def k_shortest(g: nx.DiGraph, src: str, dst: str, k: int = 3) -> list[list[str]]:
    """Precompute k alternate routes for resilient failover between two nodes."""
    try:
        paths = list(islice(nx.shortest_simple_paths(g, src, dst, weight="time"), k))
    except nx.NetworkXNoPath:
        logger.error("no path %s->%s; shadow table will have no fallback", src, dst)
        return []
    logger.debug("cached %d fallback routes for %s->%s", len(paths), src, dst)
    return paths

Tuning & Calibration

The edge-weight coefficients and solver budget are facility-specific and must be externalised so they change without a code deploy. The service-time weights should be calibrated against time-study data per equipment class; the congestion cap prevents a single jammed intersection from making an otherwise-optimal aisle look infinitely expensive. Both the YAML and its Python dict equivalent are shown so the config can be loaded from a file or embedded in a settings module.

pickpath:
  lift_penalty_s_per_m: 1.8       # vertical travel time multiplier
  turn_penalty_s: 2.5             # direction-change cost at cross-aisles
  congestion_cap: 3.0             # clamp on live congestion multiplier
  service_time_by_class:          # dwell seconds per pick face by velocity tier
    A: 6.0
    B: 8.0
    C: 11.0
    D: 15.0
solver:
  time_limit_s: 2                 # per-order OR-Tools budget
  k_fallback_paths: 3             # alternates cached per hot node pair
  reweight_threshold_moves: 25    # slotting changes before topology delta recompute
CONFIG = {
    "pickpath": {
        "lift_penalty_s_per_m": 1.8,
        "turn_penalty_s": 2.5,
        "congestion_cap": 3.0,
        "service_time_by_class": {"A": 6.0, "B": 8.0, "C": 11.0, "D": 15.0},
    },
    "solver": {"time_limit_s": 2, "k_fallback_paths": 3, "reweight_threshold_moves": 25},
}

Parameter sensitivity worth knowing before you tune: the time_limit_s budget has sharply diminishing returns past roughly two seconds for orders under ~40 lines, but for dense batch tours it is the single biggest lever on solution quality. The service_time_by_class spread controls how aggressively the router avoids slow zones — widen the A-to-D gap and the model will detour further to keep pickers out of dead-stock reserve; narrow it and tours get shorter but drag pickers through Class-D faces. Re-derive the spread whenever the SKU Velocity Taxonomy Design tier boundaries move.

Validation & Testing

Routing bugs are quiet — a wrong tour still looks like a tour. Assert the properties that must hold: symmetry breaks in a directed graph are expected, but a tour must visit every pick exactly once, must start and end at the depot semantics you intend, and must never beat the theoretical lower bound. Run these as pytest checks against a small fixed fixture where the optimum is known by hand.

def test_tour_visits_every_pick_once():
    picks = ["DEPOT", "AA-001", "AA-002", "BB-010"]
    matrix = {(a, b): 5.0 for a in picks for b in picks if a != b}
    tour = solve_tour(picks, matrix, depot=0, time_limit_s=1)
    assert sorted(tour) == sorted(picks), "tour must be a permutation of the pick set"
    assert tour[0] == "DEPOT", "tour must start at the depot node"


def test_weights_are_travel_time_not_distance():
    cfg = EdgeWeightConfig()
    nodes = [
        PickNode("AA-001", "AA", 1, 0, 0.0, 0.0, 0.0, "PICK", "A"),
        PickNode("AA-002", "AA", 2, 3, 0.0, 0.0, 3.0, "PICK", "D"),  # 3 levels up
    ]
    g = build_graph(nodes, [("AA-001", "AA-002")], cfg)
    apply_dynamic_weights(g, cfg, congestion={"AA-002": 2.0})
    w = g["AA-001"]["AA-002"]["time"]
    # base = |z|*lift = 3*1.8 = 5.4; *congestion 2.0 = 10.8; + service(D)=15 -> 25.8
    assert abs(w - 25.8) < 1e-6, f"expected composite weight 25.8, got {w}"


def test_unreachable_pairs_are_flagged(caplog):
    g = nx.DiGraph()
    g.add_edge("A", "B", time=1.0)  # no edge back to A -> A unreachable from B
    matrix = distance_matrix(g, ["A", "B"])
    assert matrix[("B", "A")] == float("inf")

Integration Points

A pick path model is a measurement instrument for the rest of the slotting system, not a standalone product. Its outputs and inputs wire directly into sibling components:

For a full ground-up build of the graph, weights, and solver in one walkthrough, follow Building a Pick Path Model from Scratch.

Failure Modes & Edge Cases

  • Weights encode distance, not time. If edge weights are Euclidean metres, the solver ignores lift time, equipment speed, and congestion and produces “optimal” tours that are slow on the floor. Remediation: assert the composite time attribute is set on every edge before solving (see the weight test above).
  • Unreachable pick pairs from directed edges. One-way aisles or an over-tight access-zone filter can leave a pick with no inbound path, yielding inf matrix entries the solver silently routes around or fails on. Remediation: flag inf pairs at matrix build time and alert if any pair in a live order is unreachable.
  • Stale congestion telemetry pinned at the cap. A dead telemetry feed leaves multipliers stuck high, and the router permanently avoids a now-clear aisle. Remediation: expire congestion values older than one wave back to 1.0 and treat cap-pinned edges as a fallback trigger, not a permanent cost.
  • Topology recompute during peak. Recomputing the full distance matrix on every slotting change starves the solver of its latency budget mid-shift. Remediation: gate recompute behind reweight_threshold_moves and apply delta updates to cached shortest paths instead of full recomputation.
  • Solver time-limit starvation on dense batches. A fixed two-second budget that is fine for single orders returns poor tours for large batch waves. Remediation: scale time_limit_s with line count and fall back to the input order (logged) rather than returning nothing.

FAQ

Do I need OR-Tools, or is Dijkstra enough for pick paths?

Dijkstra (or A*) answers point-to-point questions and builds the pairwise distance matrix, but it does not order a multi-pick visit. The moment an order has more than a few lines, sequencing becomes a TSP/VRP problem, and greedy nearest-neighbour over the matrix typically leaves 15–25% extra travel versus a proper solver. Use Dijkstra to build the matrix, then a TSP/VRP solver to order the tour.

Why weight edges by time instead of physical distance?

Because pickers are paid in time, not metres. A short reach into a congested golden-zone face during peak can cost more elapsed time than a longer walk down an empty aisle, and vertical travel, equipment speed, and dwell all vary independently of distance. Encoding the composite travel time is what makes an “optimal” tour actually fast on the floor.

How do I keep routing fast during a peak picking wave?

Cache the base topology and the pairwise matrix, and recompute only when accumulated slotting changes exceed reweight_threshold_moves — apply delta updates the rest of the time. Bound the per-order solver budget with time_limit_s, precompute k-shortest fallback paths for hot node pairs, and expire stale congestion values so the router is never blocked waiting on a recompute.

How does the pick path model connect to slotting decisions?

It is the cost function the assignment layer optimises against. When a candidate move is proposed, the model returns the marginal travel-time change across representative waves; the move is accepted only if that delta is favourable under the facility’s weight, volume, and access constraints. Slotting without a routing model has no objective measure of whether a move actually helps.

What is the safe way to roll a new routing model into live operations?

Run it in shadow mode: generate tours in parallel with the existing WMS logic without executing physical moves, and diff the two. Once the model’s suggested travel time beats or matches incumbent logic across every velocity band for several business days, promote it to advisory (planners approve) and only then to active control, behind a circuit breaker that halts on anomalous relocation volume.