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.

What a way split does to an overlay record A street carrying a 3.5 tonne overlay restriction is split by an editor into two ways with new identifiers. The overlay record keyed only on the old identifier now points at nothing. The same record keyed on geometry re-resolves to both halves and the restriction survives. Before and after an upstream way split before way 48213771 maxweight 3.5 after way 1104882301 way 1104882302 keyed on id — lost keyed on geometry — kept The lost record produces no error. The restriction simply stops applying, and the next truck finds out. Fail the build when a record cannot be re-resolved rather than dropping it — an unresolvable record needs a human.

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.

Where a restriction record comes from and where it goes Driver reports and incident records become candidate restrictions. A triage step splits them: publicly verifiable facts are contributed upstream to OpenStreetMap and arrive back in the next rebuild, while private facts stay in the local overlay and are reapplied at each build. One loop, two destinations incidents & driver reports triage public fact? yes no contribute upstream arrives free in the next rebuild local overlay reapplied at every build The upper path shrinks the overlay over time; the lower path is the part only you can maintain. An overlay that only ever grows is a triage step nobody is doing.

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.

An overlay that shrinks is an overlay being maintained Overlay record count over eight quarters. New records are added steadily from incidents and driver reports, while records made redundant by upstream OpenStreetMap tagging are retired. The total plateaus rather than growing, which is what a working contribute-back loop looks like. Overlay records, eight quarters 800 400 0 total in force — plateaus retired as upstream caught up Q1 Q8 New records keep arriving; the plateau means retirement is keeping pace, which is the only sustainable state. An overlay that only grows is one where the triage and review steps have quietly stopped happening.