Skip to content

Repository files navigation

GTFS Pathways Router

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.

Try the demo

Once the packages are published, the demo is one command:

cd demo
npx gtfs-pathways-demo

No 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 showing SEPTA's Olney Transit Center: 77 of 77 entrance-to-platform routes reachable, each row giving time, distance, generalized cost, step-free availability, and expandable turn-by-turn signage steps.

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.

From a checkout

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

Implementations

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.

Supported runtimes

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.

Tested matrix

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/OTP policy

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.

Python policy

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.

Presubmit/release boundary

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)

Dependency metadata

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.


Standing on OTP's shoulders

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.


1. Why not just use OTP?

Three reasons, most important first.

Testing a data edit should not require a service restart

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.

Most of OTP is not involved in these trips

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.

The logic is small enough to reimplement and verify

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.

A smaller point: OTP cannot plan from an entrance

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.


2. The data: three files

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.

Default preferences are part of the data contract

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.


3. The code: eight places

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

What each contributes

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:

  1. traversal_time if set,
  2. else length / walk_speed if length > 0,
  3. else 0.4 × |stair_count| / walk_speed if stair_count > 0,
  4. 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.

Estimated scope

GTFS parser for three files, graph construction rules, Dijkstra, cost model, step mapper. That is the whole reimplementation.

Deliberate divergences

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 findStopLevel looks up getRegularStop, 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.


4. Testing: golden tests against a pinned OTP

One corpus is designed to be shared by all three implementations. It is currently executed by the Elixir, Python, and JavaScript implementations.

Setup

  1. Pin an OTP commit. Defaults and behavior change across releases; record the commit hash next to the corpus.
  2. 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.
  3. Use location_type=0 stops 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.
  4. Query the GraphQL API with direct WALK mode.

Compare

  • Itinerary duration
  • Walk distance
  • Step sequence: streetName and relativeDirection, including FOLLOW_SIGNS and ELEVATOR
  • Generalized cost, where the API exposes it

Cases to cover

  • Each branch of the time-precedence chain: traversal_time set; length only; stair_count only; 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.

Parity caveats to encode as test knowledge

  • OTP rounds preference values. Units.reluctance and Units.speed round to 2, 1, or 0 decimals depending on magnitude. Reproduce the rounding, or expect small drift.
  • Descending stairs get no stairs reluctance. isStairs() is steps > 0, and the reverse edge carries a negated stair_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=0 stops.

5. Current implementation and production verification

Implementation status

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.

How the Elixir implementation is verified

The verification strategy uses independent layers so that a single snapshot or test helper cannot establish correctness by itself:

  1. 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.
  2. 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.
  3. Contract coverage parses all 152 items in CONTRACT.md and fails unless every parity item has a qualifying unit test or synthetic-corpus reference. External data alone cannot satisfy this gate.
  4. Official SEPTA data is downloaded only to a caller-created temporary directory. Both the outer release and inner google_bus.zip hashes 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.
  5. 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.
  6. Olney entrance-to-platform divergence starts at real SEPTA location_type=2 Entrance A and routes to platform stop 32156 in walking and wheelchair modes. Exact totals and steps are protected as hand-computed D1 goldens. A separate pinned-OTP capture records no_route for that same entrance ID, proving that the library supplies the intended capability.
  7. 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.
  8. 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.

How the OTP parity target stays current

OTP is an explicit compatibility target, not a floating dependency:

  1. corpus/otp-pin.json records the upstream repository, branch, exact commit, commit date, shaded-JAR name, serialization version, and required JDK.
  2. The capture and differential tasks refuse a JAR whose filename, reported commit, serialization version, or Java major version differs from that pin.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

About

Plan in-station trips from GTFS Pathways data

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages