Files
jetlag-maps/copenhagen/scripts/download_osm.py
T
marvin 54708e6cb3 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.
2026-09-14 00:00:28 +02:00

239 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Download transit route lines + stops from Overpass, per mode.
For each mode in config/modes.yaml, runs an Overpass query (with mirror
fallback + exponential backoff) and writes:
data/raw/osm/{mode}.gpkg (layers: "lines", "stops", EPSG:4326)
data/raw/osm/{mode}.overpassql (the exact query text)
Lines: one MultiLineString per route relation (concatenation of member way
geometries), carrying ref/name/network/colour/operator/route/mode tags.
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
from pathlib import Path
import geopandas as gpd
import requests
import yaml
from shapely.geometry import LineString, MultiLineString, Point
from _common import CONFIG, OSM_RAW, PROCESSED, UA
MIRRORS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
]
PLATFORM_ROLES = {"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):
s, n, w, e = bbox
filters = []
for k, v in mode_cfg["osm"].items():
if k.endswith("_neq"):
filters.append(f'["{k[:-4]}"!="{v}"]')
else:
filters.append(f'["{k}"="{v}"]')
filt = "".join(filters)
return f"""[out:json][timeout:180];
relation{filt}({s},{w},{n},{e});
out geom;
>;
out body qt;
"""
def overpass(ql):
last_err = None
for mi, mirror in enumerate(MIRRORS):
for attempt in range(4):
try:
r = requests.post(
mirror,
data={"data": ql},
headers={"User-Agent": UA},
timeout=300,
)
if r.status_code == 429 or r.status_code >= 500:
raise RuntimeError(f"HTTP {r.status_code}")
r.raise_for_status()
return r.json()
except Exception as e:
last_err = e
wait = 2 ** (attempt + mi)
print(f" [{mirror}] attempt {attempt+1} failed: {e}; retry in {wait}s", flush=True)
time.sleep(wait)
raise RuntimeError(f"all mirrors failed: {last_err}")
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 = []
# 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:
coords = [(g["lon"], g["lat"]) for g in m["geometry"]]
if len(coords) >= 2:
segs.append(LineString(coords))
geom = MultiLineString(segs) if len(segs) > 1 else (segs[0] if segs else None)
if geom is None:
continue
lines.append({
"mode": mode,
"ref": route_ref,
"name": tags.get("name"),
"network": tags.get("network"),
"colour": tags.get("colour"),
"operator": tags.get("operator"),
"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:
continue
nid = m["ref"]
if nid not in nodes:
continue
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
def stations_query(bbox):
s, n, w, e = bbox
return f"""[out:json][timeout:180];
(
node["railway"="station"]({s},{w},{n},{e});
node["public_transport"="station"]({s},{w},{n},{e});
way["railway"="station"]({s},{w},{n},{e});
way["public_transport"="station"]({s},{w},{n},{e});
);
out center;
"""
def parse_stations(data):
"""Stations (nodes or area centroids) with proper human-readable names."""
rows = []
seen = set()
for e in data["elements"]:
if e["type"] not in ("node", "way"):
continue
tags = e.get("tags", {})
name = tags.get("name")
if not name:
continue
if e["type"] == "node":
geom = Point(e["lon"], e["lat"])
else:
c = e.get("center")
if not c:
continue
geom = Point(c["lon"], c["lat"])
key = (name, round(geom.x, 5), round(geom.y, 5))
if key in seen:
continue
seen.add(key)
rows.append({
"name": name,
"railway": tags.get("railway"),
"public_transport": tags.get("public_transport"),
"station": tags.get("station"),
"subway": tags.get("subway"),
"light_rail": tags.get("light_rail"),
"train": tags.get("train"),
"uic_ref": tags.get("uic_ref"),
"geometry": geom,
})
return rows
def main():
modes = yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
area = gpd.read_file(PROCESSED / "area.gpkg")
w, s, e, n = area.total_bounds # (minx, miny, maxx, maxy) = (west, south, east, north)
bbox = (s, n, w, e)
OSM_RAW.mkdir(parents=True, exist_ok=True)
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)
print(f" -> {len(lines)} lines, {len(stops)} stops", flush=True)
if not lines:
continue
lgdf = gpd.GeoDataFrame(lines, crs="EPSG:4326")
lgdf.to_file(OSM_RAW / f"{mode}.gpkg", driver="GPKG", layer="lines")
if stops:
sgdf = gpd.GeoDataFrame(stops, crs="EPSG:4326")
sgdf.to_file(OSM_RAW / f"{mode}.gpkg", driver="GPKG", layer="stops")
# stations (named station nodes/areas) for labelling
sql = stations_query(bbox)
(OSM_RAW / "stations.overpassql").write_text(sql)
print("[stations] querying Overpass...", flush=True)
sdata = overpass(sql)
st = parse_stations(sdata)
print(f" -> {len(st)} named stations", flush=True)
if st:
gpd.GeoDataFrame(st, crs="EPSG:4326").to_file(
OSM_RAW / "stations.gpkg", driver="GPKG", layer="stations"
)
print("done.")
if __name__ == "__main__":
main()