Slotting Architecture · 13 min read

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.

Building a pick path model: two feeds converge into a weighted graph, then a solver emits an ordered tour The SKU velocity taxonomy feed (velocity class per location) and the location-hierarchy feed (node geometry and access zones) both flow into a graph-build stage that produces a weighted directed graph. Each edge weight is computed in seconds as distance divided by equipment speed, plus congestion factor times the per-class velocity penalty, plus dwell time. That graph feeds a nearest-neighbour A-star solver, which emits an ordered pick sequence that is pushed back to the WMS pick wave. Two immutable feeds → weighted directed graph → solver → ordered tour Velocity Taxonomy Feed velocity class A/B/C/D per location rolling 90-day pick frequency Location Hierarchy Feed aisle / bay / level · access zone node geometry · travel-time source immutable inputs Weighted Directed Graph A B C A edge weight (seconds) distance / speed + congestion × penalty[class] + dwell_time Nearest-Neighbour A* Solver admissible heuristic · min-cost tour Ordered Pick Sequence pushed to WMS wave

Prerequisites

Before you write a line of routing code, confirm the following are in place:

  • Location master with location_id, aisle, bay, level, and access_zone for 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.0 and numpy>=1.24 optional for the ingestion frame; ortools>=9.8 optional 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

  1. Precompute edge weights, not the graph geometry. Before instantiating PickPathRouter, walk every legal adjacency and apply the composite formula using equipment_speed_mps, congestion_factor, velocity_penalty[class], and the matching dwell_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.
  2. Close restricted and dead-stock edges at build time. Where access_zone is restricted or velocity_class == 'D', either omit the edge or set it to the D penalty (999.0). Filtering here — rather than inside the solver — keeps the hot path branch-free.
  3. Instantiate with the config dict. PickPathRouter(graph, locations, PICK_PATH_CONFIG) binds the tunables; the _heuristic reads bay_length_m and equipment_speed_mps so the estimate stays admissible (never overestimates true travel time), which is what guarantees A* returns the optimal path.
  4. 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. The min_edge_weight floor prevents zero-weight cycles from trapping the frontier.
  5. 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-D edges were never closed. Apply access_zone filters during graph construction and keep the D penalty 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_ms circuit breaker to fall back to a velocity-agnostic shortest path.
  • Zero-weight cycles trap the solver. Bidirectional edges with a 0.0 weight let the frontier loop forever. Enforce min_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 A penalty pulls every wave through the same dense faces. Reduce the magnitude and let a live congestion_factor from 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.