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:
2026-09-14 00:00:28 +02:00
parent 97a065e667
commit 54708e6cb3
3 changed files with 106 additions and 29 deletions
+62 -8
View File
@@ -262,11 +262,70 @@ def rail_station_names(stations):
return names
def group_by_route_membership(rows, cluster_m):
"""Group stop rows by (name, shared route_ref) into distinct stops.
Two same-name stops that share at least one route_ref are the same stop.
Stops on disjoint routes are different stops. Within each group,
platform duplicates are merged by distance clustering.
Returns a list of (x, y) centroids.
"""
if rows is None or rows.empty:
return []
coords = [(r.geometry.x, r.geometry.y) for _, r in rows.iterrows()]
route_sets = []
for _, r in rows.iterrows():
rr = r.get("route_refs")
if isinstance(rr, str) and rr:
route_sets.append(set(rr.split(";")))
else:
route_sets.append(set())
n = len(coords)
# union-find: stops sharing a route are connected
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for i in range(n):
for j in range(i + 1, n):
if route_sets[i] & route_sets[j]:
union(i, j)
# group indices by connected component
components = {}
for i in range(n):
root = find(i)
components.setdefault(root, []).append(i)
centroids = []
for indices in components.values():
comp_coords = [coords[i] for i in indices]
if len(comp_coords) == 1:
centroids.append(comp_coords[0])
else:
# stops sharing a route are the same stop; merge to centroid
import numpy as np
centroids.append(tuple(np.mean(comp_coords, axis=0)))
return centroids
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
"""Draw small markers for bus stops from the stops layer.
Bus stops whose name matches a rail station are skipped — the rail
station marker represents that stop.
station marker represents that stop. Stops are grouped by shared
route membership: same-name stops on disjoint routes are distinct.
"""
if stops is None or stops.empty or "bus" not in active_styles:
return
@@ -292,19 +351,14 @@ def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
marker = "o" if shape == "circle" else "s"
top_z = max(styling["zorder"].values()) + 1
# cluster same-name bus stops within threshold (opposite sides of road)
# skip names that match a rail station (merged into station marker)
# group by name, then by route membership; skip rail station names
centroids = []
skipped = 0
for name, grp in bus.groupby("name"):
if name and name in rail_names:
skipped += len(grp)
continue
coords = [(r.geometry.x, r.geometry.y) for _, r in grp.iterrows()]
if name and len(coords) > 1:
centroids.extend(cluster_stops(coords, cluster_m))
else:
centroids.extend(coords)
centroids.extend(group_by_route_membership(grp, cluster_m))
ax.scatter([p[0] for p in centroids], [p[1] for p in centroids],
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,