From dccf04349a1378cba7340a916f2b3cdc9601da95 Mon Sep 17 00:00:00 2001 From: marvin Date: Mon, 14 Sep 2026 00:00:28 +0200 Subject: [PATCH] Use platform nodes + route membership for bus stop clustering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- copenhagen/scripts/download_osm.py | 63 ++++++++++++++++++--------- copenhagen/scripts/prepare.py | 2 +- copenhagen/scripts/render.py | 70 ++++++++++++++++++++++++++---- 3 files changed, 106 insertions(+), 29 deletions(-) diff --git a/copenhagen/scripts/download_osm.py b/copenhagen/scripts/download_osm.py index 559c74c..f37d0c4 100644 --- a/copenhagen/scripts/download_osm.py +++ b/copenhagen/scripts/download_osm.py @@ -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 diff --git a/copenhagen/scripts/prepare.py b/copenhagen/scripts/prepare.py index e325fae..72ea5d2 100644 --- a/copenhagen/scripts/prepare.py +++ b/copenhagen/scripts/prepare.py @@ -171,7 +171,7 @@ def main(): if stops is not None and not stops.empty: stops = stops[~stops.geometry.isna() & stops.geometry.is_valid] stops_clipped = gpd.clip(stops, area_geom) - keep = ["mode", "style", "name", "ref", "public_transport", "railway", "geometry"] + keep = ["mode", "style", "name", "ref", "public_transport", "railway", "route_refs", "geometry"] for c in keep: if c not in stops_clipped.columns: stops_clipped[c] = None diff --git a/copenhagen/scripts/render.py b/copenhagen/scripts/render.py index ce63d53..eaee64b 100644 --- a/copenhagen/scripts/render.py +++ b/copenhagen/scripts/render.py @@ -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,