Writing GraphHopper Custom Models for Mixed Fleets

A fleet with cargo bikes, vans and articulated trucks cannot be served by one routing profile. The bike may use a cycle path the truck cannot; the truck is barred from a street the van uses daily. GraphHopper’s custom models are the mechanism for expressing that, and they are the right one — but they are usually written once, for planning, and then reused for matching, which is where the trouble starts.

The distinction this page rests on is simple. Planning is normative: it decides where a vehicle should go, and forbidding a street is a legitimate instruction. Matching is descriptive: it reconstructs where a vehicle did go, and the vehicle sometimes used that street anyway. A matcher running a strict planning model has no legal path for such a trace and fails it entirely, which shows up as an unmatched-rate problem that looks like a data quality issue.

This extends the model work in GraphHopper map matching for fleet routing.


Compatibility and Configuration Requirements

Requirement Value Notes
GraphHopper 9.x or later Custom model syntax has been stable since 8
Encoded values Enabled at import max_weight, max_height, hgv, road_class must be imported to be referenced
Profiles One per vehicle class per purpose Two purposes means two profiles per class
CH / LM Disabled for custom-model profiles that vary Contraction cannot precompute a model that changes per request
Test corpus Real traces per class The only way to know a model has not broken matching

The encoded-values requirement catches people out. A custom model that references max_weight when the graph was imported without it does not error — the value is simply absent and the expression never matches, so the model appears to do nothing.


Two Models Per Class

{
  "planning_truck_40t": {
    "priority": [
      { "if": "max_weight < 40", "multiply_by": "0" },
      { "if": "max_height < 4.0", "multiply_by": "0" },
      { "if": "road_class == RESIDENTIAL", "multiply_by": "0.3" }
    ],
    "speed": [
      { "if": "road_class == RESIDENTIAL", "limit_to": "30" },
      { "if": "true", "limit_to": "85" }
    ]
  },

  "matching_truck_40t": {
    "priority": [
      { "if": "max_weight < 40", "multiply_by": "0.05" },
      { "if": "max_height < 4.0", "multiply_by": "0.05" },
      { "if": "road_class == RESIDENTIAL", "multiply_by": "0.6" }
    ],
    "speed": [
      { "if": "road_class == RESIDENTIAL", "limit_to": "30" },
      { "if": "true", "limit_to": "85" }
    ]
  }
}

The two differ in exactly one respect and it is the important one. Planning uses multiply_by: 0, which removes the edge from the graph. Matching uses 0.05, which makes the edge twenty times less attractive without making it impossible.

That small change is what allows a matcher to reconstruct the afternoon a driver genuinely did take the restricted street — perhaps under escort, perhaps in error, perhaps because the sign is new and the map is old. The match records what happened, the restricted edge shows up in the output, and the compliance report that reads it has something to work with. Under the planning model the same trace returns no path at all.

Zero priority against small priority A truck trace passes along a street tagged with a 7.5 tonne limit. Under the planning model that edge has priority zero, so no legal path exists and the whole trace fails to match. Under the matching model the edge has priority 0.05, the path is found, and the restricted segment appears in the output where a compliance report can see it. One trace, two models planning model 7.5 t limit — priority 0 no legal path — the entire trace fails to match matching model 7.5 t limit — priority 0.05 matched, with the restricted segment flagged in the output The lower row produces a compliance finding. The upper row produces a gap that looks like a device fault. Matching should never be able to conclude that a journey which demonstrably happened was impossible.

Priority Against Speed

The two expression types do different work in the matching model, and conflating them is the second common error.

Priority reshapes the state space. It decides which edges are candidates at all and how attractive each one is. This is where physical impossibility belongs — a vehicle that cannot fit under a bridge cannot have driven under it.

Speed reshapes the transition costs. It changes how long the matcher believes a traversal takes, which affects which sequence of edges best explains the observed timings. This is where operational reality belongs — a 40-tonne truck really does move more slowly through a residential street than the map’s default suggests.

Getting them the wrong way round produces characteristic symptoms. Expressing a weight limit as a speed penalty leaves the edge fully available and merely slow, so the matcher still routes through it whenever the geometry fits. Expressing a speed difference as a priority penalty distorts which edges are considered without changing the timing model, and the matcher starts preferring geometrically worse paths.

