Source code: Query Overture Maps with DuckDB spatial and GeoParquet in Python
Runnable Python scripts, requirements and console output for the tutorial “Query Overture Maps with DuckDB spatial and GeoParquet in Python”.
Last updated
These are the exact scripts used to produce every figure and number in Query Overture Maps with DuckDB spatial and GeoParquet in Python. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- overture_duckdb.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython overture_duckdb.pyoverture_duckdb.py
"""Query Overture Maps with DuckDB spatial and GeoParquet: Bristol's buildings and places.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/geoparquet-duckdb-overture-maps/Part 1 of "Cloud-native vector GIS with Python". Part 2 (vector-tiles-pmtiles-maplibre) turns theGeoParquet files this script writes to output/ into PMTiles vector tiles.
Data: Overture Maps Foundation, latest release (read from the STAC catalogue at run time).Buildings, water: ODbL-1.0 (© OpenStreetMap contributors, Microsoft ML Buildings, ...).Places: CDLA-Permissive-2.0 / Apache-2.0 / CC0-1.0, depending on the source of each record."""import jsonimport statisticsimport sysimport timefrom pathlib import Path
import duckdbimport geopandas as gpdimport matplotlibimport pandas as pdimport pyarrowimport pyarrow.parquet as pqimport pyogrioimport requestsimport shapely
matplotlib.use("Agg")import matplotlib.pyplot as plt # noqa: E402from matplotlib.patches import Patch # noqa: E402
HERE = Path(__file__).resolve().parentDATA = HERE / "data" # downloads and benchmark files (not committed)OUT = HERE / "output" # figures, console log and the GeoParquet files used in Part 2DATA.mkdir(exist_ok=True)OUT.mkdir(exist_ok=True)
BBOX = (-2.66, 51.42, -2.52, 51.50) # xmin, ymin, xmax, ymax in lon/lat: central Bristol, about 9.7 x 8.9 kmCENTRE = (-2.600, 51.452, -2.578, 51.462) # Broadmead and Cabot Circus shopping area, for the zoomed mapH3_RES = 9 # H3 resolution 9: hexagons of about 0.1 km²BNG = "EPSG:27700" # British National Grid, for areas in m²
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
def section(title: str) -> None: print(f"\n=== {title} ===")
def timed(fn, repeat: int = 1): """Run fn() `repeat` times; return the last result and the median wall-clock time in seconds.""" times = [] for _ in range(repeat): t0 = time.perf_counter() result = fn() times.append(time.perf_counter() - t0) return result, statistics.median(times)
def size_mb(path: Path) -> float: """File size in MB; for a Shapefile, add up all its sidecar files.""" files = list(path.parent.glob(path.stem + ".*")) if path.suffix == ".shp" else [path] return sum(f.stat().st_size for f in files) / 1e6
section("versions")print(f"python {sys.version.split()[0]} | duckdb {duckdb.__version__} | geopandas {gpd.__version__} | " f"pyogrio {pyogrio.__version__} (GDAL {pyogrio.__gdal_version_string__}) | pyarrow {pyarrow.__version__} | " f"shapely {shapely.__version__}")
# ---------------------------------------------------------------------------- 1. latest releasesection("Overture release")catalog = requests.get("https://stac.overturemaps.org/catalog.json", timeout=60).json()RELEASE = catalog["latest"]release_cat = requests.get(f"https://stac.overturemaps.org/{RELEASE}/catalog.json", timeout=60).json()print(f"latest release: {RELEASE} (schema {release_cat['schema:version']})")print("themes:", ", ".join(l["title"] for l in release_cat["links"] if l["rel"] == "child"))
con = duckdb.connect()con.sql("INSTALL spatial; LOAD spatial; INSTALL httpfs; LOAD httpfs; INSTALL h3 FROM community; LOAD h3;")con.sql("SET s3_region = 'us-west-2'") # the bucket is public: no credentials needed
# ---------------------------------------------------------------------------- 2. find the files with STACsection("STAC index: which files cover Bristol?")con.sql(f""" CREATE TABLE stac AS SELECT collection, num_rows, assets.aws."file:size" AS bytes, assets.aws.alternate.s3.href AS s3_url, bbox FROM read_parquet('https://stac.overturemaps.org/{RELEASE}/collections.parquet')""")xmin, ymin, xmax, ymax = BBOXOVERLAPS = f"bbox.xmin <= {xmax} AND bbox.xmax >= {xmin} AND bbox.ymin <= {ymax} AND bbox.ymax >= {ymin}"print(con.sql(f""" SELECT collection, count(*) AS files, sum(num_rows)::BIGINT AS features, round(sum(bytes) / 1e9, 1) AS total_gb, count(*) FILTER (WHERE {OVERLAPS}) AS files_over_bristol FROM stac WHERE collection IN ('building', 'place', 'water') GROUP BY ALL ORDER BY collection""").df().to_string(index=False))
def files_for(collection: str) -> list[str]: return [r[0] for r in con.sql(f"SELECT s3_url FROM stac WHERE collection = '{collection}' AND {OVERLAPS}").fetchall()]
# ---------------------------------------------------------------------------- 3. bbox query: naive vs prunedsection("bbox query: whole theme vs STAC-pruned file list")# The pruned query runs first, so DuckDB's cache of remote file data cannot flatter it.n_stac, t_stac = timed(lambda: con.sql( f"SELECT count(*) FROM read_parquet({files_for('building')}) WHERE {OVERLAPS}").fetchone()[0])print(f"STAC-pruned file list, bbox filter : {n_stac:,} buildings in {t_stac:.1f} s")glob = f"s3://overturemaps-us-west-2/release/{RELEASE}/theme=buildings/type=building/*.parquet"n_glob, t_glob = timed(lambda: con.sql(f"SELECT count(*) FROM read_parquet('{glob}') WHERE {OVERLAPS}").fetchone()[0])n_files = con.sql("SELECT count(*) FROM stac WHERE collection = 'building'").fetchone()[0]print(f"all {n_files} building files, bbox filter : {n_glob:,} buildings in {t_glob:.1f} s " f"({t_glob / t_stac:.0f}x slower)")
# ---------------------------------------------------------------------------- 4. download to GeoParquetsection("download Bristol to local GeoParquet")queries = { # `sources` lists who supplied what: an empty `property` means the whole feature, # '/properties/height' means only the height came from that source. "buildings": f""" SELECT id, names.primary AS name, subtype, class, height, num_floors, list_filter(sources, s -> s.property = '')[1].dataset AS source, list_filter(sources, s -> s.property = '')[1].license AS licence, list_filter(sources, s -> s.property = '/properties/height')[1].dataset AS height_source, geometry FROM read_parquet({files_for('building')}) WHERE {OVERLAPS}""", "places": f""" SELECT id, names.primary AS name, basic_category, taxonomy.hierarchy[1] AS category_group, round(confidence, 3) AS confidence, list_filter(sources, s -> s.property = '')[1].dataset AS source, list_filter(sources, s -> s.property = '')[1].license AS licence, geometry FROM read_parquet({files_for('place')}) WHERE {OVERLAPS}""", # Water polygons can be huge (the Severn estuary), so clip them to the box. "water": f""" SELECT id, names.primary AS name, subtype, class, ST_Intersection(geometry, ST_MakeEnvelope({xmin}, {ymin}, {xmax}, {ymax})) AS geometry FROM read_parquet({files_for('water')}) WHERE {OVERLAPS} AND ST_GeometryType(geometry) IN ('POLYGON', 'MULTIPOLYGON')""",}for name, sql in queries.items(): path = DATA / f"bristol_{name}_raw.parquet" _, t = timed(lambda: con.sql(f"COPY ({sql}) TO '{path.as_posix()}' (FORMAT parquet, COMPRESSION zstd)")) con.sql(f"CREATE OR REPLACE TABLE {name} AS SELECT * FROM '{path.as_posix()}'") n = con.sql(f"SELECT count(*) FROM {name}").fetchone()[0] print(f"{name:<9} {n:>7,} features {size_mb(path):5.1f} MB {t:5.1f} s")
# ---------------------------------------------------------------------------- 5. what is in the data?section("sources and licences")print(con.sql(""" SELECT 'buildings' AS theme, source, licence, count(*) AS n FROM buildings GROUP BY ALL UNION ALL SELECT 'places', source, licence, count(*) FROM places GROUP BY ALL ORDER BY theme, n DESC""").df().to_string(index=False))
section("buildings: subtype and height coverage")print(con.sql(""" SELECT coalesce(subtype, '(none)') AS subtype, count(*) AS n, round(100 * count(*) / sum(count(*)) OVER (), 1) AS pct, round(100 * count(height) / count(*), 1) AS pct_with_height FROM buildings GROUP BY ALL ORDER BY n DESC LIMIT 8""").df().to_string(index=False))print(con.sql(""" SELECT CASE WHEN height IS NULL THEN '(no height)' ELSE coalesce(height_source, source) END AS height_from, count(*) AS n, round(median(height), 1) AS median_height_m FROM buildings GROUP BY ALL ORDER BY n DESC""").df().to_string(index=False))
section("places: top-level categories")print(con.sql(""" SELECT coalesce(category_group, '(none)') AS category_group, count(*) AS n, round(median(confidence), 2) AS median_confidence FROM places GROUP BY ALL ORDER BY n DESC""").df().to_string(index=False))
# ---------------------------------------------------------------------------- 6. spatial SQLsection("spatial SQL: footprint areas in British National Grid")con.sql(f""" ALTER TABLE buildings ADD COLUMN area_m2 DOUBLE; UPDATE buildings SET area_m2 = round(ST_Area(ST_Transform(geometry, 'OGC:CRS84', '{BNG}')), 1);""")print(con.sql(""" SELECT count(*) AS buildings, round(sum(area_m2) / 1e6, 2) AS footprint_km2, round(median(area_m2)) AS median_m2, round(max(area_m2)) AS max_m2 FROM buildings""").df().to_string(index=False))print(con.sql(""" SELECT name, subtype, round(area_m2) AS area_m2 FROM buildings WHERE name IS NOT NULL ORDER BY area_m2 DESC LIMIT 5""").df().to_string(index=False))
section("spatial SQL: which places are inside a building?")_, t_join = timed(lambda: con.sql(""" CREATE OR REPLACE TABLE place_building AS SELECT p.id AS place_id, b.id AS building_id FROM places p JOIN buildings b ON ST_Intersects(b.geometry, p.geometry)"""))n_places = con.sql("SELECT count(*) FROM places").fetchone()[0]n_inside = con.sql("SELECT count(DISTINCT place_id) FROM place_building").fetchone()[0]print(f"point-in-polygon join of {n_places:,} places x {n_stac:,} buildings: {t_join:.2f} s")print(f"places inside a building footprint: {n_inside:,} ({100 * n_inside / n_places:.1f}%)")print(con.sql(""" SELECT b.name, b.subtype, round(b.area_m2) AS area_m2, count(*) AS places FROM place_building pb JOIN buildings b ON b.id = pb.building_id GROUP BY ALL ORDER BY places DESC LIMIT 6""").df().to_string(index=False))
section(f"spatial SQL: aggregate to H3 resolution {H3_RES} hexagons")_, t_h3 = timed(lambda: con.sql(f""" CREATE OR REPLACE TABLE hex AS WITH b AS ( SELECT h3_latlng_to_cell(ST_Y(ST_Centroid(geometry)), ST_X(ST_Centroid(geometry)), {H3_RES}) AS h3, count(*) AS buildings, sum(area_m2) AS footprint_m2, count(*) FILTER (WHERE subtype = 'residential') AS residential FROM buildings GROUP BY h3), p AS ( SELECT h3_latlng_to_cell(ST_Y(geometry), ST_X(geometry), {H3_RES}) AS h3, count(*) AS places, count(*) FILTER (WHERE category_group = 'food_and_drink') AS food_drink FROM places GROUP BY h3) SELECT h3_h3_to_string(h3) AS h3, coalesce(buildings, 0) AS buildings, coalesce(residential, 0) AS residential, coalesce(places, 0) AS places, coalesce(food_drink, 0) AS food_drink, round(100 * coalesce(footprint_m2, 0) / h3_cell_area(h3, 'm^2'), 1) AS built_pct, ST_GeomFromText(h3_cell_to_boundary_wkt(h3)) AS geometry FROM b FULL JOIN p USING (h3) -- keep whole hexagons only: edge cells would be cut by the download box WHERE ST_Within(ST_GeomFromText(h3_cell_to_boundary_wkt(h3)), ST_MakeEnvelope({xmin}, {ymin}, {xmax}, {ymax}))"""))print(f"built {con.sql('SELECT count(*) FROM hex').fetchone()[0]} hexagons in {t_h3:.2f} s")print(con.sql(""" SELECT h3, buildings, built_pct, places, food_drink FROM hex ORDER BY built_pct DESC LIMIT 5""").df().to_string(index=False))print(con.sql(""" SELECT h3, buildings, built_pct, places, food_drink FROM hex ORDER BY food_drink DESC LIMIT 5""").df().to_string(index=False))print(con.sql(""" SELECT round(corr(built_pct, places), 2) AS r_built_places, round(corr(residential::DOUBLE / greatest(buildings, 1), food_drink), 2) AS r_residential_share_food FROM hex WHERE buildings > 0""").df().to_string(index=False))
# ---------------------------------------------------------------------------- 7. GeoPandas and GeoParquetsection("DuckDB -> GeoPandas")
def to_gdf(table: str) -> gpd.GeoDataFrame: """DuckDB table -> GeoDataFrame, passing geometry as WKB.""" df = con.sql(f"SELECT * EXCLUDE geometry, ST_AsWKB(geometry) AS wkb FROM {table}").df() geom = shapely.from_wkb(df.pop("wkb").map(bytes)) return gpd.GeoDataFrame(df, geometry=geom, crs="OGC:CRS84")
gdfs, t_gdf = timed(lambda: {t: to_gdf(t) for t in ("buildings", "places", "water", "hex")})buildings, places, water, hexes = (gdfs[k] for k in ("buildings", "places", "water", "hex"))print(f"4 tables to GeoDataFrames in {t_gdf:.2f} s; buildings: {len(buildings):,} rows, crs {buildings.crs.name}")print(buildings.drop(columns="geometry").head(3).to_string(index=False))
gdf_raw = gpd.read_parquet(DATA / "bristol_buildings_raw.parquet")print(f"gpd.read_parquet on DuckDB's file: {len(gdf_raw):,} rows, geometry column '{gdf_raw.geometry.name}', " f"crs {gdf_raw.crs.to_string() if gdf_raw.crs else None}")
section("write GeoParquet 1.1 with GeoPandas")for name, gdf in gdfs.items(): gdf.to_parquet(OUT / f"bristol_{name}.parquet", compression="zstd", schema_version="1.1.0", write_covering_bbox=True) print(f"output/bristol_{name}.parquet {len(gdf):>7,} rows {size_mb(OUT / f'bristol_{name}.parquet'):5.2f} MB")
geo = json.loads(pq.read_metadata(OUT / "bristol_buildings.parquet").metadata[b"geo"])col = geo["columns"]["geometry"]print("'geo' metadata (GeoPandas):", json.dumps({ "version": geo["version"], "primary_column": geo["primary_column"], "encoding": col["encoding"], "geometry_types": col["geometry_types"], "bbox": [round(v, 4) for v in col["bbox"]], "covering": col.get("covering"), "crs": "PROJJSON " + col["crs"]["id"]["authority"] + ":" + str(col["crs"]["id"]["code"])}))geo_ddb = json.loads(pq.read_metadata(DATA / "bristol_buildings_raw.parquet").metadata[b"geo"])print("'geo' metadata (DuckDB): ", json.dumps({k: geo_ddb[k] for k in ("version", "primary_column")}), "| keys of the geometry column:", sorted(geo_ddb["columns"]["geometry"]))
# ---------------------------------------------------------------------------- 8. format benchmarksection("format benchmark: buildings")bench = buildings.drop(columns=["licence", "height_source"])small = (-2.596, 51.447, -2.582, 51.456) # ~1 km box: Temple Meads to RedcliffeBENCH = DATA / "bench"BENCH.mkdir(exist_ok=True)formats = { "GeoParquet (DuckDB)": BENCH / "duckdb.parquet", "GeoParquet (GeoPandas)": BENCH / "geopandas.parquet", "FlatGeobuf": BENCH / "buildings.fgb", "GeoPackage": BENCH / "buildings.gpkg", "Shapefile": BENCH / "buildings.shp",}for p in BENCH.iterdir(): p.unlink()con.sql("CREATE OR REPLACE TABLE bench AS SELECT * EXCLUDE (licence, height_source) FROM buildings")writers = { "GeoParquet (DuckDB)": lambda p: con.sql(f"COPY bench TO '{p.as_posix()}' (FORMAT parquet, COMPRESSION zstd)"), "GeoParquet (GeoPandas)": lambda p: bench.to_parquet(p, compression="zstd", write_covering_bbox=True, schema_version="1.1.0"), "FlatGeobuf": lambda p: pyogrio.write_dataframe(bench, p, driver="FlatGeobuf"), "GeoPackage": lambda p: pyogrio.write_dataframe(bench, p, driver="GPKG"), "Shapefile": lambda p: pyogrio.write_dataframe(bench, p, driver="ESRI Shapefile"),}readers = { "GeoParquet (DuckDB)": lambda p, bbox=None: gpd.read_parquet(p) if bbox is None else con.sql( f"SELECT count(*) FROM '{p.as_posix()}' WHERE ST_Intersects(geometry, ST_MakeEnvelope{bbox})").fetchone(), "GeoParquet (GeoPandas)": lambda p, bbox=None: gpd.read_parquet(p, bbox=bbox),}rows = []for fmt, path in formats.items(): _, t_write = timed(lambda: writers[fmt](path), repeat=3) read = readers.get(fmt, lambda p, bbox=None: pyogrio.read_dataframe(p, bbox=bbox, use_arrow=True)) full, t_read = timed(lambda: read(path), repeat=3) sub, t_bbox = timed(lambda: read(path, bbox=small), repeat=3) n_sub = sub[0] if isinstance(sub, tuple) else len(sub) rows.append({"format": fmt, "size_MB": round(size_mb(path), 1), "write_s": round(t_write, 2), "read_s": round(t_read, 2), "bbox_read_s": round(t_bbox, 3), "bbox_rows": n_sub})bench_df = pd.DataFrame(rows)print(bench_df.to_string(index=False))print("Shapefile field names:", list(pyogrio.read_info(formats["Shapefile"])["fields"]))print("note: the DuckDB bbox query counts rows with ST_Intersects; the others return a GeoDataFrame")
# ---------------------------------------------------------------------------- 9. figuressection("figures")b27, w27, p27, h27 = (g.to_crs(BNG) for g in (buildings, water, places, hexes))X0, Y0, X1, Y1 = gpd.GeoSeries([shapely.box(*BBOX)], crs="OGC:CRS84").to_crs(BNG).total_bounds
# Figure 1: buildings by subtypeSUBTYPE_COLOURS = {"residential": "#e8a33c", "commercial": "#c21f3a", "education": "#5e3c99", "industrial": "#6b6b6b", "outbuilding": "#a6761d", "(other)": "#1b9e77", "(none)": "#9aa3a8"}main = list(SUBTYPE_COLOURS)[:-2]b27["group"] = b27["subtype"].where(b27["subtype"].isin(main), "(other)").where(b27["subtype"].notna(), "(none)")fig, ax = plt.subplots(figsize=(10, 9))w27.plot(ax=ax, color="#9ecae1", linewidth=0)for g, colour in SUBTYPE_COLOURS.items(): b27[b27["group"] == g].plot(ax=ax, color=colour, linewidth=0)ax.set_xlim(X0, X1)ax.set_ylim(Y0, Y1)ax.set_axis_off()counts = b27["group"].value_counts()ax.legend(handles=[Patch(color=c, label=f"{g} ({counts.get(g, 0):,})") for g, c in SUBTYPE_COLOURS.items()] + [Patch(color="#9ecae1", label="water")], loc="lower left", framealpha=0.9, title="building subtype")ax.set_title(f"{len(b27):,} Overture buildings in central Bristol (release {RELEASE})", loc="left")ax.text(1, -0.01, "© OpenStreetMap contributors, Microsoft, Overture Maps Foundation (ODbL)", transform=ax.transAxes, ha="right", va="top", fontsize=7, color="#4a5553")fig.savefig(OUT / "01-bristol-buildings-subtype.png")plt.close(fig)
# Figure 2: places by source/licence and by categoryLIC_COLOURS = {"CDLA-Permissive-2.0": "#1543b0", "Apache-2.0": "#e8a33c", "CC0-1.0": "#1b9e77"}src = places.groupby(["source", "licence"]).size().reset_index(name="n").sort_values("n")cat = places["category_group"].fillna("(none)").value_counts().sort_values()fig, (a1, a2) = plt.subplots(1, 2, figsize=(11, 4.2), gridspec_kw={"width_ratios": [1, 1.3]})a1.barh(src["source"], src["n"], color=[LIC_COLOURS.get(l, "#9aa3a8") for l in src["licence"]])for y, n in enumerate(src["n"]): a1.text(n, y, f" {n:,}", va="center", fontsize=8)a1.set_xlim(0, src["n"].max() * 1.2)a1.set_title("Places by source (colour = licence)", loc="left")a1.legend(handles=[Patch(color=c, label=l) for l, c in LIC_COLOURS.items()], loc="lower right")a2.barh(cat.index.str.replace("_", " "), cat.values, color="#5e3c99")for y, n in enumerate(cat.values): a2.text(n, y, f" {n:,}", va="center", fontsize=8)a2.set_xlim(0, cat.max() * 1.15)a2.set_title("Places by top-level category", loc="left")for a in (a1, a2): a.spines[["top", "right"]].set_visible(False)fig.tight_layout()fig.savefig(OUT / "02-places-sources-categories.png")plt.close(fig)
# Figure 3: H3 hexagonsfig, axes = plt.subplots(1, 2, figsize=(12, 5.6), layout="constrained")for ax, col_, title, cmap in [(axes[0], "built_pct", "Building footprint cover (% of hexagon)", "Spectral_r"), (axes[1], "food_drink", "Food and drink places per hexagon", "magma_r")]: w27.plot(ax=ax, color="#9ecae1", linewidth=0) h27.plot(ax=ax, column=col_, cmap=cmap, linewidth=0.2, edgecolor="white", alpha=0.9, legend=True, legend_kwds={"shrink": 0.7}) ax.set_xlim(X0, X1) ax.set_ylim(Y0, Y1) ax.set_axis_off() ax.set_title(title, loc="left")fig.suptitle(f"{len(h27)} H3 resolution-{H3_RES} hexagons over central Bristol, aggregated in DuckDB", x=0.02, ha="left", fontsize=10)fig.savefig(OUT / "03-h3-hexagons.png")plt.close(fig)
# Figure 4: zoom on the shopping centre: places inside vs outside buildingscx0, cy0, cx1, cy1 = gpd.GeoSeries([shapely.box(*CENTRE)], crs="OGC:CRS84").to_crs(BNG).total_boundsper_b = con.sql("SELECT building_id AS id, count(*) AS places FROM place_building GROUP BY 1").df()bz = b27.cx[cx0:cx1, cy0:cy1].merge(per_b, on="id", how="left").fillna({"places": 0})pz = p27.cx[cx0:cx1, cy0:cy1].copy()inside_ids = set(con.sql("SELECT place_id FROM place_building").df()["place_id"])pz["inside"] = pz["id"].isin(inside_ids)fig, ax = plt.subplots(figsize=(10, 6.2))w27.plot(ax=ax, color="#9ecae1", linewidth=0)bz[bz["places"] == 0].plot(ax=ax, color="#d9dcdb", edgecolor="#b5bab8", linewidth=0.3)bz[bz["places"] > 0].plot(ax=ax, column="places", cmap="YlOrRd", edgecolor="#7a7f7d", linewidth=0.3, legend=True, legend_kwds={"label": "places inside building", "shrink": 0.7}, vmax=max(bz["places"].quantile(0.99), 5))pz[pz["inside"]].plot(ax=ax, color="#0d1315", markersize=3)pz[~pz["inside"]].plot(ax=ax, color="#1543b0", markersize=5, marker="x", linewidth=0.8)ax.set_xlim(cx0, cx1)ax.set_ylim(cy0, cy1)ax.set_axis_off()ax.legend(handles=[plt.Line2D([], [], color="#0d1315", marker="o", ls="", ms=3, label="place inside a building"), plt.Line2D([], [], color="#1543b0", marker="x", ls="", ms=5, label="place outside every building")], loc="lower left", framealpha=0.9)ax.set_title(f"Broadmead and Cabot Circus: {int(pz['inside'].sum()):,} of {len(pz):,} places fall inside a footprint", loc="left")fig.savefig(OUT / "04-places-in-buildings.png")plt.close(fig)
# Figure 5: format benchmarkfig, axes = plt.subplots(1, 4, figsize=(13, 3.6), sharey=True)colours = ["#1543b0", "#5b8bd9", "#1b9e77", "#e8a33c", "#c21f3a"]for ax, col_, title in zip(axes, ["size_MB", "write_s", "read_s", "bbox_read_s"], ["File size (MB)", "Write (s)", "Read all (s)", "Read 1 km box (s)"]): ax.barh(bench_df["format"], bench_df[col_], color=colours) for y, v in enumerate(bench_df[col_]): ax.text(v, y, f" {v:g}", va="center", fontsize=8) ax.set_xlim(0, bench_df[col_].max() * 1.35) ax.set_title(title, loc="left") ax.spines[["top", "right"]].set_visible(False)axes[0].invert_yaxis()fig.suptitle(f"{len(bench):,} building polygons, {len(bench.columns) - 1} attribute columns (median of 3 runs)", x=0.01, ha="left", fontsize=10)fig.tight_layout()fig.savefig(OUT / "05-format-benchmark.png")plt.close(fig)
print("wrote", ", ".join(sorted(p.name for p in OUT.glob("*.png"))))