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.
Four silent failures in one conversion A float matrix truncates and drifts arrival times. A zero for an unreachable pair invents a free leg. An infinity overflows the solver's integer arithmetic. A transposed matrix optimises the reverse problem. None of the four raises an error. Every one of these returns a plan rather than an error float durations passed through truncated per arc, drift accumulates along the route 40 arcs × 0.6 s lost = 24 s of phantom slack symptom: plans that are late only on long routes unreachable pair left as zero solver reads it as two co-located stops it will route between them for free, every time symptom: one impossible leg in an otherwise good plan unreachable pair set to infinity overflows the solver's integer accumulation costs wrap negative and the search chases them symptom: nonsensical plans, no diagnostic matrix transposed solver optimises the reverse of your problem invisible where costs are near-symmetric symptom: plans that are slightly wrong, forever

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.

Why solver indices stop matching location indices With four locations and two vehicles the solver allocates indices zero through three for the stops and then additional indices for each vehicle's start and end node. Indexing the matrix with a solver index therefore reads the wrong row from index four onward. 4 locations, 2 vehicles — the two index spaces diverge location index 0 depot 1 2 3 matrix rows are indexed this way solver index 0 1 2 3 v0 start 4 v0 end 5 v1 start From solver index 3 onward, matrix[solver_index] reads a row that belongs to a different location. IndexToNode maps the shifted space back onto the matrix rows. With one vehicle the two rows above are identical, which is why single-vehicle tests never catch this. Always write the first integration test with two vehicles.

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 -1 or 0 for 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.
Truncation drift accumulates along a route Arrival-time error grows linearly with stop count when float durations are truncated, reaching about 24 seconds by the fortieth stop. Rounding to nearest halves the slope and keeps the error unbiased. Arrival-time error against stop number 30 s 0 stop 1 stop 20 stop 40 int() truncation — biased low, always np.rint — unbiased, and half the magnitude Truncation always loses time, so the error compounds in one direction and the plan looks feasible when it is not. On a fifteen-minute delivery window, 24 seconds of phantom slack is enough to turn a comfortable plan into a missed one.