File size: 7,081 Bytes
e855a27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
"""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()