A generated network is connected, routable and passes every topology gate, and it still does not behave like the city it is supposed to represent: journeys are too short, turns are too frequent, and a model trained on it underestimates travel time everywhere.
The parameter almost every generator exposes is edge count or total length, and it is nearly uninformative. Two networks over the same extent with identical total length can differ by a factor of three in the number of intersections, and it is the intersections rather than the length that determine how the network routes.
Four statistics between them capture most of what makes a network behave like a particular kind of place, and none of them carries a specific street:
Intersection density — junctions per square kilometre, counting only nodes of degree three or more. This is the single most predictive number: it separates a dense pre-war grid from a post-war suburb far more reliably than any measure of length.
Block-size distribution — the areas of the faces the network encloses. Two networks can share an intersection density and differ entirely in whether their blocks are uniform or wildly varied.
Orientation entropy — how uniformly street bearings are distributed around the compass. A gridded city has almost all its bearings on two axes and a low entropy; an organic one spreads across every bearing and has a high one.
Degree profile — the share of junctions at each degree, and especially the dead-end share, which is what makes a real network route less efficiently than a synthetic one.
Computed for four real-shaped city types: total street length barely separates them, and the four structural statistics separate them completely.
The chart is the argument for fitting these rather than length. All four city types in it have street lengths within twenty per cent of each other; their intersection densities differ by a factor of four and their orientation entropies span nearly the whole available range.
import math
from collections import Counter
defintersection_density(graph, area_km2:float)->float:"""Junctions per km², counting only real intersections."""returnsum(1for _, d in graph.degree()if d >=3)/ area_km2
deforientation_entropy(graph, bins:int=36)->float:"""Shannon entropy of street bearings, normalised to [0, 1].
Bearings are folded onto [0, 180) because a street has no direction, and the
entropy is divided by log(bins) so a perfectly uniform network scores 1.
"""
hist = Counter()for u, v, data in graph.edges(data=True):
x1, y1 = graph.nodes[u]["x"], graph.nodes[u]["y"]
x2, y2 = graph.nodes[v]["x"], graph.nodes[v]["y"]
bearing = math.degrees(math.atan2(x2 - x1, y2 - y1))%180
hist[int(bearing /(180/ bins))]+= data.get("length",1.0)
total =sum(hist.values())or1.0
h =-sum((w / total)* math.log(w / total)for w in hist.values()if w)return h / math.log(bins)defdead_end_share(graph)->float:returnsum(1for _, d in graph.degree()if d ==1)/max(graph.number_of_nodes(),1)
Weighting the bearing histogram by length rather than by edge count is the detail that makes orientation entropy behave. Unweighted, a grid with many short connector segments at odd angles scores as though it were organic, because each short segment counts as much as a long avenue.
from dataclasses import dataclass, asdict
@dataclass(frozen=True)classStreetProfile:
intersection_density:float# junctions (degree ≥ 3) per km²
block_area_median:float# m²
block_area_cv:float# coefficient of variation — the spread
orientation_entropy:float# 0 = perfectly gridded, 1 = uniform
dead_end_share:float
degree_profile:dict[int,float]deffit(graph, area_km2:float, blocks:list)-> StreetProfile:
areas =sorted(b.area for b in blocks)
mean =sum(areas)/len(areas)
sd = math.sqrt(sum((a - mean)**2for a in areas)/len(areas))
counts = Counter(d for _, d in graph.degree())
total =sum(counts.values())return StreetProfile(
intersection_density=intersection_density(graph, area_km2),
block_area_median=areas[len(areas)//2],
block_area_cv=sd / mean,
orientation_entropy=orientation_entropy(graph),
dead_end_share=dead_end_share(graph),
degree_profile={k: v / total for k, v insorted(counts.items())},)
The profile is a handful of floats, it goes in the data contract beside the CRS and the envelope, and it is the thing a validation gate compares against. Storing it rather than the source network is also the privacy argument: six numbers describing a city’s structure carry no address, whereas a perturbed copy of its streets carries every one of them.
deftarget_intersections(profile: StreetProfile, area_km2:float)->int:returnround(profile.intersection_density * area_km2)deftarget_bearings(profile: StreetProfile, rng, n:int)->list[float]:"""Draw bearings whose entropy matches the target.
Low entropy is generated by concentrating mass on two orthogonal axes with a
small spread; high entropy by drawing uniformly. Interpolating between them
with a single mixing weight is enough to hit any target in between.
"""
grid_weight =1.0- profile.orientation_entropy
out =[]for _ inrange(n):if rng.uniform()< grid_weight:
axis =0.0if rng.uniform()<0.5else90.0
out.append((axis + rng.normal(0,6))%180)else:
out.append(rng.uniform(0,180))return out
The mixing weight is a one-parameter family that spans the whole range from a perfect grid to a fully organic network, which makes the fit a search over one variable rather than a design exercise.
A one-parameter search over the grid-mixing weight converges on the target orientation entropy in a handful of iterations, and the other three statistics are fitted independently.
TOLERANCES ={"intersection_density":0.08,# relative"block_area_median":0.15,"block_area_cv":0.20,"orientation_entropy":0.05,# absolute, since it is already normalised"dead_end_share":0.04,# absolute}deftest_profile_matches(generated_profile, target: StreetProfile):
got, want = asdict(generated_profile), asdict(target)
failures =[]for field, tol in TOLERANCES.items():
a, b = got[field], want[field]
err =abs(a - b)if field in("orientation_entropy","dead_end_share") \
elseabs(a - b)/max(b,1e-9)if err > tol:
failures.append(f"{field}: {a:.4g} vs {b:.4g} (tol {tol})")assertnot failures,"; ".join(failures)
Two of the five tolerances are absolute rather than relative, and the reason is worth stating: orientation entropy and dead-end share are already proportions, so a relative tolerance on a target near zero is meaninglessly tight and one on a target near one is meaninglessly loose.
Being explicit about the limits matters, because a profile that matches on all four is easy to
over-trust.
They say nothing about hierarchy. A real network has a functional class structure — motorways,
arterials, collectors, local streets — with different speeds, capacities and connectivity roles,
and all four statistics are computed over the undifferentiated graph. Two networks with identical
profiles can have completely different hierarchies, and anything that assigns traffic will behave
differently on them. If the release is for assignment or capacity work, the class distribution
and the class-conditional degree profile belong in the contract too.
They say nothing about the relationship to land use. Street structure and building density are
strongly coupled in real cities, and a profile fitted to a network alone will happily place a
dense grid where the population raster says nobody lives. Where the release includes both, the
coupling is worth checking directly.
And they say little about connectivity at range. Intersection density is local; two networks
with the same density can differ in whether crossing the extent takes four turns or forty, which
is exactly what a travel-time model is sensitive to. A cheap addition is the ratio of network
distance to straight-line distance over a sample of node pairs — one number, computed from the
graph you already have, and it captures the property the four local statistics miss.
The extent is too small for the density to be stable. Intersection density over a quarter of a square kilometre is dominated by where the boundary happens to fall. Fit and gate over at least a few square kilometres, or accept a much wider tolerance and say so.
Perturbing coordinates leaves the graph structure intact, which is what a structural match recovers the original from.
Mixed urban form in one extent. A single profile fitted across a city centre and its suburbs describes neither. Partition the extent by form — the same partition the density surface already uses is usually good enough — and fit a profile per partition, which also gives the generator somewhere to put the transition.
Orientation entropy is unstable on small samples. With fewer than a few hundred edges the histogram is sparse and the entropy estimate is biased downward, so a small synthetic network looks more gridded than it is. Use a length-weighted histogram, as above, and treat the statistic as unreliable below about five hundred edges.
The block distribution needs the faces, not the edges. Computing block areas requires polygonising the network, which fails on a graph with any un-noded crossing — so this statistic can only be computed after the topology is clean, and a failure to polygonise is itself a useful signal that it is not.