5.2. Query Scope and Network Topology#

5.2.1. Learning objectives#

By the end of this section, you should be able to:

  • choose a study-area method and check a boundary before using it;

  • explain boundary effects and why features near the edge of a study area need care;

  • distinguish a bounding-box radius from a network-distance radius;

  • decide when to simplify a graph, and which analyses need the raw nodes;

  • distinguish weak from strong connectivity and clean a graph accordingly;

  • explain what converting a graph discards, namely direction and parallel edges; and

  • document every derived representation of a network.

Section goal

The query defines the graph, and the graph defines every later answer. Small synthetic networks with known structure show how extent, distance rules, connectivity, and conversions change a network, and the same checks then run on Columbia, Missouri.

5.2.2. Choose the spatial extent#

OSMnx offers several ways to define a study area. Each method answers a slightly different question, and Table 5.7 extends the function list of S01 with the two distance rules of graph_from_point.

Table 5.7 Study-area queries, their best use, and their limitation.#

Query

Best use

Limitation

graph_from_place

A geocoded administrative or named place

Depends on the returned boundary

graph_from_polygon

A custom irregular study area

Requires a valid polygon

graph_from_bbox

A rectangular tile or quick test

Includes everything in the rectangle

graph_from_point(..., dist_type="bbox")

A fast approximate radius

Distance is not along the network

graph_from_point(..., dist_type="network")

A reachable network around a point

More computation and a mode-specific result

A named place is only as good as the boundary the geocoder returns. Before building a graph from it, check that the result is the intended OpenStreetMap object (a name can match several), that its bounds are plausible for the place, and that its geometry is a polygon. Inspect a boundary before using it:

Hide code cell source

import osmnx as ox

boundary = ox.geocode_to_gdf("Columbia, Missouri, USA")
print(boundary[["name", "osm_type", "osm_id"]])
print(boundary.crs, boundary.total_bounds)

boundary_geometry = (
    boundary.geometry.union_all()
    if hasattr(boundary.geometry, "union_all")
    else boundary.geometry.unary_union
)
G_walk = ox.graph_from_polygon(boundary_geometry, network_type="walk")
       name  osm_type  osm_id
0  Columbia  relation  141403
epsg:4326 [-92.433832   38.863537  -92.2276844  39.029402 ]

The geocoder returned a single OpenStreetMap relation for Columbia, with longitude and latitude bounds roughly 0.2 degrees across, which is plausible for a city. A polygon boundary is also what makes graph_from_polygon repeatable: the same polygon returns the same extent on every run.

5.2.2.1. Boundary effects#

A study-area boundary cuts streets, and the cut changes what the network says near the edge. A node whose neighbours lie outside the boundary loses edges, so its degree, its centrality, and the distances through it are all understated. The pattern is called a boundary effect [Gil, 2017], and the practical rule is to measure features well inside the study area, or to download a larger area and analyze only its interior. The toy street grid below has 81 nodes 100 m apart. A 5 by 5 study area is cut from the middle and the number of four-way intersections is counted again.

Hide code cell source

import math

import networkx as nx
import osmnx as ox
from shapely.geometry import box

LON0, LAT0 = -92.3341, 38.9517
DX = 100 / (111_320 * math.cos(math.radians(LAT0)))   # degrees of longitude per 100 m
DY = 100 / 110_574                                      # degrees of latitude per 100 m


def node_id(i, j, n=9):
    return i * n + j


def lattice(n=9):
    # An n x n grid of two-way streets, 100 m apart, at real coordinates.
    G = nx.MultiDiGraph(crs="EPSG:4326")
    half = n // 2
    for i in range(n):
        for j in range(n):
            G.add_node(node_id(i, j, n), x=LON0 + (i - half) * DX, y=LAT0 + (j - half) * DY)

    def add_street(a, b):
        (x1, y1), (x2, y2) = [(G.nodes[k]["x"], G.nodes[k]["y"]) for k in (a, b)]
        length = ox.distance.great_circle(y1, x1, y2, x2)
        for u, v in ((a, b), (b, a)):
            G.add_edge(u, v, length=length, highway="residential", oneway=False)

    for i in range(n):
        for j in range(n):
            if i + 1 < n:
                add_street(node_id(i, j, n), node_id(i + 1, j, n))
            if j + 1 < n:
                add_street(node_id(i, j, n), node_id(i, j + 1, n))
    return G


