Handling Bridges, Tunnels and Level Crossings
Coordinate snapping works because two endpoints within a tolerance of each other are almost always the same junction. Almost. The exception is grade separation: a bridge deck and the road beneath it share a coordinate to within centimetres and are not connected at all, and a snapping pass that does not know this welds them into one node. The graph then contains a junction that lets traffic turn off a motorway onto the canal towpath crossing under it, and the router will use it — it is the shortest path, and nothing in the data says it is impossible. This page adds the third dimension that separates them, using the tag the source already carries.
Prerequisites & Versions
The layer information comes from OSM tags; the snapping is the same grid used elsewhere.
| Requirement | Minimum version | Install |
|---|---|---|
| Python | 3.11 | — |
| neo4j (async driver) | 5.20 | pip install "neo4j>=5.20" |
| Neo4j Server | 5.15 | native point |
Implementation
The snap key gains a layer component, so two endpoints only merge when they are close in space and on the same level.
import math
from dataclasses import dataclass
EARTH_R = 6_371_008.8
@dataclass(frozen=True)
class Endpoint:
lat: float
lon: float
layer: int # OSM `layer`: 0 at grade, +1 a bridge, -1 a tunnel
way_id: str
is_structure: bool # carries a bridge or tunnel tag
@dataclass(frozen=True)
class SnapKey:
cell_x: int
cell_y: int
layer: int # part of the key — this is the whole fix
class LayeredSnapGrid:
"""Snapping that refuses to merge across grade separation.
A plain 2-D grid merges anything within the tolerance, which is correct for
two ends of the same junction and catastrophic for a bridge deck and the
road under it: they are within centimetres horizontally and are not
connected in any sense a vehicle can use.
"""
def __init__(self, tol_m: float = 1.5) -> None:
self._tol_m = tol_m
self._deg = math.degrees(tol_m / EARTH_R)
def key(self, e: Endpoint) -> SnapKey:
cos_lat = max(math.cos(math.radians(e.lat)), 1e-6)
return SnapKey(
cell_x=math.floor(e.lon / (self._deg / cos_lat)),
cell_y=math.floor(e.lat / self._deg),
layer=e.layer,
)
def may_merge(self, a: Endpoint, b: Endpoint) -> bool:
"""Two endpoints merge only when they agree on the level.
The layer comparison is exact rather than approximate on purpose. A
bridge at layer 1 and a road at layer 0 are never the same junction,
however close their coordinates are — there is no tolerance at which
that becomes true.
"""
if a.layer != b.layer:
return False
return self._ground_distance(a, b) <= self._tol_m
def _ground_distance(self, a: Endpoint, b: Endpoint) -> float:
p1, p2 = math.radians(a.lat), math.radians(b.lat)
dp, dl = p2 - p1, math.radians(b.lon - a.lon)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * EARTH_R * math.asin(math.sqrt(h))
def layer_of(tags: dict[str, str]) -> int:
"""Derive the level from the tags, defaulting to grade.
`layer` is the authoritative tag but is frequently absent on structures that
obviously have one, so a bridge or tunnel tag without an explicit layer is
inferred rather than left at 0 — leaving it at 0 is precisely the case that
produces a weld.
"""
if "layer" in tags:
try:
return int(tags["layer"])
except ValueError:
pass
if tags.get("bridge") not in (None, "no"):
return 1
if tags.get("tunnel") not in (None, "no"):
return -1
return 0
CROSSING_CHECK = """
// Level crossings ARE connected, unlike bridges — a road and a railway at the
// same layer meeting at a point is a real junction with real rules.
MATCH (n:Junction)
WHERE n.layer = 0 AND n.crossing_type IS NOT NULL
RETURN n.id AS id, n.crossing_type AS kind, n.barrier AS barrier
"""
How It Works
Three points carry it.
The layer joins the snap key rather than modifying the tolerance. It is tempting to treat height as another distance and widen or narrow the tolerance accordingly, but grade separation is categorical: a deck six metres up and a deck sixty metres up are equally not-connected to the road below. Making the layer part of the key means the merge simply never considers the pair, which is both correct and cheaper than any distance test.
Missing layer tags are inferred, not defaulted. A way tagged bridge=yes with no layer is extremely common, and treating its layer as 0 puts it on the same level as everything it crosses — reintroducing exactly the weld this page exists to prevent. Inferring +1 for a bridge and −1 for a tunnel is a better default because it is right far more often than it is wrong, and where it is wrong (a bridge over a bridge) the error is a missing connection rather than a fabricated one.
Level crossings are the opposite case and must not be caught by the same rule. A road crossing a railway at grade genuinely is a junction — the layers agree, the point is shared, and the connection is real, subject to whatever the crossing’s rules are. A rule that separates by proximity alone would either weld the bridges or split the crossings; separating by layer gets both right, because the layer is exactly what distinguishes them.
Common Failure Patterns
1. Trusting layer to be present. It is optional and frequently omitted on structures that plainly have one. Inferring from bridge and tunnel covers most of the gap, and the residue is worth counting rather than ignoring — a region with an unusually high proportion of untagged structures is a data-quality signal about that region’s mapping, not about your pipeline.
2. Splitting genuine level crossings. Over-correcting by treating any road-railway meeting as separated removes real junctions, and the symptom is the opposite of the weld: routes that detour absurdly because a crossing the driver uses daily does not exist in the graph. The layer test gets this right automatically, which is why it is preferable to a rule based on way types.
3. Ignoring the vertical dimension in the elevation pass too. A bridge sampled from a bare-earth model takes the height of what is underneath, which produces impossible gradients at the portals — the same underlying confusion, appearing in a different pipeline stage. The elevation enrichment handles it with the same tags.
// Post-build audit: junctions that merged endpoints from different layers.
MATCH (j:Junction)
WHERE size(j.source_layers) > 1
RETURN j.id AS junction, j.source_layers AS layers, j.source_ways AS ways
ORDER BY size(j.source_layers) DESC;
Performance Notes
Adding the layer to the key costs nothing measurable — it is one more integer in a tuple that was already being hashed — and it reduces the candidate set inside each cell, because endpoints on different layers no longer need pairwise distance tests. On a dense urban extract with many grade separations, the snapping pass is typically slightly faster with the layer included than without.
The real cost sits in the audit, and it is worth paying once per import rather than never:
$$\text{welds} \approx \sum_{\text{cells}} \binom{n_{\text{layers}}}{2}$$
Counting junctions whose contributing endpoints span more than one layer is a single aggregation and gives an exact figure for how many fabricated turns an import created. On a national extract that number should be zero after this change and is typically in the thousands before it — which is a useful thing to be able to state, because “we fixed the bridge problem” is otherwise unverifiable.
One caveat worth planning around: layer is a relative ordering, not an absolute height. Two ways both at layer 1 in different parts of the map are not at the same altitude, and nothing about the tag implies they are. That is fine for the merge decision, which is local and only ever compares endpoints within a tolerance of each other, but it means the layer must not be reused as an elevation — the DEM sampling is what supplies real heights, and the two properties answer different questions.
A last structural note. Once the layer is part of the snap key, it becomes worth carrying onto the junction itself rather than discarding it after the merge. A junction that knows it sits at layer 1 lets downstream passes make decisions the geometry alone cannot support — the elevation sampler can skip it and interpolate from the abutments, a turn-restriction importer can reject a restriction whose members span layers as certainly malformed, and the audit query above becomes possible at all. None of those are expensive, and all of them are impossible once the layer has been dropped, because the information that distinguished the bridge from the road beneath it is not recoverable from the merged node.
Related
- Node and Edge Spatial Mapping — the mapping pass this rule belongs to.
- Snapping Coordinates and Detecting Intersections — the grid the layer is added to.
- Elevation and Terrain Enrichment for Routing Graphs — the same structures confusing a different pipeline stage.
- OSM Data Ingestion Pipelines — where the layer, bridge and tunnel tags are read.
This guide is part of Node and Edge Spatial Mapping, within Spatial Graph Database Fundamentals for Python.