HenriLD's picture
Responsiveness tweaks: parallel spine+fog, sooner reveal, tile preconnect
2d52c23
Raw
History Blame Contribute Delete
11.8 kB
"""Thin Starlette server around the RAPTOR engine.
Loads the compiled network once at startup, exposes the /api/* endpoints, and
serves the static MapLibre frontend. Deliberately stateless and dependency-light.
Why Starlette (not FastAPI): the only third-party pieces are Starlette + uvicorn,
both pure-Python, so the whole stack runs unchanged on **PyPy** (FastAPI drags in
pydantic-core, a Rust extension whose PyPy wheels are unreliable). The engine
itself is pure stdlib. This keeps the free Hugging Face Space image on
`pypy:3.10-slim` for the ~5-10x JIT speedup with no compiled dependencies.
Run: python -m uvicorn server.app:app --port 8077 (CPython, local dev)
pypy3 -m uvicorn server.app:app --host 0.0.0.0 --port 7860 (Space)
"""
from __future__ import annotations
import os
import pickle
import threading
import time
from collections import OrderedDict
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.responses import JSONResponse, StreamingResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from config import MAX_BUDGET_MIN, NETWORK_FILE, ROOT, WALK_GRAPH_FILE
from engine.raptor import ROUTE_TYPE_MODE, SPINE_TYPES, compute_isochrone, prepare_network
from engine.walk import egress_hex_disc, egress_hex_graph
def _ensure_data() -> None:
"""Optional fallback: fetch the compiled pickles from a Hugging Face Dataset
if they're not already on disk. Normally a no-op — on the Space the pickles
live in the persistent-storage bucket at /data (auto-detected by config), and
locally the build scripts have written them. Only used if neither is present
AND these env vars are set:
HF_DATA_REPO = "user/mtl-isochrone-data" (a dataset repo)
HF_TOKEN = <read token> (only if the dataset is private)"""
if NETWORK_FILE.exists() and WALK_GRAPH_FILE.exists():
return
repo = os.environ.get("HF_DATA_REPO")
if not repo:
return
import shutil
from huggingface_hub import hf_hub_download
token = os.environ.get("HF_TOKEN")
NETWORK_FILE.parent.mkdir(parents=True, exist_ok=True)
for filename, dest in (("network.pkl", NETWORK_FILE), ("walk_graph.pkl", WALK_GRAPH_FILE)):
print(f"Fetching {filename} from dataset {repo}...")
cached = hf_hub_download(repo_id=repo, filename=filename, repo_type="dataset", token=token)
shutil.copy(cached, dest)
_ensure_data()
import config as _config
print(_config.describe_data_resolution())
if not NETWORK_FILE.exists():
raise SystemExit(
f"network.pkl not found at {NETWORK_FILE}. If running on a HF Space, check "
"that the Storage Bucket is mounted and that the pickles are inside it; set "
"MTL_DATA_DIR to the directory that actually contains network.pkl (see the "
"'contents of /data' listing above), then restart the Space.")
with open(NETWORK_FILE, "rb") as f:
NET = pickle.load(f)
prepare_network(NET) # build per-route bisect columns + hop-geometry cache up front
print(f"Loaded network: {NET.n_stops} stops, service date {NET.service_date}")
WALK = None
if WALK_GRAPH_FILE.exists():
with open(WALK_GRAPH_FILE, "rb") as f:
WALK = pickle.load(f)
# Pickles built by build_walk_graph.py already ship CSR with `adj` dropped.
# These calls are no-ops then; they only kick in (and free memory) for an
# older pickle that still carries the tuple adjacency.
WALK.build_csr()
WALK.free_adj()
print(f"Loaded walk graph: {WALK.n_nodes} nodes (street-network access enabled, CSR)")
else:
print("No walk graph found — using straight-line access (run build_walk_graph.py)")
ALL_MODES = set(ROUTE_TYPE_MODE.values())
def _parse_time(hhmm: str) -> int:
h, m = hhmm.split(":")
return int(h) * 3600 + int(m) * 60
# --- single-flight isochrone cache -------------------------------------------
# The spine and fog endpoints are fired in parallel for the same origin. Without
# coordination each would recompute RAPTOR. This memoizes the IsochroneResult by
# request key and makes the second caller wait for (and reuse) the first's
# computation, so RAPTOR runs once per (origin, time, modes). Also serves instant
# repeats (e.g. re-clicking the same point).
_iso_cache: OrderedDict = OrderedDict()
_iso_events: dict = {}
_iso_lock = threading.Lock()
_ISO_CACHE_MAX = 8
def get_iso(lat: float, lon: float, time_str: str, modes: str):
allowed = {m.strip() for m in modes.split(",") if m.strip()} & ALL_MODES or ALL_MODES
dep = _parse_time(time_str)
key = (round(lat, 6), round(lon, 6), time_str, ",".join(sorted(allowed)))
with _iso_lock:
if key in _iso_cache:
_iso_cache.move_to_end(key)
return _iso_cache[key], dep
ev = _iso_events.get(key)
owner = ev is None
if owner:
_iso_events[key] = ev = threading.Event()
if not owner: # another request is computing it
ev.wait(timeout=30)
with _iso_lock:
if key in _iso_cache:
return _iso_cache[key], dep
result = compute_isochrone(NET, lat, lon, dep, MAX_BUDGET_MIN * 60,
allowed_modes=allowed, walk_graph=WALK)
with _iso_lock:
_iso_cache[key] = result
_iso_cache.move_to_end(key)
while len(_iso_cache) > _ISO_CACHE_MAX:
_iso_cache.popitem(last=False)
_iso_events.pop(key, None)
ev.set()
return result, dep
def _query(request, name: str, default=None):
return request.query_params.get(name, default)
def _origin(request):
"""Parse required lat/lon plus optional time/modes; returns a 400 JSONResponse
on bad input (else a 4-tuple)."""
try:
lat = float(request.query_params["lat"])
lon = float(request.query_params["lon"])
except (KeyError, ValueError):
return JSONResponse({"error": "lat and lon are required floats"}, status_code=400)
time_str = _query(request, "time", "08:00")
modes = _query(request, "modes", "metro,bus,rail,tram")
return lat, lon, time_str, modes
def meta(request) -> JSONResponse:
return JSONResponse({
"service_date": NET.service_date,
"feeds": NET.feeds,
"n_stops": NET.n_stops,
"modes": sorted(ALL_MODES),
"max_budget_min": MAX_BUDGET_MIN,
"center": [45.5152, -73.5616],
})
def lines(request) -> JSONResponse:
"""The distinct rapid-transit "spine" lines (metro / REM / exo trains) with
their official colours, for the legend. Buses are intentionally excluded —
they're consolidated into one colour on the map. Deduped by route_id; the
feed id (route_id prefix, e.g. 'stm', 'rem', 'exo_trains') tags the agency."""
seen: dict = {}
for r in NET.routes:
if r.route_type not in SPINE_TYPES or r.route_id in seen:
continue
seen[r.route_id] = {
"name": r.route_name,
"color": r.route_color,
"type": r.route_type,
"feed": (r.route_id.split(":", 1)[0] if ":" in r.route_id else ""),
}
# group order: metro (1) -> REM (0) -> exo trains (2); then by name
rank = {1: 0, 0: 1, 2: 2}
out = sorted(seen.values(), key=lambda d: (rank.get(d["type"], 9), d["name"]))
return JSONResponse({"lines": out})
def isochrone(request) -> JSONResponse:
"""Compute once at the max budget. Every stop/segment carries `travel`
(seconds from departure), so the client filters any smaller budget locally."""
parsed = _origin(request)
if isinstance(parsed, JSONResponse):
return parsed
lat, lon, time_str, modes = parsed
result, dep = get_iso(lat, lon, time_str, modes)
return JSONResponse({
"origin": [lat, lon],
"departure": time_str,
"max_budget_min": MAX_BUDGET_MIN,
"service_date": NET.service_date,
"count": len(result.stops),
"segments": result.segments,
})
def fog(request) -> StreamingResponse:
"""Reachable walk-area hexes as NDJSON [travel, q, r], streamed in increasing
travel order (near->far reveal). Only reachable cells are sent — the client
paints an opaque grey hex grid over the whole bbox itself and reveals these
cells via the budget filter, so the unreachable zone (incl. water) is fully
greyscale without us streaming every grey cell."""
parsed = _origin(request)
if isinstance(parsed, JSONResponse):
return parsed
lat, lon, time_str, modes = parsed
def generate():
if WALK is None:
return
result, dep = get_iso(lat, lon, time_str, modes)
cutoff = dep + MAX_BUDGET_MIN * 60
seen: set = set()
on_graph = [(WALK.stop_hex[r.stop_index], r.arrival)
for r in result.stops if WALK.stop_hex[r.stop_index] is not None]
off_graph = [(r.lon, r.lat, r.arrival)
for r in result.stops if WALK.stop_hex[r.stop_index] is None]
buf: list[str] = []
for gen in (egress_hex_graph(WALK, on_graph, cutoff, dep, seen=seen),
egress_hex_disc(off_graph, cutoff, dep, seen=seen)):
for travel, (q, r) in gen:
buf.append(f"[{travel},{q},{r}]")
if len(buf) >= 192: # smaller batches => the near hexes reveal sooner
yield "\n".join(buf) + "\n"
buf = []
if buf:
yield "\n".join(buf) + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")
def _warm_up() -> None:
"""Prime the hop-geometry cache and, on PyPy, force the JIT to compile and
stabilise the hot paths BEFORE any user clicks — otherwise the first few real
queries pay interpreter + compilation cost and spike (we measured a ~1.3 s
outlier cold). We run several representative queries: different regions
(downtown / on-graph suburb / off-graph), and a mode-filtered one (exercises
the route_allowed branch), repeated so the JIT sees enough iterations."""
cases = [
(45.5017, -73.5673, ALL_MODES), # downtown, all modes
(45.5017, -73.5673, {"metro", "rail"}), # mode filter branch
(45.46, -73.62, ALL_MODES), # west end
(45.55, -73.55, ALL_MODES), # east / on-graph
(45.62, -73.50, ALL_MODES), # far north (more off-graph)
]
t = time.perf_counter()
try:
for _ in range(2): # two passes so the JIT settles
for lat, lon, modes in cases:
compute_isochrone(NET, lat, lon, _parse_time("08:00"),
MAX_BUDGET_MIN * 60, allowed_modes=modes, walk_graph=WALK)
print(f"Warm-up: {2 * len(cases)} queries in {(time.perf_counter() - t) * 1000:.0f} ms")
except Exception as e: # never let warm-up block serving
print(f"Warm-up skipped: {e}")
_warm_up()
routes = [
Route("/api/meta", meta),
Route("/api/lines", lines),
Route("/api/isochrone", isochrone),
Route("/api/fog", fog),
# static frontend (mounted last so /api/* wins)
Mount("/", app=StaticFiles(directory=str(ROOT / "web"), html=True), name="web"),
]
# GZip the JSON responses (the ~1 MB spine compresses ~5-8x); the fog NDJSON
# stream is compressed chunk-by-chunk, so the near->far reveal is preserved.
app = Starlette(routes=routes, middleware=[Middleware(GZipMiddleware, minimum_size=500)])
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))