Finding Disconnected Road-Network Components Jump to heading
Given a routable OSM road network, list every isolated subgraph — the clusters of roads that share no path with the main network — so you can flag the areas a router will report as unreachable.
Prerequisites Jump to heading
Tick each box before running the code; a missing projection or the wrong component function is the usual reason the report is empty or nonsensical.
Conceptual minimum Jump to heading
A road network loaded as a graph is almost never a single connected whole. Extract clips sever roads at the boundary, a mapper forgets to join a new estate road to the trunk it feeds, and a ferry route or a service road ends up floating with no link to anything. Each of those leaves a connected component — a maximal set of nodes mutually reachable within the set — that is separate from the rest. In a healthy network one giant component holds the overwhelming majority of nodes and everything else is a handful of tiny islands; the job here is to measure that distribution and surface the islands.
Because a road graph is directed, “connected” has two meanings, and the Routing-Graph Topology QA section leans on the distinction. A weakly connected component treats every edge as undirected — it answers “is this cluster of roads attached to the network at all?” A strongly connected component honours one-way direction — it answers “can a vehicle actually enter and leave following the arrows?” Ranking components by node count turns both questions into a single sorted list: the first entry is your main network, and every entry after it, weighted against the total, is a candidate defect. A weak island means physically severed roads; a small strong island that is not weakly isolated means a one-way orientation fault, reachable but inescapable.
Runnable solution Jump to heading
This module builds (or loads) a drivable graph, computes both weak and strong components, ranks them by node count, and writes the isolated islands to a GeoJSON report with a centroid per island so a reviewer can jump straight to the location on the map. It targets osmnx>=1.9, networkx>=3.2, and Python 3.10+.
from __future__ import annotations
import json
import logging
from pathlib import Path
import networkx as nx
import osmnx as ox
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.disconnected_components")
def build_graph(place: str | None = None, graphml: str | None = None) -> nx.MultiDiGraph:
"""Load a drivable graph from a place name or a saved GraphML file."""
if graphml is not None:
graph = ox.load_graphml(graphml)
logger.info("loaded graph from %s", graphml)
elif place is not None:
graph = ox.graph_from_place(place, network_type="drive")
logger.info("downloaded graph for %r", place)
else:
raise ValueError("provide either a place name or a graphml path")
logger.info("graph: %d nodes, %d edges", graph.number_of_nodes(), graph.number_of_edges())
return graph
def rank_components(
graph: nx.MultiDiGraph, strong: bool = False
) -> list[set[int]]:
"""Return components sorted largest-first."""
finder = nx.strongly_connected_components if strong else nx.weakly_connected_components
components = sorted(finder(graph), key=len, reverse=True)
logger.info(
"%s components: %d total, largest = %d nodes",
"strong" if strong else "weak", len(components), len(components[0]),
)
return components
def isolated_islands(
components: list[set[int]], total_nodes: int, max_fraction: float = 0.01
) -> list[set[int]]:
"""Every component after the largest that holds less than max_fraction of nodes."""
islands = [c for c in components[1:] if len(c) < max_fraction * total_nodes]
logger.warning("%d isolated islands below %.1f%% of the network",
len(islands), max_fraction * 100)
return islands
def island_report(graph: nx.MultiDiGraph, islands: list[set[int]]) -> dict:
"""Build a GeoJSON FeatureCollection, one point per island at its centroid."""
features = []
for rank, nodes in enumerate(islands, start=1):
lons = [graph.nodes[n]["x"] for n in nodes]
lats = [graph.nodes[n]["y"] for n in nodes]
centroid = [sum(lons) / len(lons), sum(lats) / len(lats)]
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": centroid},
"properties": {
"island_rank": rank,
"node_count": len(nodes),
"sample_node": next(iter(nodes)),
},
})
return {"type": "FeatureCollection", "features": features}
def find_disconnected(place: str, out: str = "islands.geojson") -> list[set[int]]:
"""End-to-end: build, rank, flag, and write a report of disconnected islands."""
graph = build_graph(place=place)
total = graph.number_of_nodes()
weak = rank_components(graph, strong=False)
islands = isolated_islands(weak, total)
# A one-way orientation fault: reachable in the undirected graph but its own
# small strong component. These are not weakly isolated, so surface separately.
strong = rank_components(graph, strong=True)
one_way_islands = [c for c in strong[1:] if 1 < len(c) < 25]
logger.info("%d candidate one-way islands", len(one_way_islands))
report = island_report(graph, islands)
Path(out).write_text(json.dumps(report, indent=2), encoding="utf-8")
logger.info("wrote %d islands to %s", len(islands), out)
return islands
if __name__ == "__main__":
find_disconnected("Piedmont, California, USA")
Step-by-step walkthrough Jump to heading
- Flexible input —
build_graphaccepts either a live place name or a previously saved GraphML file, so the same audit runs against a fresh download or a pinned snapshot from an earlier build without re-hitting the Overpass API. - One ranking function, two modes —
rank_componentsswapsweakly_connected_componentsforstrongly_connected_componentsbehind a boolean, because the two share the “sort by length, largest first” shape. The largest entry is always the main network. - Fraction, not a fixed count —
isolated_islandscompares each component against a fraction of the total node count rather than an absolute size, so the same threshold works on a village and a metropolis. Everything after index 0 that falls below the fraction is flagged. - Two classes of island — the driver computes weak islands (physically severed roads) and, separately, small strong components that are not weakly isolated (one-way orientation faults). Reporting them apart tells the reviewer whether to add a missing road or fix a direction tag.
- Actionable geometry —
island_reportreduces each island to a centroid point with its rank and node count, producing GeoJSON that drops straight onto a map so the reviewer navigates to the defect instead of reading node IDs. - Deterministic output — components are sorted by size before writing, so two runs over the same graph produce the same ranked report, which matters when the file is diffed in review.
Verification Jump to heading
Confirm the report is trustworthy before acting on it:
- The main component dominates.
len(weak[0]) / graph.number_of_nodes()should be close to 1.0 (often above 0.98) for a well-connected extract; a low value means the network is fragmented and the threshold needs attention, not the data. - Island count is plausible. A metro extract typically yields a handful to a few dozen islands. Hundreds usually signals an over-tight clip or a parsing problem upstream, not that many genuine breaks.
- Centroids land on real roads. Open
islands.geojsonon a base map; each point should sit on or beside a road segment. A centroid in open water or off the extract edge points to a coordinate or projection error. - Strong ⊇ weak counts. There are always at least as many strong components as weak ones, because direction can only split a component further. If strong count is lower, the component functions were swapped.
- Re-run is stable. Running twice over a saved GraphML must produce byte-identical GeoJSON, proving the sort and centroid math are deterministic.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
islands.geojson is empty |
Threshold too low for a fragmented graph | Raise max_fraction, or fix the upstream clip that severed roads. |
| Everything flagged as an island | Graph loaded undirected or edges dropped | Confirm graph_from_place(..., network_type="drive") returned a MultiDiGraph. |
KeyError: 'x' in centroid |
Node lacks coordinates after simplification | Load with geometry intact, or read x/y from the unsimplified graph. |
| Strong components equal weak | Passed strong=False for both calls |
Pass strong=True when you want direction-aware components. |
| Hundreds of tiny islands | Extract clipped mid-road at the boundary | Re-clip a larger area so boundary roads keep their far endpoints. |
EmptyOverpassResponse |
Place name did not geocode | Use a bounding box or a saved GraphML instead of the place string. |
Specification reference Jump to heading
networkxdefines a connected component as a maximal set of nodes such that each pair is connected by a path. For directed graphs,weakly_connected_componentstreats edges as undirected whilestrongly_connected_componentsrequires a directed path in both directions; both return sets of nodes and are documented under Algorithms → Components in the official networkx components reference. Graph construction and coordinate storage follow the osmnx documentation, where nodes carryx(longitude) andy(latitude) attributes.
Frequently Asked Questions Jump to heading
Should I use weak or strong components to find unreachable areas?
Start with weak components to find roads physically severed from the network, then add strong components to catch one-way orientation faults. A weak island is unreachable from any direction; a small strong component that is not weakly isolated is reachable but cannot be left, or vice versa. Reporting both, ranked by size, gives a complete picture of what a router cannot serve.
What size threshold marks a component as a defect?
Use a fraction of the total node count rather than a fixed number, so the rule scales from a village to a city. Anything after the largest component that holds less than about one percent of nodes is a reasonable default candidate. Tune the fraction to your extract: dense urban networks tolerate a smaller threshold, sparse rural ones may need a larger one to avoid flagging legitimate small clusters.
Why does a component appear disconnected when the roads clearly connect on the map?
The most common cause is an extract clip that cut a road at the boundary, removing the node that linked the island to the main network. The second is a near-miss endpoint where two ways were digitized without a shared node. The graph only joins edges at shared nodes, so a visual overlap without a shared node leaves the cluster isolated regardless of how it renders.
How do I turn the report into something a mapper can act on?
Reduce each island to a centroid point carrying its rank and node count, and write it as GeoJSON. That drops onto any base map so the reviewer navigates directly to the location instead of decoding node IDs, and the rank orders their attention toward the largest unreachable clusters first.
Related Jump to heading
- Routing-Graph Topology QA — the parent reference framing components alongside near-miss endpoints, one-way traps, and turn restrictions.
- OSMnx Graph Conversion Techniques — building the directed graph this procedure consumes.
- Geometry Validation and Repair — the geometry-level checks that run alongside connectivity analysis.
- Node-Way-Relation Data Model — why edges join only at shared nodes.
- OSM Data Quality & Validation — the section that gathers every validation discipline.
Up one level: Routing-Graph Topology QA.