How to Build a Travel-Time Distance Matrix for a Warehouse
You need to score thousands of candidate pick routes per wave, and computing a shortest path over the aisle graph on every scoring call is far too slow. The fix is to pay the graph-search cost once: precompute an all-pairs travel-time matrix over the warehouse aisle graph, cache it keyed to the layout, and reduce every later route score to a dictionary lookup and a sum. This page shows exactly how to build that matrix with networkx, convert metres to seconds, and persist it. It is the concrete implementation behind the Travel-Distance & Pick-Path Cost Modeling cluster, which explains why aisle-graph distance is the only defensible metric in the first place.
Prerequisites
Confirm each of these before building the matrix:
- Python 3.10+ — the implementation uses
dict[...]generics,X | Noneunions, and adataclassconfig. networkx3.0+ — for the aisle graph andfloyd_warshall_numpy/all_pairs_dijkstra_path_length.- A connected aisle graph — built and validated per the parent cluster, Travel-Distance & Pick-Path Cost Modeling; a disconnected graph yields
infcells. - A calibrated walk speed —
walk_speed_mpsregressed from timestamped pick confirmations for this facility, not a vendor default. - A writable cache path — a local directory or object-store prefix to persist the matrix, plus a topology hash so a stale cache is never reused after a rack move.
Configuration Block
Everything tunable lives in one externalized profile. The topology_hash is the cache key: change the racking and the hash changes, forcing a rebuild.
# travel_matrix.yaml — one profile per facility
walk_speed_mps: 1.2 # calibrated loaded-cart speed
turn_penalty_s: 4.0 # seconds added per aisle change
algorithm: "floyd_warshall" # floyd_warshall | dijkstra
pick_faces_only: true # restrict all-pairs to pick nodes, not junctions
cache_path: "cache/travel_time.json"
topology_hash: "layout-2026-07-a1" # bump on any rack move to invalidate cache
# Equivalent Python config dict consumed by the builder
TRAVEL_MATRIX = {
"walk_speed_mps": 1.2,
"turn_penalty_s": 4.0,
"algorithm": "floyd_warshall",
"pick_faces_only": True,
"cache_path": "cache/travel_time.json",
"topology_hash": "layout-2026-07-a1",
}
Implementation
The builder runs all-pairs shortest paths over the aisle graph, converts each distance to seconds, and writes the result to a cache keyed by the topology hash. On a warm cache with a matching hash it loads instead of recomputing, so the expensive graph search happens once per layout, not once per wave.
from __future__ import annotations
import json
import logging
from pathlib import Path
import networkx as nx
logger = logging.getLogger("pickpath.matrix")
TravelMatrix = dict[str, dict[str, float]]
def build_travel_time_matrix(g: nx.Graph, cfg: dict) -> TravelMatrix:
"""Precompute an all-pairs travel-time matrix (seconds) over the aisle graph.
Loads from cache when the stored topology hash matches; otherwise runs
all-pairs shortest paths, converts metres to seconds via walk speed, and
persists the result. Returned as a nested dict: matrix[a][b] = seconds.
"""
cache = Path(cfg["cache_path"])
if cache.exists():
stored = json.loads(cache.read_text())
if stored.get("topology_hash") == cfg["topology_hash"]:
logger.info("Cache hit (%s); loaded %d source nodes",
cfg["topology_hash"], len(stored["matrix"]))
return stored["matrix"]
logger.warning("Cache stale: %s != %s; rebuilding",
stored.get("topology_hash"), cfg["topology_hash"])
faces = [n for n, d in g.nodes(data=True)
if not cfg["pick_faces_only"] or d.get("kind") == "pick"]
speed = cfg["walk_speed_mps"]
matrix: TravelMatrix = {}
for src, dist_map in nx.all_pairs_dijkstra_path_length(g, weight="weight"):
if src not in faces:
continue
matrix[src] = {dst: round(dist_map[dst] / speed, 2)
for dst in faces if dst in dist_map}
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps({"topology_hash": cfg["topology_hash"], "matrix": matrix}))
logger.info("Built travel-time matrix: %d x %d faces -> %s",
len(matrix), len(faces), cache)
return matrix
def route_seconds(order: list[str], matrix: TravelMatrix) -> float:
"""Score an ordered route as a sum of cached pairwise travel times."""
return sum(matrix[order[i]][order[i + 1]] for i in range(len(order) - 1))
Step-by-Step Walkthrough
- Check the cache first. The builder reads
cache_pathand compares the storedtopology_hashagainst the configured one. A match returns the cached matrix immediately — the whole point of the exercise, since the all-pairs search is the expensive part. A mismatch logs a warning and falls through to a rebuild, so a rack move can never serve stale distances. - Restrict to pick faces. With
pick_faces_onlyset, the matrix is built only between nodes whosekindis"pick", skipping aisle-end and cross-junction nodes that no task ever targets. On a graph with many junctions this shrinks the matrix quadratically and keeps the cache small. - Run all-pairs shortest paths.
nx.all_pairs_dijkstra_path_lengthyields, for each source, a map of shortest walking distances in metres. For dense graphs where you want a single dense array, swap infloyd_warshall_numpy; thealgorithmconfig key records which you used. - Convert metres to seconds. Each distance is divided by the calibrated
walk_speed_mpsand rounded, turning the physical matrix into the labour-time matrix slotting actually reasons about. Turn penalties are applied at route-scoring time, since they depend on the visit order, not the pair. - Persist and reuse. The matrix is written with its topology hash so the next process loads instead of recomputing.
route_secondsthen scores any candidate route as a sum of lookups — cheap enough to evaluate thousands of batchings per wave.
Verification
Assert the matrix invariants directly: the diagonal is zero, the matrix is symmetric over an undirected graph, and a known route sums to the expected time. These run without a live WMS.
import logging
logging.basicConfig(level=logging.INFO)
# Toy graph: A --2m-- J(top) --6m-- K(top) --3m-- B, walk speed 1.0 m/s
g = nx.Graph()
for n in ["A", "J", "K", "B"]:
g.add_node(n, kind="pick" if n in ("A", "B") else "aisle_end")
g.add_edge("A", "J", weight=2.0)
g.add_edge("J", "K", weight=6.0)
g.add_edge("K", "B", weight=3.0)
cfg = {"walk_speed_mps": 1.0, "turn_penalty_s": 0.0, "algorithm": "dijkstra",
"pick_faces_only": True, "cache_path": "cache/test_tt.json",
"topology_hash": "toy-1"}
m = build_travel_time_matrix(g, cfg)
assert m["A"]["A"] == 0.0 # zero diagonal
assert m["A"]["B"] == m["B"]["A"] == 11.0 # symmetric, 2 + 6 + 3 metres / 1.0
assert route_seconds(["A", "B"], m) == 11.0
print("OK — travel-time matrix verified:", m["A"])
Sample expected output:
INFO:pickpath.matrix:Built travel-time matrix: 2 x 2 faces -> cache/test_tt.json
OK — travel-time matrix verified: {'A': 0.0, 'B': 11.0}
Common Pitfalls
- Never invalidating the cache. A matrix cached without a topology hash silently outlives the layout that produced it, so a rack move leaves every route scored against distances that no longer exist. Always key the cache on
topology_hashand bump it on any physical change. - Including junction nodes you never pick from. Building all-pairs over every node, junctions included, bloats the matrix quadratically and slows both the build and the cache load. Set
pick_faces_onlyunless a downstream stage genuinely routes through named junctions. - Baking turn penalties into the matrix. Turn cost depends on the order of visits, not on the pair of endpoints, so folding it into the pairwise matrix double-counts or misprices it. Keep the matrix pure distance-time and add turn penalties in the route scorer.
- An uncalibrated walk speed. Dividing by a guessed
walk_speed_mpsscales the entire matrix by a constant error that is invisible because it is uniform. Regress speed from real pick timestamps, and rebuild the matrix whenever cart type or staffing shifts it.
Related
- Travel-Distance & Pick-Path Cost Modeling — the parent guide on aisle-graph distance metrics and the labour-time cost function.
- Wave & Batch Pick Sequencing — consumes this cached matrix to score and balance thousands of candidate batchings per wave.
- Pick-Path Optimization & Space Utilization — the architecture this precomputed matrix plugs into.