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