Calibrating Intersection Density for Synthetic Street Grids

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.

Part of Synthetic Network & Graph Topology Generation: where generating connected networks without orphan edges makes the graph usable, this page is about making it representative — fitting the structural statistics that decide how a network behaves, without copying any street from the source.

Root Cause: Edge Count Is Not Structure

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.
Four city forms compared by total street length and by four structural statistics Four columns, one per city form. The first row gives total street length per square kilometre, and all four values fall within about twenty per cent of one another, which is why length is nearly useless as a calibration target. The remaining four rows give the statistics that do separate them. Intersection density spans a factor of four, from a dense pre-war grid with many junctions per square kilometre down to an arterial sprawl with few. Block-area spread, expressed as a coefficient of variation, is low for the planned grid and high for the organic core, where block sizes are wildly irregular. Orientation entropy is near zero for the grid, whose bearings sit on two axes, and near one for the organic core, whose bearings are spread across the compass. Dead-end share is negligible in the grid and substantial in the suburb, where cul-de-sacs are the defining feature. Each cell is shaded by how far it sits from the four-form average, so the pattern is visible without reading the numbers. The conclusion is that these four numbers, which carry no address between them, describe how a network behaves far better than any measure of how much of it there is. Four numbers that carry no address, and describe the place better than length does dense pre-war grid organic medieval core post-war suburb arterial sprawl total street length (km/km²) 21 20 18 23 intersection density (/km²) 182 148 71 44 block-area spread (CV) 0.31 0.94 0.52 0.68 orientation entropy 0.14 0.91 0.63 0.38 dead-end share 0.03 0.11 0.34 0.22 ↑ does not separate the forms ↓ separates them completely All four forms carry street lengths within about 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 — which is why the profile, not the length, is the calibration target.
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.

Prerequisite Check: Measure the Target Before Fitting Anything

python
import math
from collections import Counter


def intersection_density(graph, area_km2: float) -> float:
    """Junctions per km², counting only real intersections."""
    return sum(1 for _, d in graph.degree() if d >= 3) / area_km2


def orientation_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()) or 1.0
    h = -sum((w / total) * math.log(w / total) for w in hist.values() if w)
    return h / math.log(bins)


def dead_end_share(graph) -> float:
    return sum(1 for _, 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.

Fix: Fit the Four, Then Generate From Them

Fit the target profile once, and store it as a contract clause

python
from dataclasses import dataclass, asdict


@dataclass(frozen=True)
class StreetProfile:
    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]


def fit(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) ** 2 for 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 in sorted(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.

Generate against the profile rather than against a length target

python
def target_intersections(profile: StreetProfile, area_km2: float) -> int:
    return round(profile.intersection_density * area_km2)


def target_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 _ in range(n):
        if rng.uniform() < grid_weight:
            axis = 0.0 if rng.uniform() < 0.5 else 90.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.

Orientation entropy against grid-mixing weight, and the convergence of a bisection search The upper panel plots measured orientation entropy against the grid-mixing weight, from a fully uniform bearing distribution at weight zero down to a strongly gridded one at weight one. The relationship is smooth and monotone across the whole range, which is the property that matters: a monotone one-parameter relationship can be inverted by bisection, so hitting any target entropy is a search rather than a design problem. The lower panel plots the entropy reached at each iteration of that bisection against a target of 0.63, marked as a dashed line. The first iteration overshoots substantially, the second and third bracket the target, and by the sixth the value is within the gate tolerance and stays there. Nine iterations are shown and the last three change nothing, which is the signal to stop. The note underneath records what the search does not cover: the other three structural statistics — intersection density, block-size spread and dead-end share — are fitted independently, because each has its own generator parameter and none of them interacts with the bearing distribution. Monotone in one parameter, so the fit is a bisection 0 0.25 0.5 0.75 1 0 0.5 1 grid-mixing weight entropy 1 2 3 4 5 6 7 8 9 0 0.5 1 bisection iteration entropy reached target 0.63 within tolerance by iteration 6 4,000 bearings per evaluation, fixed seed. The other three statistics — intersection density, block-size spread and dead-end share — are fitted independently, because each has its own generator parameter and none of them interacts with the bearing distribution.
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.

Verification Step: Gate on the Profile, Not on the Length

python
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
}


def test_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") \
            else abs(a - b) / max(b, 1e-9)
        if err > tol:
            failures.append(f"{field}: {a:.4g} vs {b:.4g} (tol {tol})")
    assert not 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.

What the Four Statistics Do Not Capture

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.

Edge Cases & Gotchas

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.

Structural profile versus perturbed copy, by what each reproduces and what each leaks Two columns. A fitted structural profile is six numbers — intersection density, block-area median and spread, orientation entropy, dead-end share and the degree profile. It reproduces how the network behaves: routing efficiency, turn frequency, block structure. It leaks nothing that identifies a place, because none of the six is tied to a coordinate, and an adversary holding it can generate a network with the same character and no shared streets. A perturbed copy of real geometry reproduces the source almost exactly, including every street a resident would recognise. It leaks the source's topology in full, because perturbing coordinates leaves the graph structure untouched, and an adversary can recover the original by matching that structure against any public network — perturbation does not obscure a graph, only its drawing. A footer records the asymmetry that decides between them: the profile is smaller, reproduces the property consumers actually use, and carries no address, while the perturbed copy reproduces a property nobody asked for and carries every address in the source. Perturbing coordinates does not obscure a graph, only its drawing fitted structural profile six numbers, no coordinates reproduces routing efficiency, turn frequency, block structure leaks nothing tied to a place an adversary gets a network of the same character and no shared streets perturbed copy of real geometry every street, moved a little reproduces the source almost exactly, recognisably so leaks the source topology in full — perturbation leaves the graph intact an adversary recovers the original by structural matching against any public network The profile is smaller, more useful, and carries no address. It reproduces the property consumers actually use; the perturbed copy reproduces a property nobody asked for and every address in the source.
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.