"""Fetch accurate Montréal metro line geometry from OpenStreetMap (Overpass). The STM GTFS shapes for the metro are extremely coarse (≈1 vertex per station → straight lines between stops), so the rendered lines look angular. OSM maps the actual tunnel alignment as `route=subway` relations; we stitch each line's member ways into one ordered polyline and save them by line ref. Output: data/raw/metro_geometry.json -> { "1": [[lon,lat], ...], ... } Run: python scripts/fetch_metro_geometry.py """ from __future__ import annotations import json import math import sys import time import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "data" / "raw" / "metro_geometry.json" RAW_CACHE = ROOT / "data" / "raw" / "metro_overpass.json" # a few public mirrors — the main instance is often overloaded (504s) OVERPASS_MIRRORS = [ "https://overpass-api.de/api/interpreter", "https://overpass.kumi.systems/api/interpreter", "https://maps.mail.ru/osm/tools/overpass/api/interpreter", "https://overpass.openstreetmap.ru/api/interpreter", ] # Montréal metro is entirely STM; grab every subway route relation in the region. QUERY = """ [out:json][timeout:120]; relation["route"="subway"](45.30,-74.10,45.80,-73.20); out geom; """ WANT = {"1", "2", "4", "5"} # green, orange, yellow, blue def _m(a, b): # approx metres between two [lon,lat] near Montréal return math.hypot((a[0] - b[0]) * 78000, (a[1] - b[1]) * 111000) def _wlen(w): return sum(_m(w[k - 1], w[k]) for k in range(1, len(w))) def _stitch(ways: list[list[list[float]]]) -> list[list[float]]: """Assemble member ways into the line's REVENUE polyline = the longest terminus-to-terminus path through the way network. Member order isn't geographic, and `route=subway` relations often include non-revenue garage / crossover spurs that loop back near the line (these mis-snap stops and bloat the geometry). Building the longest leaf-to-leaf path drops those spurs. Ways are joined at endpoints clustered within JOIN_M; we DFS the longest (by metres) simple path from every leaf cluster and keep the best.""" JOIN_M = 60 ways = [list(w) for w in ways if len(w) >= 2] if not ways: return [] if len(ways) == 1: return ways[0] # cluster way endpoints into nodes (union endpoints within JOIN_M) eps = [(i, 0, w[0]) for i, w in enumerate(ways)] + [(i, 1, w[-1]) for i, w in enumerate(ways)] parent = list(range(len(eps))) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x for a in range(len(eps)): for b in range(a + 1, len(eps)): if _m(eps[a][2], eps[b][2]) <= JOIN_M: parent[find(a)] = find(b) # node id per (way, end); adjacency node -> list of (way_index, end) node_of = {} for k, (i, end, _) in enumerate(eps): node_of[(i, end)] = find(k) adj: dict[int, list[tuple[int, int]]] = {} for (i, end), node in node_of.items(): adj.setdefault(node, []).append((i, end)) lens = [_wlen(w) for w in ways] leaves = [n for n, lst in adj.items() if len(lst) == 1] or list(adj) best = {"len": -1.0, "path": []} def dfs(node, used, path, total): if total > best["len"]: best["len"], best["path"] = total, list(path) for (i, end) in adj.get(node, []): if i in used: continue other = node_of[(i, 1 - end)] used.add(i) path.append((i, end == 1)) # flip if we entered at the way's far end dfs(other, used, path, total + lens[i]) path.pop() used.discard(i) for leaf in leaves: dfs(leaf, set(), [], 0.0) chain: list[list[float]] = [] for (i, flip) in best["path"]: seg = list(reversed(ways[i])) if flip else list(ways[i]) if chain and _m(chain[-1], seg[0]) < 1: seg = seg[1:] chain.extend(seg) return chain def main() -> None: # Cache the raw Overpass response so the stitching can be re-run offline # (Overpass is flaky). Pass --refresh to force a new query. if RAW_CACHE.exists() and "--refresh" not in sys.argv: print(f"Using cached Overpass response {RAW_CACHE.name} (pass --refresh to re-query).") data = json.loads(RAW_CACHE.read_text(encoding="utf-8")) else: print("Querying Overpass for Montréal subway relations…") payload = ("data=" + QUERY).encode("utf-8") headers = {"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "mtl-isochrone/1.0 (metro geometry fetch)"} data = None for attempt in range(6): url = OVERPASS_MIRRORS[attempt % len(OVERPASS_MIRRORS)] try: req = urllib.request.Request(url, data=payload, headers=headers) with urllib.request.urlopen(req, timeout=180) as resp: data = json.load(resp) print(f" ok via {url}") break except Exception as ex: # noqa: BLE001 — transient Overpass load / rate limits print(f" {url} failed ({ex}); retrying…") time.sleep(3) if data is None: sys.exit("All Overpass mirrors failed.") RAW_CACHE.write_text(json.dumps(data), encoding="utf-8") # group member-way geometries per line ref; keep the relation with the most ways by_ref: dict[str, list[list[list[float]]]] = {} for el in data.get("elements", []): if el.get("type") != "relation": continue tags = el.get("tags", {}) ref = (tags.get("ref") or "").strip() if ref not in WANT: continue ways = [] for m in el.get("members", []): if m.get("type") != "way" or "geometry" not in m: continue ways.append([[round(p["lon"], 6), round(p["lat"], 6)] for p in m["geometry"]]) if not ways: continue # prefer the richest relation for this ref (one direction is enough) if ref not in by_ref or sum(len(w) for w in ways) > sum(len(w) for w in by_ref[ref]): by_ref[ref] = ways out: dict[str, list[list[float]]] = {} for ref, ways in by_ref.items(): line = _stitch(ways) out[ref] = line max_gap = max((_m(line[i - 1], line[i]) for i in range(1, len(line))), default=0) flag = " <-- CHECK: large gap" if max_gap > 250 else "" print(f" line {ref}: {len(ways)} ways -> {len(line)} points, max gap {max_gap:.0f} m{flag}") missing = WANT - set(out) if missing: print(f"WARNING: no geometry for line(s): {sorted(missing)}", file=sys.stderr) if not out: sys.exit("No metro geometry fetched — aborting.") OUT.write_text(json.dumps(out), encoding="utf-8") print(f"Wrote {OUT} ({OUT.stat().st_size // 1024} KB)") if __name__ == "__main__": main()