Author: Ashiq Hameed Live demo: https://ashiqhameed.github.io/rapidroute/RapidRoute.html Stack: Python · scikit-learn · NetworkX · OSMnx · JavaScript Focus: Applying shortest-path algorithms (Dijkstra, A*) to emergency response, with machine learning driving placement, dispatch and edge weights.
In an emergency, the time it takes an ambulance to arrive often decides the outcome. That response time depends on three decisions:
Classical systems make all three decisions with static rules: fixed stations, “send the nearest unit,” and routes that minimise distance. But a city doesn’t behave statically. Demand moves by hour and neighbourhood, and the same road takes very different times at 3 AM and 6 PM.
RapidRoute replaces each static rule with a learned one, and keeps a shortest path algorithm at the core of every decision.
| Decision | Static baseline | RapidRoute |
|---|---|---|
| Where to wait | Uniformly spread depots | K-means weighted by predicted demand |
| Edge cost for routing | Road length (metres) | Travel time scaled by a learned congestion factor for each zone and hour |
| Which unit to send | Nearest available unit | Nearest unit, or a Q-learning dispatch policy |
| Route | Dijkstra on distance | Dijkstra / A* on predicted travel time |
Objective: minimise mean ambulance response time and maximise the share of incidents reached within 8 minutes, especially critical calls.
All 63,806 real Manhattan EMS calls from Nov–Dec 2024 (never used for training) were replayed through a calibrated discrete-event simulator with 110 ambulances and 8 depots.
| Scenario | Mean RT (min) | P90 RT (min) | % ≤ 8 min | % critical ≤ 8 min |
|---|---|---|---|---|
| A · Static baseline: uniform depots, distance routing, nearest unit | 11.35 | 15.19 | 46.9 | 47.7 |
| B · + plain K-means depots | 11.35 | 13.90 | 46.7 | 46.3 |
| C · + demand-weighted K-means + ML travel-time routing | 8.96 | 12.15 | 56.8 | 56.7 |
| D · C + Q-learning dispatch | 9.28 | 12.94 | 55.4 | 56.7 |
| Observed, real FDNY, same period | 12.33 | 25.23 | 37.2 | 58.7 |
Going from A to C cuts mean response time by 2.4 minutes, adds 9.9 percentage points of calls reached within 8 minutes, and adds 9.0 points for critical calls.
What drives the gain (ablation, % of calls within 8 minutes):
| Placement | Distance routing | ML travel-time routing |
|---|---|---|
| Uniform grid | 46.9 | 48.4 |
| K-means (plain) | 46.7 | 48.0 |
| K-means (demand-weighted) | 55.1 | 56.8 |
| K-means (risk-weighted) | 52.3 | 54.1 |
The gains hold across fleet sizes and are largest when the fleet is stretched: at 100 ambulances, C reaches 44.3% of calls within 8 minutes against A’s 33.5%.
Shortest path is the engine. ML decides everything it runs on: the edge weights, the source nodes, which calls are risky, and which unit is sent.
┌──────────────── ML LAYER ───────────────────┐
EMS calls ─┤ Demand forecast → where calls will come │
│ Travel-time model → congestion factor/edge │
│ Late-risk model → which calls are at risk │
│ Weighted K-means → depot (source) nodes │
│ Q-learning agent → which depot dispatches │
└──────────────────────┬──────────────────────┘
▼
┌──────────── SHORTEST PATH CORE ─────────────┐
│ Multi-source Dijkstra → coverage map │
│ A* (admissible heuristic) → single route │
│ Dijkstra / Bellman–Ford from scratch, │
│ verified against NetworkX │
└─────────────────────────────────────────────┘
| Exp | Topic | Where it is used |
|---|---|---|
| 1 | Preprocessing, discretisation, feature engineering | Phase 1: cleaning, ZIP-to-zone reduction, cyclic encoding, time bands, fleet-load features |
| 2 | Naïve Bayes | Phase 4: Categorical NB on discretised features |
| 3 | Decision Tree | Phases 2, 3, 4: regressor and classifier, depth tuned |
| 4 | SVM | Phase 4: RBF SVM, C and γ tuned |
| 5 | Gradient descent and feature scaling | Phase 2: linear regression from scratch, scaled vs unscaled loss curves |
| 6 | K-means and K selection | Phase 5: depot placement, K chosen with Elbow, Silhouette, Davies–Bouldin and Calinski–Harabasz |
| 7 | Q-learning | Phase 7: dispatch agent |
| 8 | RL hyperparameter tuning | Phase 7: sweep over α, γ and ε-decay |
| Source | Use | Link |
|---|---|---|
NYC Open Data · EMS Incident Dispatch Data (76xm-jjuj) |
395,488 Manhattan incidents in 2024 | dataset page |
| OpenStreetMap drive network (Overpass API) | 4,545 nodes and 9,750 directed edges | scripts/get_osm.sh |
| US Census 2024 ZCTA Gazetteer | ZIP centroids | zip |
src/data/download.py fetches the EMS incidents and the ZCTA file. The road network comes from scripts/get_osm.sh, which tries four Overpass mirrors with retries; this was needed because the macOS system Python cannot connect to Overpass. The script builds data/raw/manhattan_drive.graphml.
Target: calls per zone per hour.
| Model | RMSE | R² (zone-hour) | R² (citywide) |
|---|---|---|---|
| Historical mean | 1.060 | 0.315 | 0.715 |
| Linear regression (GD from scratch) | 1.059 | 0.316 | 0.720 |
| Decision tree | 1.064 | 0.310 | 0.715 |
| Gradient boosting (Poisson) | 1.058 | 0.318 | 0.732 |
Zone-hour counts average about 1 call, so random (Poisson) noise caps R² at 0.367. Gradient boosting reaches 87% of that ceiling. For Exp 5: gradient descent with scaled features converges, the same learning rate on unscaled features diverges within 12 iterations, and a tiny learning rate on unscaled features is still slow after 300.
c(zone, hour): learned only from critical calls made while the local fleet was quiet, so that road traffic is separated from ambulance availability. It averages 1.09× and peaks at 1.16× at 18:00 (1.35× worst zone-hour).w(e, h) = free_flow_time(e) × c(zone(e), h) × κTarget: whether the response will take longer than 8 minutes, predicted from information known when the call arrives.
| Model | Accuracy | F1 | ROC-AUC |
|---|---|---|---|
| Majority class | 0.576 | – | 0.500 |
| Naïve Bayes | 0.671 | 0.685 | 0.747 |
| Decision tree | 0.680 | 0.688 | 0.757 |
| SVM (RBF) | 0.675 | 0.679 | 0.745 |
| Gradient boosting (reference) | 0.695 | 0.710 | 0.771 |
| Placement | Mean travel (min) | Demand within 8 min |
|---|---|---|
| Random (mean of 20) | 10.34 | 43.2% |
| Uniform grid | 7.51 | 62.2% |
| K-means (plain) | 7.46 | 55.8% |
| K-means (demand) | 6.93 | 65.0% |
| K-means (risk-weighted) | 6.99 | 63.9% |
networkx.dijkstra_path_length exactly, and Bellman–Ford matches Dijkstra on the full graph.| October | Real | Simulated |
|---|---|---|
| Median travel | 8.0 min | 8.0 min |
| Median response | 9.2 min | 8.4 min |
| P90 response | 26.6 min | 30.6 min |
reports/figures/.web/index.html is a self-contained HTML/CSS/JavaScript app with a dark dispatch-console design. It needs no server. Online it uses the Barlow typeface from Google Fonts; offline it falls back to the system font.
rapidroute/
├── README.md
├── RapidRoute.html
├── requirements.txt
├── config.yaml
├── run_all.py
├── scripts/
│ └── get_osm.sh
├── data/
│ ├── raw/
│ └── processed/
├── src/
│ ├── config.py
│ ├── data/
│ │ ├── download.py
│ │ ├── manhattan_drive.overpassql
│ │ └── preprocess.py
│ ├── models/
│ │ ├── gd_linear.py
│ │ ├── demand.py
│ │ ├── travel_time.py
│ │ └── risk.py
│ ├── placement/
│ │ └── kmeans_depots.py
│ ├── routing/
│ │ ├── graph.py
│ │ ├── dijkstra.py
│ │ ├── astar.py
│ │ └── router.py
│ ├── rl/
│ │ └── qlearning.py
│ └── evaluate/
│ ├── style.py
│ ├── eda.py
│ ├── routing_eval.py
│ ├── simulate.py
│ ├── scenarios.py
│ ├── export_web.py
│ └── build_standalone.py
├── reports/
│ ├── figures/
│ └── *.csv
└── web/
├── index.html
├── style.css
├── app.js
└── data.js
Python 3.9+, pandas, NumPy, scikit-learn, OSMnx, NetworkX, holidays, matplotlib, PyArrow and PyYAML. The web interface is plain HTML, CSS and JavaScript with no libraries.
Just viewing: double-click RapidRoute.html in the project root. It is the whole interface in one file, with the data and charts embedded, and can be shared on its own. web/index.html is the same interface but needs the rest of the folder next to it.
Full pipeline:
cd rapidroute
python3 -m pip install -r requirements.txt
bash scripts/get_osm.sh
python3 -m src.data.download
python3 run_all.py
open web/index.html
get_osm.sh downloads the road map; skip it if data/raw/manhattan.osm already exists.src.data.download builds the road graph and downloads any missing data.run_all.py takes about 10 minutes.python3 run_all.py scenarios export_web.| Decision | Why |
|---|---|
| Predict late-response risk instead of severity | Severity is assigned from the call type, so predicting it would be leakage |
| Add fleet-load features (open incidents, calls waiting) | They are known to a dispatcher in real time and raised AUC from 0.759 to 0.771 |
| Learn congestion from quiet-fleet critical calls | Busy-fleet calls mix up road traffic with ambulance availability and produced a reversed daily pattern |
| K = 8 | Elbow method, and close to the number of real FDNY EMS stations |
| Fleet = 110 and κ = 2.95 | Both calibrated on October so the simulator reproduces real median travel, median response and P90 |
| Demand-weighted over risk-weighted placement | Chosen on October validation (53.5% vs 50.9% of calls within 8 minutes) |
| Critical calls always get the nearest unit | Never trade time on life-threatening calls; RL decides only for lower-priority calls |
place in config.yaml to "Mumbai, Maharashtra, India" and update the Overpass area.