Vulture
Rust implementation of RAPTOR (Delling, Pajor, Werneck): given a transit network, find all Pareto-optimal journeys between two stops, trading fewer transfers against earlier arrival.
Quick start
The gtfs module wraps a parsed GTFS feed and implements the Timetable trait. The repo bundles aux/dmrc_gtfs.zip (Delhi Metro) so you can run the example without downloading anything:
use Gtfs;
use date;
use ;
use GtfsTimetable;
let gtfs = new?;
let tt = new?;
let start = tt.stop_idx.expect;
let target = tt.stop_idx.expect;
let journeys = tt
.query
.from
.to
.max_transfers
.depart_at
.run;
for j in &journeys
Runnable as cargo run --example gtfs-timetable -- aux/dmrc_gtfs.zip 2024-01-15 1 44.
A returned Journey has plan: Vec<(RouteIdx, StopIdx)> ("take this route, get off at this stop"), an origin and target (relevant when the query supplies multiple of either), and a label carrying the criterion under optimisation. journey.arrival() reads it as a SecondOfDay. See Journey for the full contract; for trip IDs and per-leg depart/arrive times see Journey::with_timing.
Gtfs::new(path) is the local-file form; the same gtfs-structures API also exposes Gtfs::from_url (reqwest-backed sync fetch), Gtfs::from_url_async, and Gtfs::from_reader (any Read + Seek), all returning the same Gtfs value GtfsTimetable::new consumes:
let gtfs = from_url?;
let tt = new?;
True streaming isn't useful — GTFS is a zip of CSVs, the parser has to buffer the whole archive before any of it is queryable.
Worked examples
Berlin VBB: station-level query
A station like Berlin Hbf has hundreds of platforms; you don't usually care which one. GtfsTimetable::station_stops(parent_id) expands a parent station to its child platforms and the multi-source/multi-target search picks the best combination:
let tt = new?;
let journeys = tt
.query
.from // Hbf – ~301 platforms
.to // Alex – ~50 platforms
.max_transfers
.depart_at
.run;
Returns a direct S-Bahn boarding at 09:01, arriving Alex at 09:07 – across a feed of ~42k stops, ~71k active trips, in ~385 µs.
Helsinki HSL: augmenting sparse footpaths
HSL ships an empty transfers.txt, so out of the box no walking transfers exist between physical stops. Build coordinate-derived walking edges before querying:
let tt = new?
.with_walking_footpaths; // 500 m at 5 km/h
let journeys = tt
.query
.from // Kamppi metro
.to // Itäkeskus metro
.max_transfers
.depart_at
.run;
with_walking_footpaths uses an R-tree over an equirectangular projection (~0.5% accurate at city scale) and preserves any pre-existing transfers.txt entries.
Paris IDFM: the expensive case
Châtelet → Versailles Rive Droite crosses the IDFM region: the expanded RER feed has ~54k stops, ~146k active trips per weekday, and the optimal journey involves the RER C from Saint-Michel-Notre-Dame:
let tt = new?
.assert_footpaths_closed; // IDFM's transfers.txt is publisher-curated
let journeys = tt
.query
.from // Châtelet
.to // Versailles RD
.max_transfers
.depart_at
.run;
Returns in ~17 ms. Smaller cross-Paris queries (Châtelet → Gare du Nord, Châtelet → La Défense) come back in under 1 ms. See docs/cross-city-benchmarks.md for the full numbers and methodology.
Range queries: "leave between X and Y"
let profile = tt
.query
.from
.to
.max_transfers
.depart_in_window
.run;
Returns Vec<RangeJourney> – each entry is a (depart, journey) pair, Pareto-filtered on (later depart, fewer transfers, earlier arrival). Serial uses rRAPTOR (single reverse-chronological scan reusing labels across departures); .run_par() spreads the per-departure work across a Rayon thread pool.
Server workload: many queries, multi-threaded
Allocate a RaptorCachePool once and have each thread check out a cache:
use *;
use RaptorCachePool;
let pool = for_timetable;
let results: = queries.par_iter.map.collect;
The pool's checkout() returns an RAII guard that returns the cache on drop; same pool serves any number of threads with no per-thread bookkeeping.
Features
Timetabletrait – describes a transit network. Use the bundledGtfsTimetablefor any GTFS feed, or implement directly for non-GTFS data.- Typestate query builder –
tt.query().from(...).to(...).max_transfers(...).depart_at(...).run(). Compile-time enforced order;.depart_at(...)and.depart_in_window(...)switch the return type. - Multi-source / multi-target –
.from(...)and.to(...)accept aStopIdx, a slice, aVec, or(stop, walk_offset)pairs. Pair withstation_stops(parent_id)for "any platform of this station" queries. - Range queries –
.depart_in_window(times)returns a Pareto profile; serial path uses rRAPTOR,.run_par()and.run_with_pool(&pool)use a parallel naïve batch. Labeltrait – single-criterionArrivalTimeis the default.vulture::labelsshipsArrivalAndWalkfor trade-off queries (slower routes with less walking). ImplementLabelfor fares, accessibility, etc.- Walking footpaths from coordinates –
with_walking_footpaths(>fs, max_dist_m, speed_m_per_s)builds bidirectional R-tree walking edges, preserving any existingtransfers.txt. - Closed-footpath fast path – if your
transfers.txtis the entire intended relation,assert_footpaths_closed()opts into a single-pass O(E) relaxation instead of Dijkstra. RaptorCache– reusable scratch allocations across queries against one timetable.RaptorCachePoolis theSyncvariant for thread pools.- Per-leg timing –
journey.with_timing(&tt, depart, origin_walk)reconstructs trip IDs and per-leg depart/arrive times.Journey.planalone is just topology. - Wheelchair-accessibility filter – chain
require_wheelchair_accessible()on the query builder to skip trips wheretrips.wheelchair_accessible = 2and stops wherestops.wheelchair_boarding = 2. Default behaviour is unchanged.
When To Use What
| Goal | API |
|---|---|
| Single query, default routing | tt.query()...run() |
| Many queries, one server | allocate a RaptorCache, finish each chain with .run_with_cache(&mut cache) |
| Same as above, multi-threaded | RaptorCachePool::for_timetable(&tt), pool.checkout() per worker |
| "Leave between X and Y" | .depart_in_window(times).run() (serial rRAPTOR), or .run_par() (multi-core wins on wide windows) |
| "Slower route with less walking" | tt.query_with_label::<ArrivalAndWalk>()...run() |
| Wheelchair-only routing | tt.query()....require_wheelchair_accessible()....run() |
| Any platform of a station | tt.station_stops(parent_id) into .from(...) / .to(...) |
Sparse transfers.txt |
.with_walking_footpaths(>fs, max_dist_m, speed_m_per_s) at construction |
| Trip IDs and per-leg times in output | journey.with_timing(&tt, depart, origin_walk) |
Perf
Single-query latency, warm RaptorCache, M-series Apple Silicon, single thread. Full numbers (load times, multi-trip queries, methodology) in docs/cross-city-benchmarks.md:
| Feed | Stops | Trips/day | Representative query | Latency |
|---|---|---|---|---|
| Delhi Metro | 262 | 5,400 | 2-trip cross-network | 22 µs |
| Helsinki HSL | 8,400 | 24,000 | Kamppi → Itäkeskus (with walking) | 5.9 ms |
| Berlin VBB | 42,000 | 71,000 | Hbf → Alex (station-to-station) | 385 µs |
| Paris IDFM | 54,000 | 146,000 | Châtelet → Versailles RD | 17 ms |
For range-query latencies (serial rRAPTOR vs parallel naïve batch), see the bench source and the linked benchmark page. For a head-to-head against an independent RAPTOR implementation (the TypeScript raptor-journey-planner) on the same four feeds — including the correctness diff and a diagnosed timezone bug in the upstream library — see docs/cross-impl-comparison.md.
Soundness
For an issue-by-issue walk through the algorithmic correctness of the implementation against the paper – including the historical record of bugs found and fixed – see docs/soundness.md. The vulture-proptest harness runs the algorithm against a brute-force reference solver on every test invocation as live validation.
Cargo features
parallel(default-on) – pulls inrayon, enablesQuery::run_par/Query::run_with_pool. Opt out withdefault-features = falsefor wasm or minimal builds;RaptorCachePoolitself stays available.gtfs-bench– enables thegtfscriterion benchmark.internal– enables themanualcriterion benchmark overmanual::SimpleTimetable.
License
Apache-2.0