Spaces:
Sleeping
Sleeping
| """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() | |