Add bus whitelist, route exclusions, crow-fly shape filter, and KML export #2
@@ -198,6 +198,40 @@ def parse_stations(data):
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def orphan_bus_stops_query(bbox):
|
||||||
|
s, n, w, e = bbox
|
||||||
|
return f"""[out:json][timeout:180];
|
||||||
|
(
|
||||||
|
node["highway"="bus_stop"]["public_transport"="platform"]({s},{w},{n},{e});
|
||||||
|
);
|
||||||
|
out body;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_orphan_bus_stops(data, known_node_ids):
|
||||||
|
"""Parse standalone bus platform nodes not already captured by routes."""
|
||||||
|
stops = []
|
||||||
|
for e in data["elements"]:
|
||||||
|
if e["type"] != "node" or e["id"] in known_node_ids:
|
||||||
|
continue
|
||||||
|
tags = e.get("tags", {})
|
||||||
|
if tags.get("public_transport") != "platform":
|
||||||
|
continue
|
||||||
|
name = tags.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
stops.append({
|
||||||
|
"mode": "bus",
|
||||||
|
"name": name,
|
||||||
|
"ref": tags.get("ref"),
|
||||||
|
"public_transport": tags.get("public_transport"),
|
||||||
|
"railway": tags.get("railway"),
|
||||||
|
"route_refs": "", # orphan — no route membership
|
||||||
|
"geometry": Point(e["lon"], e["lat"]),
|
||||||
|
})
|
||||||
|
return stops
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
modes = yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
|
modes = yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
|
||||||
area = gpd.read_file(PROCESSED / "area.gpkg")
|
area = gpd.read_file(PROCESSED / "area.gpkg")
|
||||||
@@ -205,12 +239,18 @@ def main():
|
|||||||
bbox = (s, n, w, e)
|
bbox = (s, n, w, e)
|
||||||
|
|
||||||
OSM_RAW.mkdir(parents=True, exist_ok=True)
|
OSM_RAW.mkdir(parents=True, exist_ok=True)
|
||||||
|
bus_stop_node_ids = set()
|
||||||
for mode, cfg in modes.items():
|
for mode, cfg in modes.items():
|
||||||
ql = build_query(cfg, bbox)
|
ql = build_query(cfg, bbox)
|
||||||
(OSM_RAW / f"{mode}.overpassql").write_text(ql)
|
(OSM_RAW / f"{mode}.overpassql").write_text(ql)
|
||||||
print(f"[{mode}] querying Overpass bbox=({s:.4f},{w:.4f},{n:.4f},{e:.4f})...", flush=True)
|
print(f"[{mode}] querying Overpass bbox=({s:.4f},{w:.4f},{n:.4f},{e:.4f})...", flush=True)
|
||||||
data = overpass(ql)
|
data = overpass(ql)
|
||||||
lines, stops = parse(data, mode)
|
lines, stops = parse(data, mode)
|
||||||
|
# track bus stop node ids to avoid dupes with orphans
|
||||||
|
if mode == "bus":
|
||||||
|
for e in data["elements"]:
|
||||||
|
if e["type"] == "node" and e.get("tags", {}).get("public_transport") == "platform":
|
||||||
|
bus_stop_node_ids.add(e["id"])
|
||||||
print(f" -> {len(lines)} lines, {len(stops)} stops", flush=True)
|
print(f" -> {len(lines)} lines, {len(stops)} stops", flush=True)
|
||||||
if not lines:
|
if not lines:
|
||||||
continue
|
continue
|
||||||
@@ -220,6 +260,30 @@ def main():
|
|||||||
sgdf = gpd.GeoDataFrame(stops, crs="EPSG:4326")
|
sgdf = gpd.GeoDataFrame(stops, crs="EPSG:4326")
|
||||||
sgdf.to_file(OSM_RAW / f"{mode}.gpkg", driver="GPKG", layer="stops")
|
sgdf.to_file(OSM_RAW / f"{mode}.gpkg", driver="GPKG", layer="stops")
|
||||||
|
|
||||||
|
# orphan bus stops (platform nodes not in any route relation)
|
||||||
|
oql = orphan_bus_stops_query(bbox)
|
||||||
|
(OSM_RAW / "orphan_bus_stops.overpassql").write_text(oql)
|
||||||
|
print("[orphan_bus_stops] querying Overpass...", flush=True)
|
||||||
|
odata = overpass(oql)
|
||||||
|
orphans = parse_orphan_bus_stops(odata, bus_stop_node_ids)
|
||||||
|
print(f" -> {len(orphans)} orphan bus stops (not in route relations)", flush=True)
|
||||||
|
if orphans:
|
||||||
|
# append to bus.gpkg stops layer
|
||||||
|
existing = gpd.read_file(OSM_RAW / "bus.gpkg", layer="stops")
|
||||||
|
combined = gpd.GeoDataFrame(
|
||||||
|
__import__("pandas").concat([existing, gpd.GeoDataFrame(orphans, crs="EPSG:4326")],
|
||||||
|
ignore_index=True),
|
||||||
|
crs="EPSG:4326"
|
||||||
|
)
|
||||||
|
# rewrite stops layer
|
||||||
|
import tempfile, shutil
|
||||||
|
tmp = OSM_RAW / "bus_tmp.gpkg"
|
||||||
|
lines_gdf = gpd.read_file(OSM_RAW / "bus.gpkg", layer="lines")
|
||||||
|
lines_gdf.to_file(tmp, driver="GPKG", layer="lines")
|
||||||
|
combined.to_file(tmp, driver="GPKG", layer="stops")
|
||||||
|
shutil.move(str(tmp), str(OSM_RAW / "bus.gpkg"))
|
||||||
|
print(f" -> bus stops layer now: {len(combined)} total", flush=True)
|
||||||
|
|
||||||
# stations (named station nodes/areas) for labelling
|
# stations (named station nodes/areas) for labelling
|
||||||
sql = stations_query(bbox)
|
sql = stations_query(bbox)
|
||||||
(OSM_RAW / "stations.overpassql").write_text(sql)
|
(OSM_RAW / "stations.overpassql").write_text(sql)
|
||||||
|
|||||||
@@ -311,12 +311,16 @@ def group_by_route_membership(rows, cluster_m):
|
|||||||
centroids = []
|
centroids = []
|
||||||
for indices in components.values():
|
for indices in components.values():
|
||||||
comp_coords = [coords[i] for i in indices]
|
comp_coords = [coords[i] for i in indices]
|
||||||
|
comp_routes = [route_sets[i] for i in indices]
|
||||||
|
has_routes = any(rs for rs in comp_routes)
|
||||||
if len(comp_coords) == 1:
|
if len(comp_coords) == 1:
|
||||||
centroids.append(comp_coords[0])
|
centroids.append(comp_coords[0])
|
||||||
else:
|
elif has_routes:
|
||||||
# stops sharing a route are the same stop; merge to centroid
|
# stops sharing a route are the same stop; merge to centroid
|
||||||
import numpy as np
|
|
||||||
centroids.append(tuple(np.mean(comp_coords, axis=0)))
|
centroids.append(tuple(np.mean(comp_coords, axis=0)))
|
||||||
|
else:
|
||||||
|
# no route info (orphans); fall back to distance clustering
|
||||||
|
centroids.extend(cluster_stops(comp_coords, cluster_m))
|
||||||
return centroids
|
return centroids
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user