When a transit agency produces GTFS Pathways data, the team that made it wants to see it work: plan trips through the station, check the signage steps and elevator routing, edit the data, look again. OpenTripPlanner contains the right routing logic for those trips. It is also built to serve one finished feed per process, which makes it hard to check in-progress work or to compare multiple GTFS versions.
gtfs-pathways-router extracts OTP's in-station routing into small standalone libraries. Load three GTFS files, ask for a trip between any two station elements, get an itinerary with turn-by-turn signage steps.
Once the packages are published, the demo is one command:
cd demo
npx gtfs-pathways-demoNo project setup, and the download is cached, so it runs offline after the first use.
Neither package is on the npm registry yet, so today you run it from a checkout of this repository — see From a checkout below.
Either way, open the printed http://127.0.0.1:<port> URL and drop in a GTFS .zip.
The demo reads stops.txt, pathways.txt, and levels.txt straight out of the archive and runs the JavaScript library's load() and build() over them. It reports three views of the feed:
- Stations — every top-level station with pathway endpoints. Open one for its entrance-to-platform routes: time, distance, generalized cost, step-free availability, and the signage steps for each.
- Diagnostics — parse and build diagnostics, grouped by the station they affect.
- Stats — archive members, row counts, byte accounting, and per-stage timings.
Requires a Node LTS line: 22.13+, 24, or 26. The feed is never uploaded — every byte is read in the browser.
| Flag | Effect |
|---|---|
--port <n> |
Bind a specific port (default: ephemeral) |
--no-open |
Print the URL without launching a browser |
Tested against Chromium, Firefox, and WebKit. See demo/README.md for details.
The demo bundles the JavaScript library at build time, so pack the library first and install that tarball into the demo. Installing javascript/ as a folder is not enough: the bundler resolves the library's own dependencies through the install, and a linked folder does not carry them.
cd javascript
npm pack --pack-destination ..
cd ../demo
npm install ../gtfs-pathways-router-0.1.0.tgz
npm run build
node src/cli.js| Language | Status | Reason |
|---|---|---|
| Elixir | Implemented and release-gated | Integration into Pathways Studio, an Elixir GTFS Pathways editing tool |
| Python | Implemented and release-gated | In-station planning for the transit-data community without a Java service |
| JavaScript | Implemented and release-gated | Same, in the browser and in Node. See javascript/README.md |
| Demo | Local package runner | Consumer of the JavaScript library; serves the bundled in-browser planner via npx gtfs-pathways-demo. Not an implementation of CONTRACT.md |
Related documents in this folder:
docs/GTFS-Pathways-in-OTP.md: field-by-field account of how OTP handles each pathway field.docs/OpenStationTripPlanner-Analysis.md: deep guide to the OTP code paths, with full cost formulas.docs/OpenStationTripPlanner-Verification.md: the claims below, tested live against a real agency's published pathways data.
Language-specific installation and usage:
python/README.md: Python package, zero runtime dependencies, Python 3.11+.javascript/README.md: JavaScript/ESM package for Node 22+ and browsers.
The machine-readable authority for what this repository tests is
ci/support-matrix.json. Every CI matrix is generated
from that file; the tables below restate it for humans.
| Language | Runtime | Role |
|---|---|---|
| Elixir | 1.19.5 / OTP 26.2.5.21 | oldest supported minor, lowest OTP |
| Elixir | 1.19.5 / OTP 28.5.0.4 | oldest supported minor, highest OTP |
| Elixir | 1.20.2 / OTP 27.3.4.15 | newest supported minor, lowest OTP |
| Elixir | 1.20.2 / OTP 29.0.4 | primary — newest minor, highest OTP |
| Python | 3.11 | quality target (lint, mypy, packaging) |
| Python | 3.12 | behavior only |
| Python | 3.13 | behavior only |
| Python | 3.14 | behavior only |
| Node | 22.13.0 (floor) | exact floor |
| Node | 22.x (latest) | latest patch on major 22 |
| Node | 24.0.0 (floor) | exact floor |
| Node | 24.x (latest) | latest patch on major 24 |
| Node | 26.0.0 (floor) | exact floor |
| Node | 26.x (latest) | latest patch on major 26 |
elixir/mix.exs declares elixir: "~> 1.19", which admits every stable 1.x
minor from 1.19 onward. Mix has no field for an OTP requirement, so this section
and the manifest are the OTP declaration: for each supported Elixir minor, CI
tests the lowest and highest OTP major that minor supports, pinned to exact
patch versions. The upstream compatibility listing at
https://elixir-lang.org/docs.html is the source; beam.provenance in the
manifest records the resolution date.
requires-python = ">=3.11" carries no upper bound. Supported means tested:
a Python minor is supported when it appears in the manifest and has a matching
ci-py* tox environment. Newer minors are untested until listed.
CI runs hermetic suites only. The following are release-only and never gate a pull request:
- External SEPTA data downloads and verification
- Real-JVM capture against the pinned OTP JAR
- Generated differential runs (random feeds through a real OTP build)
npm run release:check(requires six-runtime evidence and a prepared SEPTA case)
Elixir and JavaScript have deterministic lockfiles (mix.lock,
package-lock.json). Python CI tooling is pinned by ci/constraints.txt,
which pins direct dependencies only; transitive dependencies float because a
full transitive lock cannot remain installable across CPython 3.11–3.14.
This project exists because OpenTripPlanner solved the hard problems first. OTP's contributors spent years getting the cost model right: the reluctance composition, the elevator weight accumulation, the traversal-time precedence chain, the wheelchair slope penalties. The routing behavior this library reproduces was designed, debated, and refined in the open by people who understood transit planning at a depth we did not start with.
What we did was smaller. We read OTP's source carefully, extracted the in-station subset, and reimplemented it in languages that do not require a JVM. The correctness target was always OTP's output, not our own intuition. Where we diverge, we say so explicitly and record why (section 3, "Deliberate divergences"). None of this work makes sense without the upstream project, and the libraries are worse for any place they drifted from OTP's semantics without noticing.
OTP is the reference. These libraries are the portable, embeddable subset.
Three reasons, most important first.
OTP builds or loads a graph, then serves it (OTPMain.startOTPServer). There is no reload endpoint. ConfigModel.java states the position directly: config "is cached, and not reloaded even if it is changed on the filesystem." Changing GTFS data means rebuilding the graph and restarting the process. Realtime updaters do not help: GTFS-RT and SIRI overlay trip-time changes on a snapshot and never touch pathways, stops, or levels.
Versions compound the problem. Multiple different feeds coexist fine (entity IDs are feed-scoped), but two versions of the same feed cross-contaminate one graph: transfer generation links elements across both, and both are live by calendar date. Comparing "before edit" and "after edit" means two instances or a restart between builds.
For an editor where a user tweaks pathways.txt and wants to see the effect, a rebuild and restart between every edit makes the tool unusable. In a library, a feed version is just an argument: build the station graph in memory, per call, from whichever version the user is inspecting.
For a walk-only in-station query, OTP bypasses most of itself. Transit routing short-circuits immediately (TransitRouter.route() returns empty when transit is disabled). OSM is unused; pathway edges come from GTFS. Realtime is unused. What remains is a small graph and a shortest-path search.
The unused parts cannot be skipped. The OneBusAway reader requires agency, routes, trips, stop_times, and calendar files before it will load anything, so testing one station's pathway data means fabricating and maintaining a complete feed, then building a graph and running a JVM service.
What remains after the bypass is a closed set of rules: three input files, explicit graph-construction rules, a cost model built from published constants, and a shortest-path search. That is small enough to implement as a library in any language, and, more importantly, testable: run the same queries against a pinned OTP build and assert the outputs match (section 4). Stop-to-stop routing, signage steps, elevator decomposition, and wheelchair rerouting have already been verified this way against a real agency's published station data.
OTP's stop-ID index contains only location_type=0 stops (see StreetIndex.java: the stopVertices map is built exclusively from TransitStopVertex). Entrances, pathway nodes, and boarding areas cannot be named as an origin or destination. A pinned, GTFS-only OTP capture using Olney's real Entrance A ID returns no route. Coordinate fallback would require a street graph; this library intentionally accepts station-element IDs instead and never downloads or uses OSM.
This is a known, fixable limitation rather than a reason to leave OTP. But it blocks the query an in-station tool asks most, entrance to platform, so all three libraries make every station element addressable.
GTFS only. No OSM.
stops.txt: stop_id, location_type (0–4), parent_station, level_id, wheelchair_boarding, stop_lat, stop_lon, stop_name.
pathways.txt: every field except min_width and wheelchair_traversal_time, which OTP does not read. PathwayMapper maps mode, from/to stop, signposted_as, reversed_signposted_as, is_bidirectional, and the four optional numerics (traversal_time, length, stair_count, max_slope), each only when the source marks it as set.
levels.txt: level_index and level_name, joined via level_id. Consulted only to count elevator hops.
The extracted libraries need these three files. OTP needs a complete valid feed to build at all.
Output only matches OTP if the defaults match:
| Setting | Default | Source |
|---|---|---|
| Walk speed | 1.33 m/s | WalkPreferences |
| Walk reluctance | 2.0 | WalkPreferences |
| Stairs reluctance | 2.0 | WalkPreferences |
| Metres per stair step | 0.4 | PathwayEdge.traverse |
| Wheelchair max slope | 0.083 | WheelchairPreferences |
| Wheelchair inaccessible-street reluctance | 25 | WheelchairPreferences |
| Wheelchair stairs reluctance | 100 | WheelchairPreferences |
| Wheelchair slope-exceeded reluctance | 1 | WheelchairPreferences |
| Elevator board cost | 15 s | ElevatorPreferences |
| Elevator board slack | 90 s | ElevatorPreferences |
| Elevator hop time | 20 s per level | ElevatorPreferences |
| Elevator reluctance | 2.0 | ElevatorPreferences |
In the station we tested, the same platform trip takes 130 seconds walking and 324 seconds in wheelchair mode, because the cost model reroutes through two elevators to avoid the stairs. Get a constant wrong and the library recommends a different path through the station than the reference does.
Full formulas (reluctance composition, elevator weight accumulation, slope penalty shape) are in OpenStationTripPlanner-Analysis.md.
| File | Purpose |
|---|---|
application/.../gtfs/mapping/PathwayMapper.java (+ PathwayModeMapper, StopMappingWrapper) |
GTFS import into the pathway/station-element model, including the level join |
application/.../graph_builder/module/AddTransitEntitiesToGraph.java |
Vertex and edge construction rules |
street/.../street/model/edge/PathwayEdge.java |
Traversal-time precedence and weight |
street/.../street/model/edge/StreetEdgeReluctanceCalculator.java |
Walk and wheelchair reluctance formulas |
street/.../street/model/edge/Elevator{Board,Hop,Alight}Edge.java |
Elevator cost model |
street/.../street/geometry/SphericalDistanceLibrary.distance |
Great-circle fallback distance |
| Search | Plain Dijkstra suffices |
application/.../routing/algorithm/mapping/StatesToWalkStepsMapper.java |
Step and narrative mapping |
Graph construction (AddTransitEntitiesToGraph): one vertex per station element. Non-elevator pathways become a PathwayEdge; when length is absent or zero, the distance is the great-circle distance between the two vertices. Bidirectional pathways get a reverse edge carrying reversed_signposted_as and negated stair_count and max_slope. Boarding areas get implicit low-cost pathway edges to and from their parent stop. Elevator pathways decompose into board, hop, and alight edges, one triple per direction, with the hop level count taken from the levels.txt indexes (falling back to 1 level when either end has no level).
Traversal time precedence (PathwayEdge.traverse), in order:
traversal_timeif set,- else
length / walk_speediflength > 0, - else
0.4 × |stair_count| / walk_speedifstair_count > 0, - else no elapsed time and a flat weight of 1.
When time is non-zero, weight is seconds × reluctance. Reluctance is the wheelchair formula when wheelchair mode is on, otherwise walk_reluctance × (stairs ? stairs_reluctance : 1).
Elevators: board adds boardCost + reluctance × boardSlack weight and boardSlack time; hop adds reluctance × time and time, where time is traversal_time if positive, else hopTime × levels; alight adds weight 1 and no time.
Search: OTP runs A* with a Euclidean heuristic. Inside a station the heuristic saves no work: the coordinates barely differ, and an elevator covers zero horizontal distance. Plain Dijkstra produces identical paths and is simpler to keep consistent across three languages.
Steps: a pathway edge with signposted_as produces a step named by the sign text with relativeDirection: FOLLOW_SIGNS; without it, the name falls back to the literal string "pathway". Elevator alight produces relativeDirection: ELEVATOR.
GTFS parser for three files, graph construction rules, Dijkstra, cost model, step mapper. That is the whole reimplementation.
Each divergence is intentional and recorded here, not discovered later:
- Any station element may be an endpoint. Entrances, pathway nodes, and boarding areas are addressable by ID. The reason this project exists.
- No street network. Coordinate-based origins are out of scope; queries address station elements by ID.
- No A*. Dijkstra only, for portability.
- Levels resolve for all station elements. OTP's
findStopLevellooks upgetRegularStop, so a level on an entrance or pathway node never reaches the elevator hop count. The libraries read the level from whichever element carries it. - Bad data produces diagnostics. Invalid in-progress editor data drops only the unusable entity or edge rather than aborting the entire build.
- No zag-removal post-processing. The library preserves deterministic pathway instructions that OTP may coalesce.
- Flat itinerary output. An in-station route is returned directly rather than wrapped in a one-leg transit itinerary.
- Feed-local string IDs. A graph represents one feed version; separately built versions cannot contaminate one another.
- Structured errors. No-path and same-origin requests return explicit Elixir error tuples instead of OTP's empty itinerary list or transit-oriented routing error.
Everything not on this list is a parity target.
One corpus is designed to be shared by all three implementations. It is currently executed by the Elixir, Python, and JavaScript implementations.
- Pin an OTP commit. Defaults and behavior change across releases; record the commit hash next to the corpus.
- Build a GTFS-only graph. No OSM. A single-station feed builds and serves in under a minute. OTP handles pathway graphs fine without a street network.
- Use
location_type=0stops as test endpoints. That is all OTP can address. Entrance-to-platform cases still get fixtures, but their expected values are computed by hand from the cost model, not captured from OTP. - Query the GraphQL API with direct WALK mode.
- Itinerary duration
- Walk distance
- Step sequence:
streetNameandrelativeDirection, includingFOLLOW_SIGNSandELEVATOR - Generalized cost, where the API exposes it
- Each branch of the time-precedence chain:
traversal_timeset;lengthonly;stair_countonly; nothing set. - Wheelchair mode: slope penalty above and below
maxSlope, stairs reluctance, elevator accessibility costs. - Bidirectional pathways traversed both ways, checking reverse signage and negated stairs/slope.
- Elevators spanning one level and several levels.
- Boarding-area hops over the implicit low-cost edges.
The station feed we verified against is the first corpus entry. It exercises signposts, stairs, escalators, elevators with levels.txt, fare gates, paid-area topology, boarding-area bridging, and accessible versus stairs-only entrances in one station. It also covers ground OTP's own test suite does not: the only pathway fixture in the OTP repo has no levels, no elevators, and no signposted pathways.
- OTP rounds preference values.
Units.reluctanceandUnits.speedround to 2, 1, or 0 decimals depending on magnitude. Reproduce the rounding, or expect small drift. - Descending stairs get no stairs reluctance.
isStairs()issteps > 0, and the reverse edge carries a negatedstair_count, so downward stairs are treated as flat. - Empty pathways add weight 1 with zero elapsed time. A pathway with no time, length, or stairs still costs something and takes nothing.
- Elevator levels default to 1 when either endpoint has no resolvable level, and OTP resolves levels only for
location_type=0stops.
The Elixir package under elixir/ provides GTFS CSV loading, validated
station-graph construction, deterministic Dijkstra search, walking and
wheelchair costs, elevator decomposition, signage steps, diagnostics, and a
reusable plan/2 API.
The Python package under python/ provides the same in-station planning
boundary as a typed, zero-runtime-dependency library. Its release gate builds
and inspects the wheel and sdist, runs a clean-install smoke test, and checks
repository parity against the shared corpus. See python/README.md for
installation and usage.
The JavaScript/TypeScript package under javascript/ provides the same
in-station planning boundary as an ESM library for Node 22+ and browsers. Its
release gate builds one tarball, verifies it across three supported Node
runtime lines, and checks browser compatibility via Playwright. See
javascript/README.md for installation and usage.
The verification strategy uses independent layers so that a single snapshot or test helper cannot establish correctness by itself:
- Unit and integration tests cover CSV parsing, diagnostics, graph construction, every traversal-time branch, preference normalization, wheelchair penalties, elevator edges, deterministic tie-breaking, public errors, step generation, and row-order independence.
- The synthetic corpus contains minimal, exact golden cases for the behavioral contract. Successful queries assert duration, distance, generalized cost, and every step's direction, name, and distance.
- Contract coverage parses all 152 items in
CONTRACT.mdand fails unless every parity item has a qualifying unit test or synthetic-corpus reference. External data alone cannot satisfy this gate. - Official SEPTA data is downloaded only to a caller-created temporary
directory. Both the outer release and inner
google_bus.ziphashes are verified before slicing the Olney Transit Center closure: 87 stops, 112 pathways, and 3 levels. No GTFS publisher bytes or OSM data are committed. - Olney stop-to-platform parity compares walking and wheelchair totals against the pinned OTP build and asserts the library's complete step sequences. The wheelchair sequence is exact; the walking sequence retains the documented D6 instructions that OTP's zag removal coalesces.
- Olney entrance-to-platform divergence starts at real SEPTA
location_type=2Entrance A and routes to platform stop32156in walking and wheelchair modes. Exact totals and steps are protected as hand-computed D1 goldens. A separate pinned-OTP capture recordsno_routefor that same entrance ID, proving that the library supplies the intended capability. - Generated differential testing creates deterministic valid GTFS feeds, builds a real graph with the pinned OTP JAR, and compares the same queries with the Elixir library. Duration, generalized cost, availability, and full step sequences are compared. One-sided ordinary-stop failures, malformed batches, and oracle-process failures are hard mismatches; only the narrowly detected D1 and D4 cases may be skipped.
- Release gates include strict formatting, Credo, Dialyzer, coverage, performance, a real-JVM capture smoke test, production compilation with warnings as errors, documentation generation, an unpacked Hex-package inspection, JSON and shell validation, and committed-data guards.
The deliberate divergences have explicit evidence rather than being hidden inside parity skips:
| Divergence | Verification |
|---|---|
| D1 — any station element is addressable | Direct public tests for entrances, pathway nodes, and boarding areas; CSV and synthetic multi-hop cases; official Olney entrance walking/wheelchair routes; pinned OTP no_route reference |
| D2 — no street/coordinate origins | Public API accepts element IDs only and explicitly returns unknown_element for a coordinate-like origin; every official and differential graph is GTFS-only |
| D3 — Dijkstra rather than A* | Search unit tests, deterministic equal-cost tie tests, measured random-feed tie frequency, and the performance budget |
| D4 — resolve levels on all station elements | Builder tests for entrance levels and a differential regression that permits a D4 skip only when OTP-bug emulation fully explains the result |
| D5 — diagnostics rather than build abort | Loader fault tests and a degraded-feed integration case that routes the unaffected graph while asserting every diagnostic |
| D6 — no zag removal | Exact official Olney walking steps retained beside OTP's shorter captured sequence |
| D7 — flat itinerary | Public entrance route asserts an Itinerary with no legs wrapper |
| D8 — feed-local IDs | Separate graphs with identical IDs and different costs are planned repeatedly and proven isolated |
| D9 — structured errors | Public tests assert no_path, same_origin_and_destination, and unknown_element tuples |
Current release evidence:
| Gate | Result |
|---|---|
mix check |
1 doctest and 309 tests, 0 failures; 95.99% coverage; 96/96 parity contracts; Credo and Dialyzer clean |
| Official SEPTA/Olney suite | 51 tests, 0 failures; 1 intentional pinned-OTP D1 reference skip |
| Generated real-OTP differential | 198 queries; 33 comparable matches; 0 mismatches; 109 both-unavailable and 56 D1 endpoint skips |
| Real-JVM capture smoke | 1 test, 0 failures against the pinned OTP JAR and Java 21 |
| Performance | 1,000-pathway graph build remains below the 250 ms budget |
| Release artifact | Production compile, ExDoc generation, and unpacked Hex-package inspection pass |
| Data policy | Repository and staged-content guards find no committed GTFS ZIP, OSM, PBF, or generated Python cache data |
The exact official-feed commands, hashes, route totals, and current
differential results are recorded in
docs/SEPTA-Olney-Verification.md.
OTP is an explicit compatibility target, not a floating dependency:
corpus/otp-pin.jsonrecords the upstream repository, branch, exact commit, commit date, shaded-JAR name, serialization version, and required JDK.- The capture and differential tasks refuse a JAR whose filename, reported commit, serialization version, or Java major version differs from that pin.
- Updating OTP starts by selecting and reviewing a new upstream commit. The pin is changed only together with a JAR rebuilt from that exact commit.
- The maintainer reruns the real-JVM smoke, recaptures eligible OTP golden cases, executes the generated differential matrix, and reruns the official Olney routes. Hand-computed D1/D4 cases cannot be overwritten by capture.
- Every output change is classified as an upstream correction, an
implementation defect, or a deliberate divergence.
CONTRACT.md,PORTING.md, corpus expectations, and tests are updated together; a newly accepted divergence receives a stable divergence ID. - The full release gate must pass before merging or publishing. A compatibility change that affects public behavior requires the corresponding package version change and release notes.
