"""Substitute the coarse GTFS metro shapes with accurate OSM geometry. Reads data/raw/metro_geometry.json (from fetch_metro_geometry.py), orients each line to match the existing GTFS shape's direction, replaces the shape in the compiled network, and re-projects each metro pattern's stops onto the detailed polyline so the per-hop spine tracing follows the real tunnel alignment. Run (after fetch_metro_geometry.py): python scripts/apply_metro_geometry.py """ from __future__ import annotations import json import pickle import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from config import NETWORK_FILE, ROOT # noqa: E402 from engine.gtfs import _simplify # noqa: E402 from engine.model import project_stops_to_shape # noqa: E402 GEO_FILE = ROOT / "data" / "raw" / "metro_geometry.json" def _sq(a, b): return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 def _orient(detailed: list[list[float]], coarse: list[list[float]]) -> list[list[float]]: """Flip the detailed line if the GTFS shape runs the other way.""" c0, cn, d0, dn = coarse[0], coarse[-1], detailed[0], detailed[-1] straight = _sq(c0, d0) + _sq(cn, dn) crossed = _sq(c0, dn) + _sq(cn, d0) return detailed if straight <= crossed else list(reversed(detailed)) def _nearest(det: list[list[float]], p) -> int: return min(range(len(det)), key=lambda i: _sq(det[i], p)) def _trim_to_termini(det: list[list[float]], first, last) -> list[list[float]]: """Clip the polyline to the span between the line's two TERMINUS stations, dropping non-revenue tails (e.g. the garage track past Angrignon that OSM includes — it loops back near the line and otherwise mis-snaps stops).""" i0, i1 = _nearest(det, first), _nearest(det, last) lo, hi = (i0, i1) if i0 <= i1 else (i1, i0) seg = det[lo:hi + 1] return list(reversed(seg)) if i0 > i1 else seg def apply_metro_geometry(net, geo_file: Path = GEO_FILE) -> int: """Swap the coarse GTFS metro shapes in `net` for accurate OSM geometry. Mutates `net` in place; returns the number of patterns updated (0 if the geometry file is missing, so the build still works without it).""" if not geo_file.exists(): print(f"(no {geo_file.name} — keeping coarse GTFS metro shapes; " f"run scripts/fetch_metro_geometry.py to improve them)") return 0 geo = json.loads(geo_file.read_text(encoding="utf-8")) metro = [r for r in net.routes if r.route_type == 1] # one detailed polyline per shape_id, trimmed to that pattern's terminus stops rep: dict[str, object] = {} # shape_id -> route with the most stops for r in metro: if geo.get(r.route_id) and r.shape_id and ( r.shape_id not in rep or len(r.stops) > len(rep[r.shape_id].stops)): rep[r.shape_id] = r detailed_by_shape: dict[str, list[list[float]]] = {} for sid, r in rep.items(): coarse = net.shapes.get(sid) if not coarse or len(coarse) < 2: continue det = _simplify([[round(p[0], 6), round(p[1], 6)] for p in geo[r.route_id]], tol_m=2.0) det = _orient(det, coarse) first = (net.stop_lon[r.stops[0]], net.stop_lat[r.stops[0]]) last = (net.stop_lon[r.stops[-1]], net.stop_lat[r.stops[-1]]) detailed_by_shape[sid] = _trim_to_termini(det, first, last) swapped = 0 for r in metro: det = detailed_by_shape.get(r.shape_id) if not det: continue net.shapes[r.shape_id] = det stop_lonlat = [(net.stop_lon[s], net.stop_lat[s]) for s in r.stops] r.stop_shape_idx = project_stops_to_shape(stop_lonlat, det) swapped += 1 print(f"Applied OSM metro geometry to {swapped} patterns ({sorted(detailed_by_shape)} shapes).") return swapped def main() -> None: net = pickle.loads(NETWORK_FILE.read_bytes()) apply_metro_geometry(net) NETWORK_FILE.write_bytes(pickle.dumps(net)) print(f"Saved {NETWORK_FILE}") if __name__ == "__main__": main()