Most synthetic spatial work generates things that sit in space. A network is different: it generates the thing that other things move along, and its correctness is defined by traversal rather than by geometry. This page is part of Spatial Distribution & Pattern Generation, and it covers the sub-problem of producing road, utility and transit graphs that a router, a flow model or a Markov-chain routing model can actually use.
The distinction matters because every check that works for points and polygons fails here. A network can be geometrically flawless — every line valid, every coordinate inside the envelope, the density statistics matching a reference perfectly — and still be useless, because two halves of it are not connected to each other and no journey can cross between them. Geometry validation cannot see that. Only traversal can.
Each of these produces a network that looks correct on a basemap and fails the first time something tries to route across it.
Disconnection. The graph has more than one connected component. A router asked for a path between two of them returns nothing, and the failure surfaces as an unexplained gap in the results rather than as an error — the request simply produced no route, and a pipeline that treats “no route” as a legitimate answer will silently drop those trips.
Orphan and dangling edges. An edge whose endpoint coincides with nothing, usually because two segments that should share a node have coordinates differing by a floating-point epsilon. The line is drawn, the intersection is not created, and traffic that should turn there cannot.
Missing junction nodes at crossings. Two edges cross geometrically and share no node. This is the same defect as the last one seen from the other side, and it is what makes a generated grid behave like a set of overpasses.
Implausible degree distribution. The graph is fully connected, every junction exists, and the network still does not behave like a road network — because real street networks have a characteristic distribution of junction degrees, dominated by three- and four-way intersections with very few of anything else, and a generator that places edges at random produces a degree histogram no city has.
Which check catches which defect: every geometric validation passes all four, and the topological ones each see exactly one.
The table above is the argument for treating topology as a validation dimension of its own. Geometry gates are necessary and they are blind here, and a pipeline that runs only geometry gates on a network has no coverage of the property the network exists to have.
networkx==3.3 # the graph model and the connectivity algorithms
shapely==2.0.4 # geometry, and the noding operations
geopandas==0.14.4 # the I/O boundary
One decision precedes all of these: the network is a graph with geometry, not a geometry collection that happens to be linear. Holding it as a GeoDataFrame of LineStrings and computing topology on demand means the topology is re-derived — and re-derived slightly differently — at every stage that needs it. Holding it as a graph whose edges carry geometry means the topology is the artifact, and the geometry is an attribute of it.
Nearly every defect above reduces to one question: when do two coordinates refer to the same node?
Answering it by exact floating-point equality guarantees orphans, because coordinates that arrive from different computations are almost never bit-identical. Answering it by proximity within a tolerance creates a different problem — the relation is not transitive, so three points pairwise within tolerance can chain into a cluster wider than the tolerance, and the resulting node placement depends on the order the points were processed in.
The stable answer is a precision grid: snap every coordinate to a fixed grid, and define node identity as equality of the snapped coordinates. That relation is transitive, order-independent and reproducible, and the grid step is a declared parameter rather than an emergent property of the data.
python
from dataclasses import dataclass
GRID =1e-6# ≈ 0.11 m at the equator; declare it in the contractdefnode_key(x:float, y:float, grid:float= GRID)->tuple[int,int]:"""Node identity: a pair of integers, not a pair of floats."""return(round(x / grid),round(y / grid))
The choice of grid step is the same trade-off as in fixing sliver polygons: too fine and coincident endpoints stay distinct, too coarse and genuinely separate junctions merge. The difference is that here the consequence of merging is a road that connects two places it should not, which is worse than a sliver, and worse in a way no geometric check will report.
Three node-identity rules against the properties a topology build needs — only the grid rule is transitive and order-independent.
from shapely.ops import unary_union, linemerge
from shapely import segmentize
defnode_network(lines:list)->list:"""Split every line at every intersection, so crossings become endpoints."""# unary_union of a linear collection splits at intersections — this is the whole# operation, and skipping it is what produces overpasses.returnlist(linemerge(unary_union(lines)).geoms)
unary_union over a set of lines splits them at every point where they cross. It is one call, it is where junctions come from, and a pipeline that builds a graph directly from source geometry without it produces a network in which every crossing is a flyover.
import networkx as nx
defbuild_graph(lines:list, grid:float= GRID)-> nx.MultiGraph:
g = nx.MultiGraph()for line in lines:
coords =list(line.coords)
a, b = node_key(*coords[0], grid), node_key(*coords[-1], grid)if a == b and line.length < grid *4:continue# a degenerate stub, not an edge
g.add_node(a, x=coords[0][0], y=coords[0][1])
g.add_node(b, x=coords[-1][0], y=coords[-1][1])
g.add_edge(a, b, geometry=line, length=line.length)return g
The degenerate-stub guard matters more than it looks. A zero-length or sub-grid edge creates a self-loop that a router will happily traverse forever, and it arrives from noding operations rather than from the source data, so it is not something a source-data check would have caught.
Returning the drop record rather than logging it is deliberate. The size of the largest dropped component is a diagnostic: a handful of one- and two-node fragments is normal noding residue, while a dropped component with hundreds of nodes means the network was generated as two separate networks and the connection between them was never made.
from collections import Counter
defdegree_profile(g: nx.MultiGraph)->dict[int,float]:
counts = Counter(d for _, d in g.degree())
total =sum(counts.values())return{k: v / total for k, v insorted(counts.items())}
REFERENCE ={1:0.14,3:0.42,4:0.38,5:0.05,6:0.01}# a typical urban grid
Degree one is the dead-end share, and it is the entry that most often reveals a generator has been tuned on the wrong thing: real street networks have a substantial dead-end fraction from cul-de-sacs, and a generator penalised for producing them will produce a network that is unnaturally well connected and routes too efficiently.
Measured degree distributions for four generation strategies against an urban reference — the edge count matches in all four, and only one matches the shape.
deftest_network_is_one_component(graph):assert nx.number_connected_components(graph)==1deftest_no_isolated_or_stub_edges(graph):assertnot[n for n, d in graph.degree()if d ==0]assertnot[(u, v)for u, v, k in graph.edges(keys=True)if u == v]deftest_every_crossing_has_a_node(lines, graph):"""No two edges may cross except at a shared node."""
crossings =0for i, a inenumerate(lines):for b in lines[i +1:]:if a.crosses(b):
crossings +=1assert crossings ==0,f"{crossings} un-noded crossings"deftest_degree_profile_within_tolerance(graph, reference=REFERENCE, tol=0.05):
got = degree_profile(graph)for degree, share in reference.items():assertabs(got.get(degree,0.0)- share)< tol,(degree, got.get(degree,0.0))deftest_random_pairs_are_routable(graph, rng, n=200):
nodes =sorted(graph.nodes())for _ inrange(n):
a, b = rng.choice(nodes), rng.choice(nodes)assert nx.has_path(graph, a, b)
Each gate is one assertion, and each is only meaningful once its predecessor has run.
The last of these is the one worth running even when the component check already passed, because it exercises the graph through the same API a consumer will. A graph that is connected in networkx and unroutable in the consumer’s engine usually differs in how one-way restrictions or turn restrictions are represented, and only a routing test finds that.
The crossing test above is quadratic and is fine for a few thousand edges and unusable beyond that. Replace it with a spatial index once the network is large:
python
from shapely import STRtree
defcrossings(lines:list)->int:
tree = STRtree(lines)
count =0for i, a inenumerate(lines):for j in tree.query(a):if j > i and a.crosses(lines[j]):
count +=1return count
Noding is the other cost, and it is superlinear in the number of intersections rather than in the number of edges — a dense urban grid nodes far more slowly than a sparse rural network of the same edge count. Where a network is generated tile by tile, node within each tile and then node again across the seams only, rather than noding the whole assembled set: the second pass touches a tiny fraction of the geometry and produces the same result, provided the tiles were cut on a fixed grid origin so the seams are in the same place on every run.
A network artifact that is only geometry forces every consumer to rebuild the topology, and
every consumer will rebuild it slightly differently. Three things belong in the release
alongside the lines, and all three are cheap.
The precision grid step is first, because it is the parameter that decides node identity and
therefore the entire topology. A consumer who re-snaps at a different step gets a different graph
from the same file. Declaring it means a consumer can reproduce the producer’s topology exactly,
and it means a defect in the choice is attributable rather than mysterious.
The structural profile is second — intersection density, block-area median and spread,
orientation entropy, dead-end share and the degree histogram. These are the numbers a validation
gate compares against, and shipping them turns “does this network look right” from a judgement
into a check. They are also the honest way to describe what the network resembles, since six
numbers carry no address while a perturbed copy of real geometry carries every one.
The restriction coverage is third and most often omitted. A network whose turn restrictions
are ninety per cent complete behaves very differently from one that is thirty per cent complete,
and a consumer matching traces against it will attribute the difference to the traces. Record the
share of junctions carrying at least one restriction, and record where the restrictions came
from, so a discrepancy with a consumer’s better-informed network is explicable.
None of the three is expensive to produce, and all three are impossible for a consumer to recover
from the geometry alone. The pattern is the same one that governs the rest of this area: the
things worth shipping are the things that were known at generation time and cannot be inferred
afterwards.
The router finds no path between two obviously connected places. Check the component count first. If it is one, check for a one-way restriction encoded in a direction the router reads differently from the writer.
The graph has thousands of degree-one nodes. Noding produced fragments. Almost always a grid step finer than the coordinate agreement of the source geometry; measure the agreement and set the step above it.
Two roads that should not connect are connected. The grid step is coarser than the smallest genuine separation in the network — usually a road passing under another with a small vertical clearance and a small horizontal offset. Either refine the step or carry an explicit level attribute and key node identity on it.
The degree profile matches and routes are unrealistically short. The dead-end share is too low. Real networks route less efficiently than well-connected synthetic ones, and a model trained on the synthetic version will underestimate travel time systematically.
Noding never finishes. Almost always a self-intersecting input line. Validate and repair the individual geometries before the union rather than after it.
Can I generate a network without a source city at all?
Yes, and it is often the right choice. Pick a structural profile that describes the kind of
place you need — a dense grid, an organic core, a low-density suburb — and generate against it.
The result carries no relationship to any real network, which removes the disclosure question
entirely, and it is perfectly adequate for anything that needs a plausible network rather than a
particular one. Fitting to a real city only earns its cost when a consumer’s model has to
transfer to that city specifically.
For a street network at the scale most simulations use, yes — and enforcing planarity is what noding does. Real networks are not planar, because bridges and tunnels exist, but they are planar almost everywhere, and the exceptions are far better modelled as an explicit level attribute on the node key than as un-noded crossings. The reason is diagnostic: with a level attribute, an unconnected crossing is a deliberate statement, and without one it is indistinguishable from a bug.
How do I generate a network that matches a real city's structure without copying it?
Fit the structural statistics and generate from them: the degree distribution, the block-size distribution, the orientation entropy — how uniformly street bearings are distributed, which separates a gridded city from an organic one — and the intersection density per square kilometre. Those four capture most of what makes a network look like a particular kind of place, and none of them carries a specific street. Copying geometry and perturbing it is the approach to avoid, for the same reason it is avoided everywhere else in this area: the perturbed version is still linkable to the original.
Do I need turn restrictions?
If anything downstream routes vehicles, yes. Without them a router will generate journeys through turns that are physically or legally impossible, and the traces that result are exactly the ones a map-matching consumer will reject. Represent them as a restriction on node-edge-edge triples rather than as a property of the node, since the same junction permits some turns and forbids others.