Use platform nodes + route membership for bus stop clustering

Replaces distance-based clustering with route-membership grouping:
- download_osm.py: collect route_refs per stop node; use platform roles
  for bus stops, stop roles for rail stops
- prepare.py: carry route_refs through to master.gpkg
- render.py: group_by_route_membership() uses union-find to cluster
  same-name stops sharing at least one route into one marker; stops on
  disjoint routes are kept separate. No distance threshold needed.

Results: 786 bus platforms -> 465 markers (was 1610 -> 781).
Veksøvej: 2 platforms sharing routes 2A+5C -> 1 marker.
Peter Bangs Vej: 7 platforms on routes 10/21/22/4A/7A -> 2 markers.
This commit is contained in:
marvin
2026-09-14 00:00:28 +02:00
parent 977a3ffb4c
commit dccf04349a
3 changed files with 106 additions and 29 deletions
+43 -20
View File
@@ -8,7 +8,8 @@ fallback + exponential backoff) and writes:
Lines: one MultiLineString per route relation (concatenation of member way
geometries), carrying ref/name/network/colour/operator/route/mode tags.
Stops: one Point per stop/platform node member, carrying ref/name/mode.
Stops: one Point per platform node member, carrying ref/name/mode/route_refs
(route refs of routes using this stop, for disambiguation).
"""
import sys
import time
@@ -28,7 +29,11 @@ MIRRORS = [
]
PLATFORM_ROLES = {"platform", "platform_entry", "platform_exit"}
STOP_ROLES = {"stop", "stop_entry", "stop_exit", "platform", "platform_entry", "platform_exit"}
STOP_ROLES = {"stop", "stop_entry", "stop_exit", "stop_entry_only", "stop_exit_only"}
# bus stops: use platform nodes (where passengers wait)
# rail stops: use stop nodes (platforms are often areas, stop nodes are reliable points)
BUS_STOP_ROLES = PLATFORM_ROLES
RAIL_STOP_ROLES = STOP_ROLES | PLATFORM_ROLES
def build_query(mode_cfg, bbox):
@@ -72,19 +77,25 @@ def overpass(ql):
def parse(data, mode):
is_bus = mode == "bus"
stop_roles = BUS_STOP_ROLES if is_bus else RAIL_STOP_ROLES
nodes = {}
for e in data["elements"]:
if e["type"] == "node":
nodes[e["id"]] = e
lines, stops = [], []
stop_seen = set()
lines = []
# collect route_refs per stop node: {node_id: set(route_refs)}
node_routes = {}
for e in data["elements"]:
if e["type"] != "relation" or "tags" not in e:
continue
tags = e["tags"]
if tags.get("route") is None:
continue
route_ref = tags.get("ref")
# collect line geometries
segs = []
for m in e["members"]:
if m["type"] == "way" and "geometry" in m and m.get("role") not in PLATFORM_ROLES:
@@ -96,7 +107,7 @@ def parse(data, mode):
continue
lines.append({
"mode": mode,
"ref": tags.get("ref"),
"ref": route_ref,
"name": tags.get("name"),
"network": tags.get("network"),
"colour": tags.get("colour"),
@@ -104,25 +115,37 @@ def parse(data, mode):
"route": tags.get("route"),
"geometry": geom,
})
# record route_ref on each stop member node
for m in e["members"]:
if m["type"] != "node" or m.get("role") not in STOP_ROLES:
if m["type"] != "node" or m.get("role") not in stop_roles:
continue
nid = m["ref"]
if nid not in nodes or nid in stop_seen:
if nid not in nodes:
continue
nd = nodes[nid]
if "lat" not in nd or "lon" not in nd:
continue
stop_seen.add(nid)
ntags = nd.get("tags", {})
stops.append({
"mode": mode,
"name": ntags.get("name") or ntags.get("public_transport") or "stop",
"ref": ntags.get("ref"),
"public_transport": ntags.get("public_transport"),
"railway": ntags.get("railway"),
"geometry": Point(nd["lon"], nd["lat"]),
})
node_routes.setdefault(nid, set())
if route_ref:
node_routes[nid].add(route_ref)
# build stops from collected nodes
stops = []
stop_seen = set()
for nid, routes in node_routes.items():
if nid in stop_seen:
continue
nd = nodes[nid]
if "lat" not in nd or "lon" not in nd:
continue
stop_seen.add(nid)
ntags = nd.get("tags", {})
stops.append({
"mode": mode,
"name": ntags.get("name") or ntags.get("public_transport") or "stop",
"ref": ntags.get("ref"),
"public_transport": ntags.get("public_transport"),
"railway": ntags.get("railway"),
"route_refs": ";".join(sorted(routes)),
"geometry": Point(nd["lon"], nd["lat"]),
})
return lines, stops