File size: 5,721 Bytes
e855a27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83a0702
 
 
 
 
 
 
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
"""Build the pedestrian walk graph and recompute transit transfers along it.

Inputs:  data/raw/osm_walk.json (from download_osm.py), data/processed/network.pkl
Outputs: data/processed/walk_graph.pkl, and updated transfers in network.pkl

Usage:  python scripts/build_walk_graph.py
"""
from __future__ import annotations

import json
import pickle
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import (  # noqa: E402
    MAX_TRANSFER_WALK_SECONDS, NETWORK_FILE, OSM_RAW_FILE, WALK_GRAPH_FILE,
    WALK_SNAP_MAX_M, WALK_SPEED_MPS,
)
from engine.model import haversine_m  # noqa: E402
from engine.walk import WalkGraph, _Scratch, build_grid, build_hex_graph, dijkstra  # noqa: E402


def build_graph(ways: list[dict]) -> WalkGraph:
    osm_to_idx: dict[int, int] = {}
    lon: list[float] = []
    lat: list[float] = []
    adj_d: list[dict[int, int]] = []   # neighbor -> seconds (dedup, keep min)

    def node(osm_id: int, glon: float, glat: float) -> int:
        idx = osm_to_idx.get(osm_id)
        if idx is None:
            idx = len(lon)
            osm_to_idx[osm_id] = idx
            lon.append(glon)
            lat.append(glat)
            adj_d.append({})
        return idx

    for w in ways:
        ids = w.get("nodes")
        geom = w.get("geometry")
        if not ids or not geom or len(ids) != len(geom):
            continue
        prev_i = None
        prev_g = None
        for oid, g in zip(ids, geom):
            i = node(oid, g["lon"], g["lat"])
            if prev_i is not None and i != prev_i:
                d = haversine_m(prev_g["lat"], prev_g["lon"], g["lat"], g["lon"])
                sec = max(1, int(d / WALK_SPEED_MPS))
                if sec < adj_d[prev_i].get(i, 10 ** 9):
                    adj_d[prev_i][i] = sec
                    adj_d[i][prev_i] = sec
            prev_i, prev_g = i, g

    wg = WalkGraph(node_lon=lon, node_lat=lat,
                   adj=[list(d.items()) for d in adj_d])
    build_grid(wg)
    edges = sum(len(a) for a in wg.adj) // 2
    print(f"  walk graph: {wg.n_nodes} nodes, {edges} edges")
    return wg


def snap_stops(wg: WalkGraph, net, node_hex: list) -> None:
    """Snap each stop to a walk-graph node. Stops farther than WALK_SNAP_MAX_M
    (off-island suburbs the island graph doesn't cover) are marked off-graph
    (stop_node = -1) and fall back to geometric transfers/access. Also records
    each on-graph stop's hex (for fog egress)."""
    wg.stop_node = [-1] * net.n_stops
    wg.stop_hex = [None] * net.n_stops
    wg.node_stops = {}
    off = 0
    for s in range(net.n_stops):
        nd = wg.nearest_node(net.stop_lon[s], net.stop_lat[s])
        if nd is None:
            off += 1
            continue
        d = haversine_m(net.stop_lat[s], net.stop_lon[s], wg.node_lat[nd], wg.node_lon[nd])
        if d > WALK_SNAP_MAX_M:
            off += 1                    # off-graph (suburb) — leave stop_node = -1
            continue
        wg.stop_node[s] = nd
        wg.stop_hex[s] = node_hex[nd]
        wg.node_stops.setdefault(nd, []).append(s)
    print(f"  snapped {net.n_stops} stops; {off} are off-graph (suburbs, geometric fallback)")


def compute_transfers(wg: WalkGraph, net) -> None:
    """Walk-graph transfers for on-graph (island) stops; keep the geometric
    transfers already on net.transfers for off-graph (suburban) stops."""
    geometric = net.transfers
    scratch = _Scratch(wg.n_nodes)
    transfers: list[list[tuple[int, float]]] = [[] for _ in range(net.n_stops)]
    t0 = time.time()
    for s in range(net.n_stops):
        src = wg.stop_node[s]
        if src < 0:
            transfers[s] = list(geometric[s])   # suburb: keep straight-line transfers
            continue
        dijkstra(wg, src, MAX_TRANSFER_WALK_SECONDS, scratch)
        best: dict[int, int] = {}
        for nd in scratch.dirty:
            stops_here = wg.node_stops.get(nd)
            if not stops_here:
                continue
            dsec = scratch.dist[nd]
            for t in stops_here:
                if t != s and dsec < best.get(t, 10 ** 9):
                    best[t] = dsec
        transfers[s] = [(t, float(d)) for t, d in best.items()]
        if s % 4000 == 0:
            print(f"    transfers {s}/{net.n_stops} ({time.time()-t0:.0f}s)")
    net.transfers = transfers
    total = sum(len(t) for t in transfers)
    print(f"  transfers: {total} edges ({total/max(net.n_stops,1):.1f} avg/stop)")


def main() -> None:
    t0 = time.time()
    print("Loading OSM ways...")
    ways = json.load(open(OSM_RAW_FILE, encoding="utf-8"))
    print(f"  {len(ways)} ways")
    wg = build_graph(ways)
    print("  building hex graph...")
    node_hex = build_hex_graph(wg)
    print(f"  hex graph: {len(wg.hex_adj)} hexes")

    net = pickle.load(open(NETWORK_FILE, "rb"))
    snap_stops(wg, net, node_hex)
    compute_transfers(wg, net)

    # Bake the packed CSR adjacency and drop the tuple `adj`: the runtime Dijkstra
    # reads only CSR, so the shipped pickle is smaller and starts up instantly.
    # (The hex graph above was the last consumer of `adj`.)
    print("  building CSR adjacency...")
    wg.build_csr()
    wg.free_adj()

    # the snapping maps live on the graph; don't double-store on both pickles
    with open(WALK_GRAPH_FILE, "wb") as f:
        pickle.dump(wg, f, protocol=pickle.HIGHEST_PROTOCOL)
    with open(NETWORK_FILE, "wb") as f:
        pickle.dump(net, f, protocol=pickle.HIGHEST_PROTOCOL)
    print(f"Wrote {WALK_GRAPH_FILE} ({WALK_GRAPH_FILE.stat().st_size/1e6:.1f} MB) "
          f"and updated {NETWORK_FILE.name} in {time.time()-t0:.0f}s")


if __name__ == "__main__":
    main()