full = lattice()
nx.set_node_attributes(full, ox.stats.count_streets_per_node(full), "street_count")

study_area = box(LON0 - 2.5 * DX, LAT0 - 2.5 * DY, LON0 + 2.5 * DX, LAT0 + 2.5 * DY)
clipped = ox.truncate.truncate_graph_polygon(full, study_area)
nx.set_node_attributes(clipped, ox.stats.count_streets_per_node(clipped), "street_count")

kept = list(clipped.nodes)
four_way_full = sum(full.nodes[n]["street_count"] == 4 for n in kept)
four_way_clipped = sum(clipped.nodes[n]["street_count"] == 4 for n in kept)
print(f"full grid: {full.number_of_nodes()} nodes; study area keeps {len(kept)}")
print(f"four-way intersections among the kept nodes: {four_way_full} in the full grid, "
      f"{four_way_clipped} after clipping")
full grid: 81 nodes; study area keeps 25
four-way intersections among the kept nodes: 25 in the full grid, 9 after clipping

All 25 kept nodes are four-way intersections in the full grid, but only the inner 9 still look like one after clipping, because the 16 nodes on the new edge have lost their outward streets. An intersection-density or degree statistic computed on the clipped graph would describe the boundary, not the city.

5.2.2.2. Two ways to define a radius#

A radius around a point can mean two different areas. With dist_type="bbox" it is a square of half-width dist around the point, which contains every street inside the square whether or not it can be reached. With dist_type="network" it is the set of nodes within network distance dist of the point, so rivers, rail lines, and missing links shrink it. The next cell compares the two on the grid, with and without a river that has no crossing near the centre.

Hide code cell source

river = full.copy()
for i in range(1, 9):                       # cut every street crossing between rows 4 and 5, except at i = 0
    for u, v in ((node_id(i, 4), node_id(i, 5)), (node_id(i, 5), node_id(i, 4))):
        river.remove_edge(u, v)

center, dist = node_id(4, 4), 250
half_x, half_y = dist * DX / 100, dist * DY / 100
square = box(LON0 - half_x, LAT0 - half_y, LON0 + half_x, LAT0 + half_y)

in_box = ox.truncate.truncate_graph_polygon(river, square)
reachable_open = ox.truncate.truncate_graph_dist(full, center, dist)
reachable_river = ox.truncate.truncate_graph_dist(river, center, dist)

print(f"bounding box, {dist} m half-width:             {in_box.number_of_nodes()} nodes")
print(f"network distance, {dist} m, no river:           {reachable_open.number_of_nodes()} nodes")
print(f"network distance, {dist} m, river with no crossing: {reachable_river.number_of_nodes()} nodes")
bounding box, 250 m half-width:             25 nodes
network distance, 250 m, no river:           13 nodes
network distance, 250 m, river with no crossing: 9 nodes

The square holds 25 nodes whatever the river does. The network radius is 13 nodes on an open grid, because only nodes within two blocks of travel qualify, and falls to 9 when the river removes everything north of the centre. The box says what is near, and the network radius says what is reachable. The same difference appears on real Columbia data:

Hide code cell source

center = (38.9517, -92.3341)
G_bbox = ox.graph_from_point(
    center, dist=750, dist_type="bbox", network_type="walk"
)
G_network = ox.graph_from_point(
    center, dist=750, dist_type="network", network_type="walk"
)
print(G_bbox.number_of_nodes(), G_network.number_of_nodes())
2743 1544

