mtl_isochrone / scripts /apply_metro_geometry.py
HenriLD's picture
Montreal multi-modal transit isochrone map
e855a27
Raw
History Blame Contribute Delete
4.06 kB
"""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()