Routing-Graph Topology QA Jump to heading
A routing engine never complains about bad topology — it just quietly returns “no route found,” or worse, an implausible detour, and the mapper who reported the bug gets told the data is fine. That is the trap this guide addresses. When an OSM extract is converted into a routable graph, geometric correctness and topological correctness are two different properties: a road can render perfectly on a tile yet be unreachable in the graph because its endpoint sits two centimetres away from the junction it was meant to join. Every one of these defects passes a visual review, passes geometry validation, and still poisons shortest-path queries. This section belongs to the wider OSM Data Quality & Validation discipline, and it narrows the lens to the connectivity invariants a graph must satisfy before anyone trusts a route computed on it.
Prerequisites Jump to heading
Three foundations make the rest of this guide actionable. First, you need a mental model of how ways become edges — a road is a chain of node references, and two roads only share a graph vertex when they share an OSM node, a fact rooted in the Node-Way-Relation Data Model. Second, you need a graph in hand: the translation from tagged ways to a directed, weighted networkx object is the job of OSMnx Graph Conversion Techniques, and topology QA runs on that output rather than on raw primitives. Third, this connectivity work sits beside — not on top of — Geometry Validation and Repair: a geometry can be perfectly valid (a closed, non-self-intersecting ring) while its role in the network is still broken, so run both classes of check, not one as a substitute for the other.
The Defect Catalogue: What Breaks Routing Jump to heading
Topology QA is a bounded problem because the failure modes are enumerable. Each defect below has a precise graph-theoretic signature, which is what makes it detectable without human review.
| Defect | Graph signature | Routing symptom |
|---|---|---|
| Disconnected component | A weakly connected component containing a tiny fraction of nodes | Trips to/from the region return “no route” |
| Near-miss endpoint | Two degree-1 nodes within a small distance but not identical | Two roads that visually touch never join |
| One-way trap (sink) | Node with in-degree > 0 and out-degree 0 | Vehicles route in but can never leave |
| One-way island | Strongly connected component you can enter but not exit | Region reachable one way, unreachable the other |
| Dangling stub | Degree-1 node not a legitimate cul-de-sac terminus | Harmless alone; noise that hides real breaks |
| Self-loop | Edge whose source and target are the same node | Zero-length cycle; distorts turn logic |
| Unmodeled turn restriction | restriction relation with no edge-level penalty |
Engine plans an illegal turn |
The distinction between weak and strong connectivity is the crux of directed-graph QA. A weakly connected component ignores edge direction — treat every one-way as bidirectional and ask “is this reachable at all?” A strongly connected component respects direction — “can I get here and leave following the arrows?” A one-way trap is invisible to weak-component analysis (the sink is weakly connected to everything around it) but stands out immediately under strong-component analysis, where it collapses into a singleton or a small island. Running only weak-component checks is the single most common reason a one-way defect ships to production.
Turn restrictions are a category apart. They are not encoded in node references at all; they live in type=restriction relations whose members carry from, via, and to roles. A plain node-and-edge graph has nowhere to store “no left turn from this way onto that way,” so unless the graph builder expands restricted junctions into penalty edges or a line-graph, the restriction is simply absent — and the router will happily plan the illegal manoeuvre.
Step-by-Step: Auditing a Routing Graph Jump to heading
The pass below loads a drivable network with osmnx, then applies a battery of networkx connectivity checks. It uses Python 3.10+ type hints and the project logger convention, and it is written to report defects rather than silently mutate the graph — remediation is a deliberate second step, never a side effect of detection.
- Build the directed graph. Convert the extract into a
MultiDiGraphwhere one-ways are single directed edges and bidirectional roads are reciprocal pairs. Preserve edge geometry and length so distance-based checks have real coordinates to work with. - Rank weakly connected components. Sort components by node count; the largest is your main network, and everything below a size threshold is a candidate island to flag.
- Test strong connectivity for direction faults. Find strongly connected components and locate nodes with an out-degree of zero (sinks) or in-degree of zero (sources) — the fingerprints of one-way traps.
- Degree-scan for stubs and self-loops. Enumerate degree-1 nodes and self-loop edges directly from the degree view.
- Range-search for near-miss endpoints. Index the coordinates of degree-1 nodes and query for pairs closer than a snapping tolerance but not already joined.
import logging
import networkx as nx
import osmnx as ox
logger = logging.getLogger(__name__)
def load_drive_graph(place: str) -> nx.MultiDiGraph:
"""Download and build a drivable routing graph for a place name."""
graph = ox.graph_from_place(place, network_type="drive")
logger.info(
"graph built: %d nodes, %d edges",
graph.number_of_nodes(),
graph.number_of_edges(),
)
return graph
def rank_weak_components(graph: nx.MultiDiGraph, min_fraction: float = 0.01) -> list[set[int]]:
"""Return weakly connected components smaller than min_fraction of the graph."""
total = graph.number_of_nodes()
components = sorted(nx.weakly_connected_components(graph), key=len, reverse=True)
islands = [c for c in components[1:] if len(c) < min_fraction * total]
logger.info(
"%d weak components; largest holds %d nodes; %d small islands flagged",
len(components), len(components[0]), len(islands),
)
return islands
Directional faults come from the strong-component and degree views. A sink has out-degree zero after collapsing parallel edges, so query the directed degree views rather than the undirected .degree():
def find_directional_traps(graph: nx.MultiDiGraph) -> dict[str, list[int]]:
"""Locate one-way sinks (no exit) and sources (no entry)."""
sinks = [n for n, deg in graph.out_degree() if deg == 0]
sources = [n for n, deg in graph.in_degree() if deg == 0]
strong = list(nx.strongly_connected_components(graph))
reachable = max(strong, key=len)
islands = [c for c in strong if c is not reachable and len(c) < 25]
logger.warning(
"%d sinks, %d sources, %d one-way islands",
len(sinks), len(sources), len(islands),
)
return {"sinks": sinks, "sources": sources, "islands": [n for c in islands for n in c]}
Stubs and self-loops fall straight out of the graph structure, and near-miss endpoints need a spatial range query over the degree-1 node coordinates:
from scipy.spatial import cKDTree
def find_stubs_and_loops(graph: nx.MultiDiGraph) -> dict[str, list]:
"""Degree-1 dead ends and zero-length self-loop edges."""
undirected = graph.to_undirected()
stubs = [n for n, deg in undirected.degree() if deg == 1]
loops = list(nx.selfloop_edges(graph, keys=True))
return {"stubs": stubs, "self_loops": loops}
def find_near_miss_endpoints(
graph: nx.MultiDiGraph, stubs: list[int], tol_m: float = 2.0
) -> list[tuple[int, int]]:
"""Pairs of dead-end nodes closer than tol_m metres but not connected."""
# osmnx stores lon/lat in x/y; project so the tolerance is in metres.
projected = ox.project_graph(graph)
coords = [(projected.nodes[n]["x"], projected.nodes[n]["y"]) for n in stubs]
if len(coords) < 2:
return []
tree = cKDTree(coords)
pairs = tree.query_pairs(r=tol_m)
near_miss: list[tuple[int, int]] = []
for i, j in pairs:
a, b = stubs[i], stubs[j]
if not graph.has_edge(a, b) and not graph.has_edge(b, a):
near_miss.append((a, b))
logger.warning("%d near-miss endpoint pairs within %.1f m", len(near_miss), tol_m)
return near_miss
Validation & Detection Matrix Jump to heading
Each defect maps to a check, a detection expression, and a remediation that must never run blind — snapping the wrong pair of endpoints invents a road that does not exist, so every automated fix needs a tolerance and a review gate.
| Defect | Root cause | Detection | Remediation |
|---|---|---|---|
| Disconnected component | Extract clip severed a road; missing bridge way | Weak component below size threshold | Re-clip larger; add the missing connecting way upstream |
| Near-miss endpoint | Two ways digitized without a shared node | Degree-1 nodes within tolerance, unconnected | Snap to a shared node if gap < tolerance and review confirms |
| One-way trap (sink) | oneway direction reversed or over-applied |
Out-degree 0 on a non-terminal node | Correct the oneway tag or add the missing reverse edge |
| One-way island | A ring of one-ways all oriented the same way | Small strongly connected component | Re-tag one segment; verify entry and exit both exist |
| Dangling stub | Legitimate cul-de-sac vs. truncated road | Degree-1 node not tagged as terminus | Distinguish real dead ends; join truncated ends |
| Self-loop | Way whose first node ref equals its last | nx.selfloop_edges non-empty |
Split the way at an interior node or drop the loop edge |
| Unmodeled turn restriction | Restriction relation dropped in graph build | restriction relation with no penalty edge |
Rebuild with turn-penalty expansion enabled |
Performance & Scale Considerations Jump to heading
Connected-component analysis is linear in the size of the graph — networkx computes weak and strong components in roughly O(V + E) — so the component checks are cheap even on a national road network of millions of edges. The expensive operations are the ones you add around them. Projecting the graph so a tolerance is expressed in metres (ox.project_graph) copies every coordinate and is the slowest single step; do it once and reuse the projected graph for all distance checks rather than reprojecting per query. The near-miss search scales with the number of degree-1 nodes, not the whole graph, and a cKDTree query_pairs over even a few hundred thousand dead ends completes in well under a second because the candidate set is small and the radius tight.
Two levers keep large runs bounded. First, partition by administrative area: run the audit per region and reconcile at the boundaries, so a planet-scale check becomes an embarrassingly parallel batch rather than one monolithic graph in RAM. Second, cache the built graph: the conversion from PBF to MultiDiGraph dominates wall-clock time, so serialize the graph once and re-run only the detection functions when you iterate on thresholds. For genuinely planet-scale topology work, hold only node degree and component labels in memory and stream edge geometry from disk when a specific defect needs inspection.
Failure Modes & Gotchas Jump to heading
- Undirected degree hides one-way faults.
graph.degree()sums in- and out-edges, so a sink node looks like a normal degree-2 vertex. Always usein_degree()andout_degree()separately when hunting directional traps. - Every legitimate cul-de-sac is a degree-1 node. A raw stub count is mostly false positives; filter against
highwayvalues andnoexit=yesbefore treating a dead end as a defect. - Tolerance is a physical quantity, not a coordinate delta. Comparing raw lon/lat differences conflates a metre near the equator with far less near the poles. Project to a metric CRS before any distance threshold, or the same tolerance means different things across the extract.
osmnxmay pre-simplify away the nodes you want. Graph simplification collapses interstitial degree-2 nodes into single edges; if you need to test snapping at the original vertices, build withsimplify=Falseor work from the unsimplified geometry.- Bridges and tunnels look disconnected in 2D. A road crossing over another shares no node by design (
layerdiffers). Do not snap endpoints that are near in plan but separated bylayerorbridge/tunneltags. - Turn restrictions survive only if the builder expands them. A standard node-edge graph discards
via-node restrictions; verify your routing library models them before trusting turn-by-turn output.
Integration Points: Feeding the Router Jump to heading
The audit’s output is a defect manifest, and the clean boundary is that the routing stage consumes a repaired graph while the QA stage consumes the raw one. The wiring below runs the full battery and emits a single structured report, routing each finding to the reviewer who can fix it upstream in the source data:
def audit_topology(place: str) -> dict[str, object]:
"""Run every connectivity check and return a consolidated report."""
graph = load_drive_graph(place)
stubs_loops = find_stubs_and_loops(graph)
report = {
"islands": rank_weak_components(graph),
"directional": find_directional_traps(graph),
"stubs": stubs_loops["stubs"],
"self_loops": stubs_loops["self_loops"],
"near_miss": find_near_miss_endpoints(graph, stubs_loops["stubs"]),
}
logger.info("topology audit complete for %s", place)
return report
A green audit is the precondition for graph conversion downstream: only once components, direction, and endpoint snapping are clean does the graph produced by OSMnx Graph Conversion Techniques yield routes a user will trust. Feed the manifest back to whoever authors the source fixes, and re-run the audit after each edit so regressions surface immediately.
Examine Topology QA in Depth Jump to heading
This reference expands into a focused walkthrough of its highest-leverage check:
- Finding Disconnected Road-Network Components — a complete, runnable procedure that builds a graph, computes weak and strong components, ranks them by size, and reports the isolated islands so unreachable areas can be flagged for repair.
Frequently Asked Questions Jump to heading
What is the difference between weakly and strongly connected components for routing?
A weakly connected component ignores edge direction: it answers whether two nodes are joined at all if you treat every one-way as bidirectional. A strongly connected component respects direction: it answers whether you can travel from one node to another and back following the arrows. One-way traps and islands are invisible to weak-component checks but stand out under strong-component analysis, so a directed routing graph needs both.
Why do two roads that touch on the map still fail to route?
Because rendering and topology are independent. Two ways can be drawn so their endpoints overlap visually while their OSM nodes are distinct points a short distance apart. The router joins edges only where they share a node, so the un-snapped gap means there is no graph vertex linking them. Detect these as degree-1 node pairs closer than a snapping tolerance in a projected coordinate system.
How do I tell a real dead end from a broken road?
Both appear as degree-1 nodes, so a raw count is dominated by legitimate cul-de-sacs. Filter using tags and context: a genuine terminus is often on a residential or service road and may carry noexit=yes, while a truncated road frequently sits near another dead end within a snapping tolerance or ends abruptly on a major highway. Treat the near-miss pairs as the actionable subset rather than every stub.
Why does my router plan an illegal turn even though the data has a restriction?
Turn restrictions live in type=restriction relations with from, via, and to members, not in node references. A plain node-and-edge graph has nowhere to store them, so unless the graph builder expands restricted junctions into penalty edges or a line-graph, the restriction is simply absent and the engine treats the manoeuvre as legal. Rebuild the graph with turn-penalty expansion enabled to model them.
Should topology QA modify the graph automatically?
Detection and repair should be separate. Snapping the wrong pair of endpoints or deleting a real one-way invents or destroys roads, so automated fixes must be gated by a tolerance and human review. The audit should emit a manifest of findings; corrections belong upstream in the source data where they persist across rebuilds rather than as a silent mutation of one derived graph.
Related Jump to heading
- Finding Disconnected Road-Network Components — the runnable component-analysis procedure this section frames.
- Geometry Validation and Repair — the sibling discipline for ring, polygon, and geometry-level defects that topology checks do not cover.
- OSMnx Graph Conversion Techniques — how tagged ways become the directed graph these checks run on.
- Node-Way-Relation Data Model — why edges share a vertex only when ways share a node.
- OSM Data Quality & Validation — the parent section covering geometry, topology, tags, and rule authoring together.
Up one level: OSM Data Quality & Validation.