Which expression a constraint belongs in Physical limits such as height and weight belong in priority because they decide whether an edge is possible. Operational realities such as slower travel on residential streets belong in speed because they decide how long it takes. Legal restrictions belong in priority for planning and in a small priority for matching. Driver preferences belong in neither when matching. Constraint type, and where it belongs priority speed matching value height, weight yes · 0.05, not 0 legal restriction yes · 0.05, not 0 slower on residential · yes same as planning avoid motorways · · omit entirely The last row is the trap: a preference that improves planned routes actively degrades matching. The truck used the motorway. A model that discourages motorways will find a worse explanation for the same trace.

Testing a Model Before It Ships

A custom model change is a matching change, and it should be gated the same way any other matching change is. The specific assertion that matters here is that the model has not made real journeys unmatchable.

def assert_model_matches_history(model_name: str, corpus: list, min_rate: float = 0.98) -> None:
    """A matching model must still match the class's own historical traces."""
    ok = sum(1 for t in corpus if match(t, profile=model_name).matchings)
    rate = ok / len(corpus)
    if rate < min_rate:
        raise SystemExit(
            f"{model_name} matches only {rate:.1%} of historical {model_name} traces — "
            "check for a priority of 0 on an edge the fleet actually uses"
        )

Run it per vehicle class against that class’s own traces. A model for 40-tonne trucks tested on van traces proves nothing, because the vans never went near the restricted streets.

The threshold should be high — 98 percent or better — because the failure this catches is categorical rather than gradual. A model with an accidental hard zero does not lose a few percent; it loses every trace that touches the affected edges, which is usually a whole depot’s worth.

Wire it into the same gate described in regression testing a map matcher in CI, and treat a model change with the same seriousness as a code change — because from the output’s point of view there is no difference.


Common Pitfalls Specific to This Technique

Referencing an encoded value that was not imported. The expression silently never matches, the model appears to have no effect, and the usual response is to make it more aggressive — which does nothing either.

Using one model for planning and matching. The single most common cause of an unexplained unmatched-rate spike after a fleet adds a vehicle class.

Enabling contraction hierarchies on a per-request custom model profile. Contraction precomputes against fixed weights, so a model that varies per request cannot use it. GraphHopper will tell you, but usually after somebody has spent an afternoon on the configuration.

Encoding a fleet-subset limitation as a map-wide one. A clearance that stops one vehicle type belongs in that class’s model, not in the shared graph — see adding truck restrictions.


Managing Models as the Fleet Grows

A fleet with three vehicle classes has six models once planning and matching are separated. At eight classes it has sixteen, and by then the collection needs the same discipline as code.

Compose rather than copy. Most classes share the majority of their expressions — the residential speed limit, the general road-class preferences — and only differ in a handful of physical constraints. Generating models from a shared base plus a per-class override keeps a fix in one place.

BASE_SPEED = [
    {"if": "road_class == RESIDENTIAL", "limit_to": "30"},
    {"if": "road_class == SERVICE", "limit_to": "20"},
]

def matching_model(max_weight_t: float, max_height_m: float) -> dict:
    """Permissive model for reconstructing history, generated per class."""
    return {
        "priority": [
            {"if": f"max_weight < {max_weight_t}", "multiply_by": "0.05"},
            {"if": f"max_height < {max_height_m}", "multiply_by": "0.05"},
        ],
        "speed": BASE_SPEED,
    }

Version them with the graph. A model change alters matched output exactly as a map change does, so the profile revision belongs in the graph version key described in pinning map versions. Without it, a re-match six months later can produce a different answer with no way to explain why.

Assign the class from the vehicle registry, not from the request. A caller that passes a profile name can pass the wrong one, and a van matched with the articulated model produces subtly worse results that nothing flags. Look the class up from the vehicle identifier at the edge of the system and let the caller supply only the vehicle.

When a class does not need its own model

Not every distinction justifies a model. The test is whether the class’s physical constraints change which edges it can traverse. Two van types that differ in payload but not in dimensions traverse the same network, and giving them separate models adds maintenance for no behavioural difference.

Merge classes whose matching models would be identical, and keep the distinction in the vehicle registry where it belongs. A collection of sixteen models where six are byte-identical is a collection that will drift.

Models to maintain as classes are added Under copy-per-class, adding vehicle classes doubles the number of independently maintained model documents, reaching sixteen at eight classes. Generating models from a shared base plus a per-class override keeps the maintained surface at one base plus a short table of constraints. Documents to keep correct as the fleet grows 16 8 0 copy per class generated from one base 2 classes 4 8 classes Sixteen hand-maintained documents where six are byte-identical is a collection that will drift. Generate them, and a fix to the residential speed rule lands in every class at once.