duckOSM and OSMnx¶
OSMnx is the standard Python library for street networks from OSM, so
it is a useful independent check of what duckOSM extracts. The two don't use the same road filter or
the same graph model, so compare length of road per highway class, not totals and not edge
counts.
Line them up first¶
Road filter. Use OSMnx's drive_service, the closest to duckOSM's driving network:
| Network | Service roads |
|---|---|
OSMnx drive |
none |
OSMnx drive_service |
yes, but not service=parking, parking_aisle, emergency_access or private |
duckOSM driving |
all, including parking aisles and driveways; private ones are drawn but not routable |
Pedestrian streets. OSMnx's drive and drive_service leave highway=pedestrian out. duckOSM
keeps the pedestrian streets that are open to cars, as living_street.
Access tags. duckOSM reads the most specific tag (motorcar, then motor_vehicle, vehicle,
access). OSMnx drops any way with motor_vehicle=no, motorcar=no or access=private. duckOSM
keeps access=private roads in private_edges, drawn on its maps but never routed (see
Access).
Border. graph_from_bbox drops every edge that crosses the box. Pass truncate_by_edge=True,
and cut both networks to the same area before measuring. Here the area is duckOSM's own boundary
polygon, because the box around Monaco also holds French roads that duckOSM left out.
Length. Measure in a metric CRS, the UTM zone of the data. Web Mercator (EPSG:3857) makes lengths about 1.5 times too long at this latitude.
Recipe¶
Run it in an environment that has osmnx, duckdb and geopandas. It needs a live connection to
the Overpass API.
import osmnx as ox, duckdb, geopandas as gpd, pandas as pd
con = duckdb.connect("area.duckdb", read_only=True)
con.execute("LOAD spatial")
# the area to measure: duckOSM's boundary
area = gpd.GeoSeries.from_wkb(
con.execute("SELECT ST_AsWKB(geom) FROM main.boundary").df().iloc[:, 0].map(bytes),
crs=4326).union_all()
utm = gpd.GeoSeries([area], crs=4326).estimate_utm_crs() # metres (EPSG:32632 for Monaco)
cut = gpd.GeoSeries([area], crs=4326).to_crs(utm).iloc[0]
def metres(gdf, name):
gdf = gdf.to_crs(utm)
gdf["highway"] = gdf["highway"].map(lambda h: h[0] if isinstance(h, list) else h)
gdf["m"] = gdf.clip(cut).length
return gdf.groupby("highway")["m"].sum().rename(name)
def osmnx_m(network_type, truncate):
G = ox.graph_from_bbox(area.bounds, network_type=network_type, truncate_by_edge=truncate)
return metres(ox.convert.graph_to_gdfs(ox.convert.to_undirected(G), nodes=False), network_type)
duck = gpd.GeoDataFrame(con.execute(
"SELECT highway, ST_AsWKB(geometry) AS geometry FROM driving.edges WHERE NOT is_reverse").df())
duck["geometry"] = gpd.GeoSeries.from_wkb(duck["geometry"].map(bytes), crs=4326)
duck = metres(duck.set_geometry("geometry"), "duckOSM")
t = pd.concat([osmnx_m("drive", False), osmnx_m("drive", True),
osmnx_m("drive_service", True), duck], axis=1).fillna(0).round()
t.columns = ["drive", "drive + truncate", "drive_service + truncate", "duckOSM"]
t = t[t.sum(axis=1) > 0].sort_values("duckOSM", ascending=False)
t.loc["total"] = t.sum()
print(t.to_string())
When a simplified OSMnx edge merges several OSM ways, its highway is a list; the recipe takes the
first element. NOT is_reverse keeps one direction of each two-way street, to match the undirected
OSMnx graph.
An example¶
Measured on Monaco with the recipe above: metres of road inside the boundary of the duckOSM build (OSM data as of the run; a new run can differ by a few hundred metres).
highway |
OSMnx drive |
drive + truncate |
drive_service + truncate |
duckOSM | Difference |
|---|---|---|---|---|---|
| residential | 20,108 | 20,132 | 19,993 | 19,810 | −183 |
| service | 0 | 0 | 12,898 | 12,023 | −875 |
| secondary | 10,451 | 10,451 | 10,451 | 10,554 | +103 |
| tertiary | 8,492 | 8,492 | 8,492 | 8,492 | 0 |
| primary | 6,139 | 6,486 | 6,486 | 6,486 | 0 |
| primary_link | 1,018 | 1,018 | 1,018 | 1,018 | 0 |
| unclassified | 427 | 427 | 554 | 427 | −127 |
| secondary_link | 520 | 520 | 520 | 391 | −129 |
| living_street | 89 | 89 | 89 | 89 | 0 |
| tertiary_link | 45 | 45 | 45 | 45 | 0 |
| total | 47,289 | 47,660 | 60,546 | 59,335 | −2.0% |
The difference is duckOSM minus drive_service + truncate.
- Truncate adds 371 m, 347 m of it on
primary: the edges that cross the border. - service −875 m: mostly roads tagged
motor_vehicle=privatewithout anaccesstag (1,226 m in Monaco). duckOSM reads that tag and moves them toprivate_edges; OSMnx drops onlyaccess=private, so it keeps them. Smaller differences go the other way. - residential, secondary, unclassified, secondary_link: differences of 100 to 200 m each.
Where OSMnx merges several ways into one edge, its
highwayis a list, and the recipe takes the first element, so a class can shift to a neighbour from run to run. - living_street: equal here. The extra
living_streetfrom pedestrian streets open to cars (see above) did not show up in Monaco.
tertiary, primary, primary_link, living_street and tertiary_link match to the metre:
where the filters agree, the geometry agrees.
What it is good for¶
Good for checking geometry and clipping, and for noticing a whole road class that went missing. Not good for counts of edges or nodes: OSMnx also splits and merges, but duckOSM cuts every mode at the same points, including where a footway or crosswalk meets a road, merges chains by its own rules, and adds virtual nodes, so its counts can't match OSMnx's. OSMnx downloads from the Overpass API, so keep the area small.