Building a Pick Path Model from Scratch
You have a WMS location export, a velocity table, and a picker workforce walking 30% further than they should because the routing engine treats the floor as a uniform grid. This page walks the exact build: turn those two feeds into a weighted directed graph, weight every edge by travel time rather than distance, and solve for an ordered pick sequence a solver can score before any slot move is authorized. It is part of the Pick Path Modeling Frameworks system, which sits under the broader Core Slotting Architecture & Velocity Taxonomies architecture — the routing model here is what the assignment layer queries to measure whether a candidate slot actually reduces walk time.
Static, distance-only paths degrade throughput by 15–30% within a quarter of go-live because the two inputs that dominate travel cost — SKU velocity and physical topology — are never static. This guide keeps the model deterministic and adaptable to both.
Prerequisites
Before you write a line of routing code, confirm the following are in place:
- Location master with
location_id,aisle,bay,level, andaccess_zonefor every pickable position, sourced from the Location Hierarchy Mapping layer. Nodes missing parent aisle/bay mapping must be rejected, not defaulted. - Velocity class per location (
A/B/C/D) derived from the SKU Velocity Taxonomy Design layer, normalized to a rolling 90-day pick-frequency window so a seasonal spike does not permanently promote a bin. - Access-boundary map so restricted aisles (cold rooms, hazmat, mezzanine cages) can be closed to traversal — see Security & Access Boundaries for Slotting.
- Python 3.11+ with the standard library only for the core solver (
heapq,dataclasses,logging);pandas>=2.0andnumpy>=1.24optional for the ingestion frame;ortools>=9.8optional for exact TSP post-processing. - A distance or travel-time source between adjacent nodes: measured aisle geometry, or a facility CAD export converted to metres.
Configuration Block
Externalize every tunable so you can recalibrate weights without a redeploy. Below is the YAML config and its exact Python dict equivalent — the solver reads the dict, and the YAML is what you version-control per facility.
pick_path_config:
equipment_speed_mps: 1.5 # average traverse speed, metres/second
bay_length_m: 1.2 # physical length of one bay, for the heuristic
congestion_factor: 0.30 # 0..1 multiplier on the velocity penalty
velocity_penalty: # per-class edge adjustment (seconds)
A: -4.0 # reward routing through dense pick corridors
B: 0.0
C: 2.0
D: 999.0 # effectively closes dead-stock traversal edges
dwell_time_s:
each_pick: 8.0
case_pick: 14.0
min_edge_weight: 0.01 # floor to prevent zero-weight cycles
latency_budget_ms: 500 # per-batch circuit-breaker threshold
PICK_PATH_CONFIG = {
"equipment_speed_mps": 1.5,
"bay_length_m": 1.2,
"congestion_factor": 0.30,
"velocity_penalty": {"A": -4.0, "B": 0.0, "C": 2.0, "D": 999.0},
"dwell_time_s": {"each_pick": 8.0, "case_pick": 14.0},
"min_edge_weight": 0.01,
"latency_budget_ms": 500,
}
The velocity_penalty scales inversely with turnover: class A gets a negative value (a reward) so tours prefer dense pick corridors, while class D gets a large positive value that effectively removes dead-stock aisles from traversal unless batch logic forces retrieval. congestion_factor mixes real-time WMS/WCS telemetry (or historical shift averages) into that penalty.
Implementation
The core is a lean, type-hinted A* solver over a sparse adjacency dict — no heavyweight graph library in the latency-sensitive routing path. Edge weights are precomputed with the composite formula weight = (distance / equipment_speed) + (congestion_factor * velocity_penalty) + dwell_time; the solver below consumes that graph and returns an ordered sequence via nearest-neighbour A*.
import heapq
import logging
import math
from dataclasses import dataclass
from typing import Dict, List, Tuple
logger = logging.getLogger("pick_path")
@dataclass(frozen=True)
class LocationNode:
node_id: str
aisle: str
bay: int
level: int
velocity_class: str # 'A', 'B', 'C', 'D'
class PickPathRouter:
def __init__(
self,
graph: Dict[str, Dict[str, float]],
locations: Dict[str, LocationNode],
config: dict,
) -> None:
"""Velocity-weighted router over a sparse adjacency graph.
graph: {node_id: {neighbor_id: precomputed_edge_weight_seconds}}
locations: node metadata lookup for the heuristic
config: PICK_PATH_CONFIG dict of tunable parameters
"""
self.graph = graph
self.locations = locations
self.config = config
def _heuristic(self, u: str, v: str) -> float:
"""Admissible Manhattan estimate: bay distance / traverse speed."""
loc_u, loc_v = self.locations[u], self.locations[v]
bay_diff = abs(loc_u.bay - loc_v.bay)
metres = bay_diff * self.config["bay_length_m"]
return metres / self.config["equipment_speed_mps"]
def _a_star_cost(self, start: str, goal: str) -> float:
"""A* over travel-time edges; returns minimal path cost in seconds."""
open_set: List[Tuple[float, str]] = [(0.0, start)]
g_score: Dict[str, float] = {start: 0.0}
while open_set:
_, current = heapq.heappop(open_set)
if current == goal:
return g_score[current]
for neighbor, edge_weight in self.graph.get(current, {}).items():
tentative_g = g_score[current] + edge_weight
if tentative_g < g_score.get(neighbor, math.inf):
g_score[neighbor] = tentative_g
f = tentative_g + self._heuristic(neighbor, goal)
heapq.heappush(open_set, (f, neighbor))
return math.inf
def solve(self, start: str, targets: List[str]) -> Tuple[List[str], float]:
"""Ordered nearest-neighbour A* tour visiting every target once."""
if not targets:
return [start], 0.0
path, current, total_cost = [start], start, 0.0
remaining = set(targets)
while remaining:
best_next, best_cost = None, math.inf
for target in remaining:
cost = self._a_star_cost(current, target)
if cost < best_cost:
best_cost, best_next = cost, target
if best_next is None:
logger.warning("Unreachable targets remain: %s", remaining)
break
path.append(best_next)
total_cost += best_cost
current = best_next
remaining.discard(best_next)
logger.info("Solved tour of %d picks, cost=%.1fs", len(path) - 1, total_cost)
return path, total_cost
Step-by-Step Walkthrough
- Precompute edge weights, not the graph geometry. Before instantiating
PickPathRouter, walk every legal adjacency and apply the composite formula usingequipment_speed_mps,congestion_factor,velocity_penalty[class], and the matchingdwell_time_s. Store the result as{node_id: {neighbor_id: seconds}}. Separate traversal edges (aisle movement) from service edges (pick dwell) so travel and dwell can be tuned independently. - Close restricted and dead-stock edges at build time. Where
access_zoneis restricted orvelocity_class == 'D', either omit the edge or set it to theDpenalty (999.0). Filtering here — rather than inside the solver — keeps the hot path branch-free. - Instantiate with the config dict.
PickPathRouter(graph, locations, PICK_PATH_CONFIG)binds the tunables; the_heuristicreadsbay_length_mandequipment_speed_mpsso the estimate stays admissible (never overestimates true travel time), which is what guarantees A* returns the optimal path. - Solve per wave.
solve(start, targets)runs nearest-neighbour A* from the pack station across the batch’s pick faces, logging the tour size and total seconds. Themin_edge_weightfloor prevents zero-weight cycles from trapping the frontier. - Escalate to exact TSP only when it pays. Nearest-neighbour is within a few percent of optimal for most waves. For large batched orders where that gap matters, feed the pairwise A* cost matrix into an OR-Tools routing solve as a post-processing layer — never inline in the latency-sensitive worker.
Verification
Assert against a synthetic grid with a known optimum, and log the realized tour cost so shadow-mode runs are auditable.
import logging
logging.basicConfig(level=logging.INFO)
# 3-node straight aisle: S -> P1 (10s) -> P2 (10s)
graph = {"S": {"P1": 10.0}, "P1": {"S": 10.0, "P2": 10.0}, "P2": {"P1": 10.0}}
locations = {
"S": LocationNode("S", "A1", bay=0, level=0, velocity_class="A"),
"P1": LocationNode("P1", "A1", bay=1, level=0, velocity_class="A"),
"P2": LocationNode("P2", "A1", bay=2, level=0, velocity_class="B"),
}
router = PickPathRouter(graph, locations, PICK_PATH_CONFIG)
path, cost = router.solve("S", ["P2", "P1"])
assert path == ["S", "P1", "P2"], path # nearest-first ordering
assert cost == 20.0, cost # 10s + 10s, no backtrack
print(path, cost)
Expected output:
INFO:pick_path:Solved tour of 2 picks, cost=20.0s
['S', 'P1', 'P2'] 20.0
Before promoting the model, run it in shadow mode for 14–21 days against historical picker telemetry: compare generated tour cost to actual walk time and flag any wave diverging by more than 15%, which usually means a weight is miscalibrated rather than a picker error.
Common Pitfalls
- Tours route through dead-stock zones. The
velocity_penalty['D']was set too low or class-Dedges were never closed. Applyaccess_zonefilters during graph construction and keep theDpenalty high enough (or the edge absent) that no tour prefers it. - A* exceeds the latency budget on dense facilities. An unpruned open set or a full adjacency matrix balloons the frontier. Keep the graph sparse, precompute aisle-to-aisle macro-edges, and trip the
latency_budget_mscircuit breaker to fall back to a velocity-agnostic shortest path. - Zero-weight cycles trap the solver. Bidirectional edges with a
0.0weight let the frontier loop forever. Enforcemin_edge_weight(0.01) on every edge and validate one-way aisles as directed. - Velocity reward overshoots and jams golden-zone corridors. Too-negative an
Apenalty pulls every wave through the same dense faces. Reduce the magnitude and let a livecongestion_factorfrom WMS telemetry damp it during peak.
FAQ
Should edge weight be distance or travel time?
Always travel time. A two-metre reach into a congested golden-zone face during a peak wave can cost more elapsed seconds than a ten-metre walk down an empty reserve aisle. The composite formula converts distance to seconds via equipment_speed_mps, then adds the velocity penalty and dwell, so every edge is comparable in the same unit the picker actually experiences.
Is nearest-neighbour A* good enough, or do I need exact TSP?
For most single-picker waves, nearest-neighbour lands within a few percent of the optimal tour and runs in milliseconds. Reach for an exact Traveling Salesperson solve (via OR-Tools) only for large batched orders where that few-percent gap multiplied across a shift is worth the extra compute — and run it as a post-processing layer on the precomputed A* cost matrix, not inside the routing worker.
How do I keep the A* heuristic admissible?
The heuristic must never overestimate true remaining travel time, or A* can return a suboptimal path. The _heuristic here uses straight-line bay distance divided by the maximum traverse speed, which is a guaranteed lower bound on real time. If you add level or cross-aisle terms, keep them optimistic — assume the fastest legal move, never the average.
Where do velocity and topology come from at runtime?
Velocity classes are pulled from the SKU Velocity Taxonomy Design layer on a rolling 90-day window, and node geometry plus access zones come from Location Hierarchy Mapping. The path model consumes both as immutable inputs during traversal; it never mutates them, which is what keeps routing decoupled from the slotting engine that performs physical relocations.
How do I handle cart capacity and heavy-on-bottom precedence?
Nearest-neighbour A* solves the ordered tour; capacity and precedence are a separate constraint layer. Feed the tour through Weight & Volume Constraint Modeling to enforce cart cube/weight ceilings and crush-sensitive-last ordering, which turns the pure travel-time tour into a feasible Vehicle Routing solution.
Related
- Pick Path Modeling Frameworks — the parent framework this build implements, covering graph, edge, and solver theory in full.
- SKU Velocity Taxonomy Design — the velocity classes that drive every edge penalty here.
- Location Hierarchy Mapping — the node geometry and access zones the graph is built on.
- Weight & Volume Constraint Modeling — the capacity and precedence layer that turns a travel-time tour into a feasible VRP route.