The network rule keeps 1,544 of the 2,743 nodes that the box holds, 44% fewer, so the walkable catchment is much smaller than the square around it.

Network distance is more realistic for accessibility [Apparicio et al., 2008, Geurs and Van Wee, 2004], while a bounding box can be useful for a quick, reproducible tile. The two graphs should not be treated as equivalent study areas.

5.2.3. Simplification changes the graph#

Raw OpenStreetMap geometry contains points along curves. With simplify=True, OSMnx [Boeing, 2017] removes intermediate shape nodes while retaining the edge geometry, so graph nodes more closely represent intersections and endpoints, as the toy chain in S01 showed. Simplification is useful for routing and statistics, but an analysis of raw OSM vertices should leave it disabled. Table 5.8 says which analyses need which.

Table 5.8 Whether to simplify, by kind of analysis.#

Analysis

Simplify?

Reason

Routing and shortest paths

Yes

Same lengths with far fewer nodes

Intersection counts, degree, and centrality

Yes

Nodes should be intersections and dead ends

Snapping points to street vertices, or measuring curvature

No

The bend nodes carry the information

Comparing node counts between graphs

Same setting for all

Counts depend directly on the setting

Simplification is not the last step for intersection counts. A single junction mapped as several close nodes, such as a dual carriageway crossing, is still counted several times, and OSMnx can merge them with a tolerance in a projected graph.

Hide code cell source

G_raw = ox.graph_from_point(
    center, dist=500, network_type="drive", simplify=False
)
G_simple = ox.simplify_graph(G_raw)
print("raw:", G_raw.number_of_nodes())
print("simplified:", G_simple.number_of_nodes())
raw: 536
simplified: 96

Simplification removes 440 of the 536 raw nodes, 82% of them, and leaves every edge’s length intact because the shape moves into the edge geometry.

5.2.4. Connectivity: weak and strong components#

A connected component is a set of nodes that can reach one another, and on a directed graph there are two ways to define it. Table 5.9 compares them.

Table 5.9 Weak and strong connectivity in a directed graph.#

Type

Nodes are connected when

Routing consequence

Weak

A path exists if directions are ignored

The pieces touch, but a route may still be impossible

Strong

A path exists in both directions, obeying direction

Every node can route to every other

retain_all=False commonly keeps only the largest weakly connected component, and retain_all=True keeps the islands, which is right when they are meaningful to the study. Dropping islands is usually harmless, but a weakly connected graph can still contain a trap: a group of streets that can be entered but not left because every link out is one-way the wrong way. The toy graph below has a chain of two-way streets 1 to 4 and a pair of nodes 5 and 6 reached by a one-way street from node 3.

Hide code cell source

trap = nx.MultiDiGraph(crs="EPSG:4326")
for u, v in [(1, 2), (2, 3), (3, 4), (5, 6)]:
    trap.add_edge(u, v, length=100)
    trap.add_edge(v, u, length=100)
trap.add_edge(3, 5, length=100)               # one-way into the cul-de-sac, with no way back

print("weak components:  ", nx.number_weakly_connected_components(trap))
print("strong components:", nx.number_strongly_connected_components(trap))
print("route 1 -> 6 exists:", nx.has_path(trap, 1, 6))
print("route 6 -> 1 exists:", nx.has_path(trap, 6, 1))

routable = ox.truncate.largest_component(trap, strongly=True)
print("nodes in the largest strong component:", sorted(routable.nodes))
weak components:   1
strong components: 2
route 1 -> 6 exists: True
route 6 -> 1 exists: False
nodes in the largest strong component: [1, 2, 3, 4]

The graph is weakly connected, so a weak-component check passes, yet it has two strong components: a route from node 1 to node 6 exists, and the way back does not. A routing workflow that picks origins and destinations inside the trap would fail or return nonsense. largest_component(strongly=True) keeps only the nodes that can reach one another, and the choice between weak and strong should be recorded with the graph.

