Files
jetlag-maps/copenhagen/scripts/prepare.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

206 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Merge OSM layers into a single master GeoPackage, enriched for rendering.
Reads data/raw/osm/{mode}.gpkg (lines + stops) and data/processed/area.gpkg,
and writes data/processed/master.gpkg with layers "lines" and "stops".
Enrichment:
- style : from config/modes.yaml (mode -> style key)
- colour : GTFS route_color (if present) -> OSM colour tag -> palette
- bus_category : A / C / S / regular (regex from styling.yaml)
Lines and stops are clipped to the City Pass area polygon.
"""
import re
from pathlib import Path
import geopandas as gpd
import pandas as pd
import yaml
from _common import CONFIG, OSM_RAW, PROCESSED
MIXED_CRS_WARN = "mixed CRS"
def load_modes():
return yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
def load_styling():
return yaml.safe_load((CONFIG / "styling.yaml").read_text())
def collect_lines(modes):
frames = []
for mode, cfg in modes.items():
p = OSM_RAW / f"{mode}.gpkg"
if not p.exists():
continue
g = gpd.read_file(p, layer="lines")
if g.empty:
continue
g["mode"] = mode
g["style"] = cfg["style"]
g["gtfs_route_type"] = cfg.get("gtfs_route_type")
frames.append(g)
if not frames:
return gpd.GeoDataFrame(columns=["mode", "ref", "style", "geometry"], crs="EPSG:4326")
return pd.concat(frames, ignore_index=True)
def collect_stops(modes):
frames = []
for mode, cfg in modes.items():
p = OSM_RAW / f"{mode}.gpkg"
if not p.exists():
continue
try:
g = gpd.read_file(p, layer="stops")
except Exception:
continue
if g.empty:
continue
g["mode"] = mode
g["style"] = cfg["style"]
frames.append(g)
if not frames:
return None
return pd.concat(frames, ignore_index=True)
def gtfs_colour_map():
"""Return {(route_short_name, route_type): route_color} if GTFS data exists."""
csv = PROCESSED / "route_colors.csv"
if not csv.exists():
return {}
df = pd.read_csv(csv)
out = {}
for _, r in df.iterrows():
name = str(r.get("route_short_name") or "").strip()
if name:
out[(name, r.get("route_type"))] = r.get("route_color")
return out
def categorise_bus(ref, patterns):
if ref is None:
return "regular"
s = str(ref)
for cat, pat in patterns.items():
if re.search(pat, s):
return cat
return "regular"
def resolve_colour(row, palette, gtfs):
style = row["style"]
ref = row.get("ref")
colour = row.get("colour")
# 1. GTFS (if available)
if gtfs and isinstance(ref, str):
c = gtfs.get((ref, row.get("gtfs_route_type")))
if c:
return str(c)
# 2. OSM colour tag (only accept real hex/named strings, not NA/None)
if isinstance(colour, str) and colour.strip():
return colour
# 3. palette
p = palette.get(style, {})
if style == "bus":
cat = row.get("bus_category")
if not isinstance(cat, str):
cat = "regular"
return p.get(cat) or p.get("default")
key = ref if isinstance(ref, str) else None
return p.get(key) or p.get("default")
def main():
modes = load_modes()
styling = load_styling()
palette = styling["palette"]
bus_patterns = styling["bus_filters"]["categories"]
area = gpd.read_file(PROCESSED / "area.gpkg")
area_geom = area.geometry.union_all()
lines = collect_lines(modes)
print(f"collected {len(lines)} raw lines across modes", flush=True)
# bus categorisation (only meaningful for bus style)
lines["bus_category"] = [
categorise_bus(r, bus_patterns) if s == "bus" else None
for r, s in zip(lines.get("ref"), lines["style"])
]
gtfs = gtfs_colour_map()
if gtfs:
print(f"GTFS colours loaded: {len(gtfs)} routes", flush=True)
else:
print("no GTFS colour data; using OSM colour -> palette", flush=True)
lines["colour_final"] = [
resolve_colour(r, palette, gtfs) for _, r in lines.iterrows()
]
# clip to area
lines = lines[~lines.geometry.isna() & lines.geometry.is_valid]
clipped = gpd.clip(lines, area_geom)
print(f"clipped to area: {len(clipped)} line features remain", flush=True)
out_cols = [
"mode", "style", "ref", "name", "network", "operator", "route",
"colour", "bus_category", "colour_final", "geometry",
]
for c in out_cols:
if c not in clipped.columns:
clipped[c] = None
clipped = clipped.set_geometry("geometry")
clipped = gpd.GeoDataFrame(clipped[out_cols], crs="EPSG:4326")
PROCESSED.mkdir(parents=True, exist_ok=True)
# drop existing master.gpkg so layer overwrite is clean
out = PROCESSED / "master.gpkg"
if out.exists():
out.unlink()
clipped.to_file(out, driver="GPKG", layer="lines")
print(f"wrote lines layer: {len(clipped)} features", flush=True)
# stops (stop_positions along routes)
stops = collect_stops(modes)
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", "route_refs", "geometry"]
for c in keep:
if c not in stops_clipped.columns:
stops_clipped[c] = None
stops_clipped = gpd.GeoDataFrame(stops_clipped[keep], crs="EPSG:4326")
stops_clipped.to_file(out, driver="GPKG", layer="stops")
print(f"wrote stops layer: {len(stops_clipped)} features", flush=True)
else:
print("no stops to write", flush=True)
# stations (named station nodes/areas) for labelling
st_path = OSM_RAW / "stations.gpkg"
if st_path.exists():
st = gpd.read_file(st_path, layer="stations")
if not st.empty:
st = st[~st.geometry.isna() & st.geometry.is_valid]
st_clipped = gpd.clip(st, area_geom)
st_clipped = gpd.GeoDataFrame(st_clipped, crs="EPSG:4326")
st_clipped.to_file(out, driver="GPKG", layer="stations")
print(f"wrote stations layer: {len(st_clipped)} named stations", flush=True)
else:
print("no stations file; labelling will be limited", flush=True)
# summary by style
print("\nsummary by style:")
for style, grp in clipped.groupby("style"):
refs = grp["ref"].dropna().unique()
print(f" {style:10s}: {len(grp):4d} feats, {len(refs):3d} refs")
if __name__ == "__main__":
main()