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.
206 lines
6.5 KiB
Python
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()
|