Adding Truck Restrictions to an OSM Extract for Routing
The coverage audit in map data and graph preparation usually delivers the same uncomfortable finding: weight and height tags cover most of the motorway network and almost none of the residential and service roads. Those are exactly the roads where a delivery truck meets a low bridge or a 7.5-tonne limit, so the profile is confident where it is safe and silent where it is not.
Meanwhile, the fleet already knows. Drivers report the bridge; operations maintain a list of sites with access restrictions; the incident log records every vehicle that had to reverse out of a street. That knowledge lives in spreadsheets and dispatcher memory, and this page is about getting it into the graph — without entangling it with the upstream data in a way that makes the next rebuild impossible.
Compatibility and Configuration Requirements
| Requirement | Value | Notes |
|---|---|---|
osmium-tool |
≥ 1.16 | apply-changes with a generated .osc change file |
| Overlay store | A table, not a file | It changes weekly and needs history |
| Key per record | OSM way id plus a geometry | Way IDs change when an editor splits a road |
| Tag vocabulary | Standard OSM keys | maxweight, maxheight, hgv, access — invented keys are ignored by every profile |
| Provenance | Source and date on every record | Overlay records outlive the people who added them |
Use the standard OSM tag keys even though the data is private. Every routing profile already reads
them, and inventing fleet:max_weight means writing and maintaining a profile patch as well.
Building the Overlay
Keep the overlay as a table with one row per restriction, resolved to a way and carrying its provenance:
from __future__ import annotations
import geopandas as gpd
import polars as pl
OVERLAY_SCHEMA = {
"way_id": pl.Int64, # resolved OSM way, re-checked every rebuild
"geometry_wkt": pl.Utf8, # fallback for re-resolution after a way split
"tag_key": pl.Utf8, # maxheight, maxweight, hgv, access
"tag_value": pl.Utf8,
"source": pl.Utf8, # "driver_report", "site_survey", "incident"
"recorded_on": pl.Date,
"public": pl.Boolean, # true if it belongs upstream in OSM
}
def resolve_ways(overlay: gpd.GeoDataFrame, ways: gpd.GeoDataFrame, tol_m: float = 8.0) -> gpd.GeoDataFrame:
"""Re-resolve each overlay record to the nearest current way within tolerance."""
joined = gpd.sjoin_nearest(
overlay.to_crs(ways.crs), ways[["way_id", "geometry"]],
max_distance=tol_m, how="left", distance_col="dist_m",
)
unresolved = joined["way_id_right"].isna().sum()
if unresolved:
raise RuntimeError(f"{unresolved} overlay records no longer resolve to a way — review before building")
return joined
Re-resolving on every rebuild rather than trusting the stored ID is the detail that keeps the overlay alive. Editors split ways constantly — adding a bus lane, correcting a junction — and a split creates new IDs for both halves. An overlay keyed only on the ID silently loses a few percent of its records per year, and nobody notices because the failure is a restriction that stops being applied.
Applying the Overlay
Emit an OSM change file and apply it to the clipped extract. Keeping the overlay as a change file rather than editing the PBF in place means the pipeline can always answer “what did we add?” by diffing two artefacts.
def write_change_file(resolved: gpd.GeoDataFrame, path: str) -> None:
"""Emit a minimal .osc adding tags to existing ways."""
parts = ['<?xml version="1.0" encoding="UTF-8"?>', '<osmChange version="0.6">', " <modify>"]
for way_id, group in resolved.groupby("way_id"):
tags = "".join(
f' <tag k="{r.tag_key}" v="{r.tag_value}"/>\n' for r in group.itertuples()
)
parts.append(f' <way id="{way_id}" version="99">\n{tags} </way>')
parts += [" </modify>", "</osmChange>"]
open(path, "w", encoding="utf-8").write("\n".join(parts))
osmium apply-changes region-routing.osm.pbf fleet-overlay.osc \
--overwrite --output region-routing-overlaid.osm.pbf
Two artefacts, one derived from upstream alone and one with local knowledge applied. Build the graph from the second and keep the first, so that a question about whether a restriction came from OpenStreetMap or from your own incident log has an answer that does not depend on memory.
Execution and Tuning Guidelines
Separate the public facts from the private ones. A signed 3.5-tonne limit is a public fact and
belongs in OpenStreetMap, where every rebuild will pick it up without an overlay entry. A customer’s
yard rule, a bridge your specific vehicles cannot clear because of a roof-mounted unit, or a street
your operations team has decided to avoid after a complaint — none of those are public facts. The
public flag in the schema exists to drive that triage, and working the flagged queue is how the
overlay stays small.
Treat missing as unknown, not as permitted. Where the map has no maxheight at all, the profile
should record that the decision rested on absent data rather than assuming clearance. That flag is
what lets a planning system route a 4.2-metre vehicle conservatively while a van ignores it.
Feed incidents back automatically. Every time a vehicle is recorded reversing out of a street, or a driver reports a low bridge, that is an overlay candidate. A pipeline that turns incidents into candidate records — for a human to confirm — is the difference between an overlay that grows and one that was populated once during a project and has decayed since.
Common Pitfalls Specific to This Technique
Baking the overlay into the archived extract. Once local edits are merged into the stored PBF, the provenance of every tag is lost and a later question — “is this limit from OSM or from us?” — becomes unanswerable. Keep the change file.
Applying restrictions that only some vehicles face. A 3.5-metre clearance stops a box truck and not a van. Overlay records that encode a vehicle-specific limitation as a universal one make the router refuse legal routes for half the fleet. Where a restriction is fleet-subset-specific, express it in the profile rather than in the map.
Letting the overlay drift out of review. Restrictions expire: bridges are rebuilt, one-way systems are reversed, customers change their site rules. Put a review date on every record and re-confirm the oldest ones each quarter, or the overlay becomes a list of things that used to be true.
Measuring Whether the Overlay Is Working
An overlay is a maintenance commitment, so it should be able to show that it earns its keep. Three measures do that, and all three come from data the fleet already collects.
Incidents on overlaid ways. If the overlay is doing its job, vehicles should stop encountering the restrictions it encodes. A count of incidents that occurred on a way already carrying an overlay record is the sharpest possible signal: a non-zero number means the record exists but the router is not honouring it, which is a profile problem rather than a data problem.
Route changes attributable to the overlay. Build the graph twice, with and without the overlay, route the same set of jobs through both, and count how many routes differ. A large number means the overlay is materially shaping operations and deserves its review cadence; a number near zero means the restrictions it encodes are ones the router was already avoiding.
Record age. The median age of an overlay record is a direct measure of whether anybody is reviewing it. A median that climbs steadily past a year says the overlay has become an archive.
def overlay_effect(jobs, graph_with, graph_without) -> dict:
changed = sum(
route(graph_with, j).edge_ids != route(graph_without, j).edge_ids
for j in jobs
)
return {"jobs": len(jobs), "routes_changed": changed, "share": changed / len(jobs)}
Retiring records
Restrictions expire. Bridges are rebuilt with more clearance, weight limits are lifted, a customer moves site. A record that is no longer true is worse than no record, because it removes a legal route and nobody questions a restriction that has been there for years.
Give every record a review date rather than an expiry: expiry deletes silently, review prompts a human. Work the oldest quarter of the queue each quarter and the overlay stays roughly current without anybody being asked to audit the whole thing at once.
The strongest retirement signal is upstream: when OpenStreetMap gains the same tag your overlay carries, the overlay record has become redundant and can go. A weekly diff between the overlay and the freshly clipped extract finds those automatically, and it is the mechanism by which contributing upstream actually shrinks the maintenance burden rather than just being good manners.
Related
- Map data and graph preparation for fleet routing — parent topic, including the coverage audit that motivates the overlay
- Clipping OSM extracts to a fleet operating area with osmium — the extract the overlay is applied to
- Writing GraphHopper custom models for mixed fleets — expressing vehicle-specific limits in the profile instead of the map
- Valhalla and Meili map matching for telematics — truck costing options that read these tags
- Routing Engine Integration for Fleet Telematics — parent section