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.
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:
- Upstream — velocity signal. Edge service-time weights are keyed on velocity class produced by SKU Velocity Taxonomy Design, which in turn is fed by the Velocity Data Ingestion & WMS Sync Pipelines data plane. A stale WMS & ERP Polling Strategies watermark propagates straight into wrong edge weights.
- Upstream — topology. Node coordinates, aisle geometry, and one-way constraints come from Location Hierarchy Mapping; the access-zone gate in step 1 is enforced by Security & Access Boundaries for Slotting.
- Downstream — assignment scoring. The Location Assignment & ABC Classification Algorithms engine calls this model to price a candidate move: it proposes a slot, the path model returns the marginal travel-time delta across representative waves, and the move is accepted only if the delta is favourable under Weight & Volume Constraint Modeling. Co-pick tours are shortened further when the model respects Family & Affinity Grouping, because affine SKUs already sit within a tight sub-tour.
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
timeattribute 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
infmatrix entries the solver silently routes around or fails on. Remediation: flaginfpairs 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_movesand 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_swith 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.
Related
- Building a Pick Path Model from Scratch — the full ground-up implementation walkthrough.
- SKU Velocity Taxonomy Design — the velocity classes that weight every edge.
- Location Hierarchy Mapping — the geometry and one-way constraints the graph is built from.
- Security & Access Boundaries for Slotting — the access-zone gate applied during node ingestion.
- Weight & Volume Constraint Modeling — the constraints a routed move must satisfy before assignment.
- Core Slotting Architecture & Velocity Taxonomies — the parent architecture this routing framework serves.