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
This commit is contained in:
2026-09-14 00:22:15 +02:00
parent 54708e6cb3
commit 4aca62ca1a
2 changed files with 70 additions and 2 deletions
+64
View File
@@ -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)