Street Orientation Looks Wrong
On this page
Problem statement
The polar plot of a grid city shows eight spikes instead of four, or a smeared ring where there should be a cross. The orientation entropy of Manhattan comes out close to Rome's. A published value for a city cannot be reproduced. Street orientation is a short calculation with several places where a default quietly changes the answer: the graph is directed, the segments are counted instead of measured, the bins straddle north, or bearings are taken in a projected coordinate system.
Quick answer
Four checks, in order:
- Undirected graph — every two-way street once, not twice.
- Length weighting — metres of street per direction, not segment counts.
- Bins centred on the cardinal directions — so a north–south street is not split between two bins.
- Bearings from latitude/longitude — not from a projected grid.
import numpy as np, osmnx as ox
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
G = ox.graph_from_point((40.7549, -73.9840), dist=1500, network_type="drive") # Midtown
def H(g, weight=None, bins=36):
return ox.bearing.orientation_entropy(g, num_bins=bins, weight=weight)
directed = ox.bearing.add_edge_bearings(G)
undirected = ox.bearing.add_edge_bearings(ox.convert.to_undirected(G))
print(f"directed graph, counts: {H(directed):.3f} ({len(directed.edges)} edges)")
print(f"undirected graph, counts: {H(undirected):.3f} ({len(undirected.edges)} edges)")
print(f"undirected graph, by length: {H(undirected, 'length'):.3f}")
print(f"perfect four-way grid: {np.log(4):.3f}; uniform: {np.log(36):.3f}")
directed graph, counts: 1.873 (1294 edges)
undirected graph, counts: 1.897 (1213 edges)
undirected graph, by length: 1.700
perfect four-way grid: 1.386; uniform: 3.584
Undirecting the graph barely moves Midtown's value; weighting by length moves it most, from 1.897 to 1.700, because short connectors point every way and count as much as an avenue when segments are counted. Even the best value stays above the perfect-grid 1.386: Broadway cuts diagonally across the grid, and ramps and connectors add bearings no plan intended.
Undirected, length-weighted, right bins, unprojected bearings
1. Convert to undirected before computing bearings
A directed graph stores each two-way street as two edges pointing in opposite directions. osmnx folds bearings onto both directions anyway, so the extra edges double the weight of two-way streets relative to one-way ones.
2. Weight by length
Short segments at junctions, ramps and roundabouts point in every direction. Counting them equally with long avenues inflates entropy.
3. Check the bin layout
osmnx centres its bins on 0°, 10°, 20°… A grid that is exactly north–south lands in single bins; a rotated grid straddles two. That is a real property of the measure, but it means entropy is not rotation-invariant. For a rotation-free comparison, find each city's dominant direction and rotate first.
4. Compute bearings on the unprojected graph
ox.bearing.add_edge_bearings uses latitude and longitude. On a projected graph "north" is grid north, which can differ from true north by degrees away from the projection's central meridian.
5. Compare only like with like
Same network type, same extent, same weighting and bins. Published values often use the whole city, not a centre.
The Manhattan rotation and projected bearings
The data used on this page
OpenStreetMap's drive network within 1.5 km of Midtown Manhattan (40.7549° N, 73.9840° W), fetched by the quick answer through OSMnx and the maps.mail.ru Overpass mirror; the setup unpacks that answer as saved on 10 October 2026.
import io
import os
import requests
import zipfile
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-street-orientation-wrong-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
Example 1 — the Manhattan rotation
b = np.array([d["bearing"] for *_, d in undirected.edges(data=True)]) % 180
L = np.array([d["length"] for *_, d in undirected.edges(data=True)])
hist, edges = np.histogram(b, bins=180, range=(0, 180), weights=L)
peak = edges[np.argmax(hist)]
print(f"dominant bearing: {peak:.0f}° (and {peak + 90 if peak < 90 else peak - 90:.0f}°)")
shifted = (b - (peak % 90)) % 180
h_rot, _ = np.histogram(np.r_[shifted, shifted + 180] % 360, bins=36, range=(-5, 355),
weights=np.r_[L, L])
p = h_rot / h_rot.sum()
print(f"entropy after rotating the grid onto north: {-(p[p > 0] * np.log(p[p > 0])).sum():.3f}")
dominant bearing: 119° (and 29°)
entropy after rotating the grid onto north: 1.644
Rotating the dominant direction onto the bin centres lowers the entropy only a little, from 1.700 to 1.644. Manhattan's 29° happens to sit near the centre of the 30° bin, so little of its apparent disorder was the bin layout. A grid near 45°, such as Barcelona's Eixample, straddles bin edges and gains much more from the same rotation.
Example 2 — projected bearings
from pyproj import Geod
Gp = ox.project_graph(G, to_crs="EPSG:3857") # Web Mercator: conformal
nodes = ox.graph_to_gdfs(G, edges=False)
nodes_p = ox.graph_to_gdfs(Gp, edges=False)
u, v, _ = list(G.edges(keys=True))[0]
geod = Geod(ellps="WGS84")
true_bearing = geod.inv(nodes.x[u], nodes.y[u], nodes.x[v], nodes.y[v])[0] % 360
grid_bearing = np.degrees(np.arctan2(nodes_p.x[v] - nodes_p.x[u], nodes_p.y[v] - nodes_p.y[u])) % 360
print(f"one Midtown segment: true bearing {true_bearing:.2f}°, Web Mercator grid bearing {grid_bearing:.2f}°")
Gutm = ox.project_graph(G, to_crs="EPSG:32619") # UTM zone 19, far from its meridian
nu = ox.graph_to_gdfs(Gutm, edges=False)
utm_bearing = np.degrees(np.arctan2(nu.x[v] - nu.x[u], nu.y[v] - nu.y[u])) % 360
print(f"same segment in UTM zone 19N: {utm_bearing:.2f}° (grid convergence of the wrong zone)")
one Midtown segment: true bearing 29.14°, Web Mercator grid bearing 29.05°
same segment in UTM zone 19N: 32.39° (grid convergence of the wrong zone)
Web Mercator is cylindrical, so its grid north is true north everywhere and the difference is 0.1°. In the wrong UTM zone, grid convergence turns the same segment by 3.25° — and every bearing in the city by about the same, enough to push a grid that sat near a bin edge into the next bin.
Example 3 — segment counts against length
lengths = np.array([d["length"] for *_, d in undirected.edges(data=True)])
short = lengths < 30
print(f"segments under 30 m: {short.mean():.0%} of segments, "
f"{lengths[short].sum() / lengths.sum():.0%} of street length")
segments under 30 m: 9% of segments, 1% of street length
Short segments — junction connectors, turn lanes, ramps — are one segment in eleven here and one metre of street in a hundred. Counted as equals to avenues, they are what pushes the unweighted entropy up; in cities with complex junctions their share of segments is larger still.
Folded bearings and rotation
Why osmnx folds bearings
Streets are undirected objects, so a street at 30° is the same as one at 210°. osmnx adds each bearing and its reverse to the histogram, making the polar plot symmetric. On a directed graph each two-way street already appears in both directions, so it ends up counted four times against a one-way street's two.
Why entropy depends on rotation
Entropy is computed from binned counts. A distribution concentrated exactly on bin centres has the lowest entropy the bins allow; the same distribution shifted half a bin spreads each spike over two bins and gains entropy. Grids aligned with north look more ordered than equally regular rotated grids.
Why projections matter only sometimes
A conformal projection preserves angles locally, but its grid north differs from true north by the meridian convergence, which grows with distance from the central meridian. For a city in its own UTM zone the effect is small; for a mis-chosen zone it is a constant rotation of every bearing.
Simplified graphs, curves and network types
- Simplified vs unsimplified graphs change segment counts and, without length weighting, entropy.
- Curved streets are represented by their end-to-end bearing.
- Bins: 36 is conventional; fewer bins lower the entropy ceiling.
- Network type (
drivevsall) changes the result; compare like with like. - Polar plots should be drawn from the same undirected, length-weighted data as the entropy.
Internal links
- Street network orientation explained — what the measure means
- How to measure street orientation entropy — the workflow
- How to calculate street network metrics — other network measures
- Street networks as graphs explained — directed and undirected graphs
- Projected vs geographic CRS explained — grid north and true north
- How to build a street network graph — getting the graph
FAQ
Why does my grid city show eight spikes?
The grid is rotated relative to the bins, or the network mixes two grids. Check the dominant bearing.
Should I use a directed or undirected graph?
Undirected, so each street counts once.
Should I weight bearings by length?
Yes, unless the question is specifically about segment counts.
Why is entropy higher than expected for a grid?
Short segments, a rotation relative to the bins, or a directed graph.
Can I compute bearings on a projected graph?
osmnx expects latitude and longitude; projected bearings are grid bearings and can be rotated.
Why can't I reproduce a published entropy?
Different extent, network type, weighting, bins or graph direction.