5.2.5. Check direction and parallel edges#

The checks below count the one-way and parallel edges of the simplified drivable graph:

Hide code cell source

import networkx as nx

print("directed:", G_simple.is_directed())
print("multigraph:", G_simple.is_multigraph())
print("weak components:", nx.number_weakly_connected_components(G_simple))

one_way_edges = [
    (u, v, k)
    for u, v, k, data in G_simple.edges(keys=True, data=True)
    if data.get("oneway", False)
]
parallel_edges = [
    (u, v)
    for u, v in G_simple.edges()
    if G_simple.number_of_edges(u, v) > 1
]
print(len(one_way_edges), len(parallel_edges))
directed: True
multigraph: True
weak components: 1
14 0

The graph is directed, a multigraph, and weakly connected, with 14 one-way edges and no parallel edges. Its strong connectivity is a separate question, answered on the same graph by the cell below.

Hide code cell source

print("strong components:", nx.number_strongly_connected_components(G_simple))

routable = ox.truncate.largest_component(G_simple, strongly=True)
print("nodes before and after keeping the largest strong component:",
      G_simple.number_of_nodes(), routable.number_of_nodes())
strong components: 4
nodes before and after keeping the largest strong component: 96 93

The drivable graph has four strong components, but only three nodes fall outside the largest one: the 93 retained nodes leave 3 of the 96, so each of the other three components is a single node (an entry or exit that cannot be used in both directions). Losing 3% of the nodes is small, yet any route that starts or ends at one of them would fail, which is why the strong component is the one to keep for routing.

5.2.6. What a conversion discards#

Every simpler graph type discards something, and Table 5.10 lists what. Converting a graph is sometimes justified, for a metric that is defined only on simple graphs, for example, but each conversion should be a deliberate and recorded choice.

Table 5.10 What common graph conversions discard.#

Conversion

What it erases

Consequence

to_undirected()

Direction

One-way restrictions disappear, as in S01

nx.DiGraph(G)

Parallel edges

Only one of several edges survives

ox.convert.to_digraph(G, weight="length")

Parallel edges, by a stated rule

The shortest parallel edge is kept

The collapse of parallel edges is the less obvious loss. A plain conversion keeps one edge without regard for its cost. The toy pair below has a 300 m street and a 450 m service road between the same two nodes, and the service road is added second.

Hide code cell source

pair = nx.MultiDiGraph(crs="EPSG:4326")
pair.add_edge("a", "b", length=300, highway="residential")   # the street
pair.add_edge("a", "b", length=450, highway="service")       # a parallel service road
pair.add_edge("b", "a", length=300, highway="residential")

print("multigraph, shortest a -> b:        ",
      nx.shortest_path_length(pair, "a", "b", weight="length"), "m")
print("nx.DiGraph(G), edge a -> b:         ", nx.DiGraph(pair)["a"]["b"]["length"], "m")
print("to_digraph(G, weight='length'):     ",
      ox.convert.to_digraph(pair, weight="length")["a"]["b"]["length"], "m")
multigraph, shortest a -> b:         300 m
nx.DiGraph(G), edge a -> b:          450 m
to_digraph(G, weight='length'):      300 m

The plain DiGraph kept the last edge, the 450 m service road, so every route through the pair is now 150 m longer than the real shortest route, and nothing reports the change. to_digraph with an explicit weight keeps the 300 m street, which is the defensible rule here.

Do not convert a directed multigraph merely to make a statistic easier. Converting to an undirected graph can erase one-way restrictions, and collapsing parallel edges can discard a legitimate route choice. Preserve the original graph and document every derived representation.

5.2.7. Summary#

Query extent, network distance, simplification, component retention, and direction all change the meaning of a network. Keep the analysis well inside the boundary to avoid edge effects, choose a network radius when reachability is the question, simplify only when the analysis wants intersections as nodes, and use strong connectivity when routes must work in both directions. Make those choices before comparing graphs or calculating a route.