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.
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.
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.
Related
- GraphHopper map matching for fleet routing — parent topic
- Batch map matching with GraphHopper in Python — running these profiles at fleet scale
- Adding truck restrictions to an OSM extract for routing — when a limit belongs in the map instead
- Multi-modal route matching for mixed fleets — matching across networks rather than across vehicle classes
- Routing Engine Integration for Fleet Telematics — parent section