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