The join between a routing engine and a VRP solver is three lines of code and a surprising number of ways to be wrong. The engine returns floating-point durations in a JSON array; the solver wants integer seconds in a square matrix indexed consistently with your manifest. This page covers that conversion carefully, as part of vehicle routing problem solvers and fleet dispatch within Routing API Automation & Fleet Integration, and it assumes the matrix itself was produced by the pipeline in async route matrix automation.
Every failure in this conversion produces a solver that runs happily and returns a plan. None of them raise.
When to use this approach
This conversion is needed whenever a solver consumes engine output, but the checks below earn their keep specifically when:
- The matrix was assembled from multiple requests. Chunked matrices have gaps by construction, and a gap that reaches the solver as a zero is worse than one that reaches it as a failure.
- The location set includes points that may not be reachable. Islands, gated sites, and coordinates that snapped to a pedestrian-only way all produce unreachable pairs in an otherwise healthy matrix.
- More than one vehicle is involved. Solver index space diverges from location index space as soon as vehicles have their own start and end nodes, and the divergence is invisible on single-vehicle tests.
- Arrival times are contractual. Rounding that is harmless for route ordering is not harmless when a window is fifteen minutes wide.
Implementation
Build the matrix and the manifest from one ordered list of locations so they cannot drift apart.
# requires: numpy (pip install numpy)
import numpy as np
UNREACHABLE = 10 ** 7 # ~116 days in seconds: prohibitive, not overflowing
def to_solver_matrix(durations: list[list[float | None]]) -> np.ndarray:
"""Engine response -> square int32 seconds with unreachable pairs marked."""
arr = np.array(
[[UNREACHABLE if v is None else v for v in row] for row in durations],
dtype="float64",
)
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
raise ValueError(f"matrix must be square, got {arr.shape}")
if np.isnan(arr).any():
raise ValueError("matrix contains NaN — a chunk failed and was not retried")
# Round once, explicitly, rather than letting the solver truncate
out = np.rint(arr).astype("int32")
np.fill_diagonal(out, 0)
if (out < 0).any():
raise ValueError("negative durations — check for a sentinel left unmapped")
return out
np.rint rounds to nearest rather than truncating, which halves the expected drift compared with an implicit int() conversion. Filling the diagonal with zero is not cosmetic: some engines return a small non-zero self-duration, and the solver will use it.
The UNREACHABLE constant deserves its magnitude. It must exceed any plausible route cost so the solver never buys it, and stay far below int32 limits so accumulating a few dozen of them cannot overflow. Ten million seconds satisfies both comfortably.
Then the index translation, which is where multi-vehicle models break:
# requires: ortools, numpy (pip install ortools numpy)
from ortools.constraint_solver import pywrapcp
def make_transit_callback(routing, manager, matrix):
"""Arc cost callback with the translation that multi-vehicle models require."""
def transit(from_index: int, to_index: int) -> int:
# Solver index != location index once vehicles have their own endpoints
i = manager.IndexToNode(from_index)
j = manager.IndexToNode(to_index)
return int(matrix[i][j])
return routing.RegisterTransitCallback(transit)
Test this with two vehicles, never with one. On a single-vehicle model the two index spaces coincide and a callback that omits the translation passes every test you are likely to write.
One more property is worth asserting explicitly: the matrix and the manifest must be built from a single ordered list of locations. It is common for the manifest to come from a database query and the matrix from a separate request built by iterating a dictionary, and dictionaries preserve insertion order while database results are ordered by whatever the query said. The two agree until someone adds an ORDER BY, at which point the matrix silently describes a different set of stops than the demands and windows do. Build both from one list, pass that list around, and the failure becomes impossible rather than unlikely.
Key parameters and tuning
| Parameter | Recommended value | Notes |
|---|---|---|
| Matrix dtype | int32 |
int64 wastes memory; float invites silent truncation |
UNREACHABLE |
10**7 seconds |
Prohibitive but far from int32 overflow when accumulated |
| Rounding | np.rint |
Round-to-nearest halves expected drift versus truncation |
| Diagonal | forced to 0 | Some engines return non-zero self-durations and the solver uses them |
| Depot index | 0, by convention | Any index works provided the manifest and matrix agree |
| Missing-cell policy | raise | A gap means a chunk failed; solving around it hides the failure |
| Symmetry check | log, do not enforce | Real networks are asymmetric; a perfectly symmetric matrix is suspicious |
Integration points
From the matrix pipeline. The chunked assembly in chunking large origin-destination matrices should hand over a fully populated array plus the block success map. Convert only after confirming the map is complete — a matrix with holes is not ready for a solver regardless of how the holes are filled.
Into the dimensions. The same matrix backs both the arc-cost evaluator and the time dimension’s transit callback, but the time callback adds service time while the cost callback does not. Keeping them as two separate callbacks over one matrix avoids the common error of adding service time to the objective twice.
Into validation. The audit in vehicle routing problem solvers and fleet dispatch re-derives arrival times from this same array. If the audit and the solver disagree, the callback and the audit are indexing differently — which is exactly the bug worth catching.
Validation checklist
- The array is square,
int32, and non-negative. Assert all three at the conversion boundary rather than discovering them in solver behaviour. - The diagonal is exactly zero. A non-zero self-duration is a real value some engines return, and the solver will spend it.
- No cell equals a sentinel from the engine. Check for the engine’s own null encoding as well as
None; some return-1or0for unreachable. - A two-vehicle smoke test produces sane costs. Compare the solver’s reported route cost against a manual sum over the same matrix. Any difference is an indexing bug.
- Asymmetry is present and plausible. Log the mean absolute difference between the matrix and its transpose. Zero means something collapsed the directions; a very large value means a chunk was written transposed.
Related
- Vehicle routing problem solvers and fleet dispatch — the model this matrix feeds, and the dimensions built on top of it
- Chunking large origin-destination matrices — assembling the matrix, including the block success map this conversion depends on
- Modelling time windows in OR-Tools VRP — where rounding drift turns into a missed window
- Async route matrix automation — the concurrency and retry layer that produces the engine response