From 2dcaed760069c6bb98adf9f68ae52c7c11c56b7c Mon Sep 17 00:00:00 2001 From: marvin Date: Mon, 14 Sep 2026 00:22:15 +0200 Subject: [PATCH] Add orphan bus stops (platforms not in route relations) Some bus stop platform nodes in OSM are not members of any route relation (e.g. Borgergade). These were missed by the route-membership download approach. Now download_osm.py: - tracks bus platform node ids captured by route queries - runs a supplementary query for all highway=bus_stop + platform nodes - appends orphans (with empty route_refs) to the bus stops layer render.py: orphan stops (empty route_refs) fall back to distance clustering instead of route-membership grouping. Results: 1164 route-member stops + 707 orphans = 1871 total -> 1370 after clip -> 1049 markers after grouping --- copenhagen/scripts/download_osm.py | 64 ++++++++++++++++++++++++++++++ copenhagen/scripts/render.py | 8 +++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/copenhagen/scripts/download_osm.py b/copenhagen/scripts/download_osm.py index f37d0c4..e797817 100644 --- a/copenhagen/scripts/download_osm.py +++ b/copenhagen/scripts/download_osm.py @@ -198,6 +198,40 @@ def parse_stations(data): return rows +def orphan_bus_stops_query(bbox): + s, n, w, e = bbox + return f"""[out:json][timeout:180]; +( + node["highway"="bus_stop"]["public_transport"="platform"]({s},{w},{n},{e}); +); +out body; +""" + + +def parse_orphan_bus_stops(data, known_node_ids): + """Parse standalone bus platform nodes not already captured by routes.""" + stops = [] + for e in data["elements"]: + if e["type"] != "node" or e["id"] in known_node_ids: + continue + tags = e.get("tags", {}) + if tags.get("public_transport") != "platform": + continue + name = tags.get("name") + if not name: + continue + stops.append({ + "mode": "bus", + "name": name, + "ref": tags.get("ref"), + "public_transport": tags.get("public_transport"), + "railway": tags.get("railway"), + "route_refs": "", # orphan — no route membership + "geometry": Point(e["lon"], e["lat"]), + }) + return stops + + def main(): modes = yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"] area = gpd.read_file(PROCESSED / "area.gpkg") @@ -205,12 +239,18 @@ def main(): bbox = (s, n, w, e) OSM_RAW.mkdir(parents=True, exist_ok=True) + bus_stop_node_ids = set() for mode, cfg in modes.items(): ql = build_query(cfg, bbox) (OSM_RAW / f"{mode}.overpassql").write_text(ql) print(f"[{mode}] querying Overpass bbox=({s:.4f},{w:.4f},{n:.4f},{e:.4f})...", flush=True) data = overpass(ql) lines, stops = parse(data, mode) + # track bus stop node ids to avoid dupes with orphans + if mode == "bus": + for e in data["elements"]: + if e["type"] == "node" and e.get("tags", {}).get("public_transport") == "platform": + bus_stop_node_ids.add(e["id"]) print(f" -> {len(lines)} lines, {len(stops)} stops", flush=True) if not lines: continue @@ -220,6 +260,30 @@ def main(): sgdf = gpd.GeoDataFrame(stops, crs="EPSG:4326") sgdf.to_file(OSM_RAW / f"{mode}.gpkg", driver="GPKG", layer="stops") + # orphan bus stops (platform nodes not in any route relation) + oql = orphan_bus_stops_query(bbox) + (OSM_RAW / "orphan_bus_stops.overpassql").write_text(oql) + print("[orphan_bus_stops] querying Overpass...", flush=True) + odata = overpass(oql) + orphans = parse_orphan_bus_stops(odata, bus_stop_node_ids) + print(f" -> {len(orphans)} orphan bus stops (not in route relations)", flush=True) + if orphans: + # append to bus.gpkg stops layer + existing = gpd.read_file(OSM_RAW / "bus.gpkg", layer="stops") + combined = gpd.GeoDataFrame( + __import__("pandas").concat([existing, gpd.GeoDataFrame(orphans, crs="EPSG:4326")], + ignore_index=True), + crs="EPSG:4326" + ) + # rewrite stops layer + import tempfile, shutil + tmp = OSM_RAW / "bus_tmp.gpkg" + lines_gdf = gpd.read_file(OSM_RAW / "bus.gpkg", layer="lines") + lines_gdf.to_file(tmp, driver="GPKG", layer="lines") + combined.to_file(tmp, driver="GPKG", layer="stops") + shutil.move(str(tmp), str(OSM_RAW / "bus.gpkg")) + print(f" -> bus stops layer now: {len(combined)} total", flush=True) + # stations (named station nodes/areas) for labelling sql = stations_query(bbox) (OSM_RAW / "stations.overpassql").write_text(sql) diff --git a/copenhagen/scripts/render.py b/copenhagen/scripts/render.py index eaee64b..9ef3404 100644 --- a/copenhagen/scripts/render.py +++ b/copenhagen/scripts/render.py @@ -311,12 +311,16 @@ def group_by_route_membership(rows, cluster_m): centroids = [] for indices in components.values(): comp_coords = [coords[i] for i in indices] + comp_routes = [route_sets[i] for i in indices] + has_routes = any(rs for rs in comp_routes) if len(comp_coords) == 1: centroids.append(comp_coords[0]) - else: + elif has_routes: # stops sharing a route are the same stop; merge to centroid - import numpy as np centroids.append(tuple(np.mean(comp_coords, axis=0))) + else: + # no route info (orphans); fall back to distance clustering + centroids.extend(cluster_stops(comp_coords, cluster_m)) return centroids