Add stop_direction to stops.txt — cardinal direction of travel for stop/platform disambiguation
- Dominant language
- No language data
- Stars
- 1.1k
- Forks
- 225
- Avg merge
- 7d 17h
- Merged PRs (30d)
- 3
Description
## Describe the problem
In the vast majority of transit networks, a single on-street location is served by **two separate stops on opposite sides of the road** — one for each direction of travel (north/south or east/west). GTFS today has **no machine-readable, geographic way to express which direction of travel a stop serves.**
This creates two concrete, rider-facing problems:
1. **Riders cannot reliably tell the two stops apart.** When an app shows two stops a few meters apart, or a rider physically stands between two poles, there is no structured signal telling them (or the app) which pole is "the one going my way." Today this information, when present at all, is buried in free text inside `stop_name` (e.g. *"Main St (EB)"*) — inconsistent, language-locked, and not parseable.
2. **Consumers cannot reliably snap a realtime vehicle to the correct stop of a pair.** A GTFS-Realtime `VehiclePosition` carries a `bearing`, but the static stop has no comparable attribute. Matching a moving vehicle to one of two opposite stops by proximity alone is a ~50/50 guess, which degrades the arrival predictions shown to riders.
The direction of travel served by a stop is **stable, intrinsic to the physical stop, and known to the agency** (it is, in practice, printed on the pole). It is, however, **expensive and unreliable for each consumer to re-derive**: it requires high-quality `shapes.txt` (which many feeds lack or provide with poor geometry), per-stop geometric computation, and still produces inconsistent results at bin boundaries. Standardizing it lets the producer publish the authoritative value **once**, for all consumers.
> **Important clarification (and why this works on curves):** the value describes the direction the vehicle is heading **as it serves / approaches that specific stop** (its instantaneous heading at the stop), **not** where the trip eventually ends up. On a curved road, only the local heading at the stop matters — if a vehicle passes the stop heading west, the stop is "West", regardless of whether the line later turns south. This keeps the attribute well-defined for every stop, including those on bends, ramps and curves.
## Use cases
**1. Stop disambiguation in the UI (primary, rider-facing).**
A rider opens the app at a junction served by two opposite poles. Instead of two identical "Main St" entries, the app labels them *"Main St — Northbound"* and *"Main St — Southbound"* and walks the rider to the correct pole. The enum is a **language-neutral code** that each app localizes (`Nord` / `North` / `Norte`), the same internationalization rationale already used for `platform_code`.
**2. More accurate realtime, hence better predictions (rider-facing, via consumer logic).**
A consumer matching a GTFS-Realtime vehicle to the network compares the vehicle's `position.bearing` against the candidate stops' `stop_direction` and snaps to the correct stop of the pair. The result — a correct, non-flickering arrival countdown for the right pole — is passenger information.
**3. Automatic detour / re-route detection (producer & consumer).**
When a vehicle deviates from its scheduled shape, systems that re-attach it to nearby stops need each stop's served direction to avoid attaching the vehicle to the opposite-direction pole.
## Proposed solution
Add one **optional** field, `stop_direction`, to **stops.txt**:
| Field | Type | Presence | Description |
|---|---|---|---|
| `stop_direction` | Enum | Optional | Cardinal direction of travel of vehicles **as they serve / approach this stop/platform**, quantized to the 8 principal compass points. This is the **instantaneous heading of the vehicle while serving the stop** — semantically aligned with GTFS-Realtime `VehiclePosition.position.bearing` — **not** the side of the street, the orientation of the stop pole, nor the trip's final destination. Because only the local heading at the stop is used, the value is well-defined even on curves. Valid options:
`0` (or empty) – Not specified, or variable (terminals, loops, or a single stop served in multiple directions).
`1` – North (bearing 337.5°–22.5°)
`2` – Northeast (22.5°–67.5°)
`3` – East (67.5°–112.5°)
`4` – Southeast (112.5°–157.5°)
`5` – South (157.5°–202.5°)
`6` – Southwest (202.5°–247.5°)
`7` – West (247.5°–292.5°)
`8` – Northwest (292.5°–337.5°)
**Conditionally Forbidden:**
- **Forbidden** for stations (`location_type=1`), entrances/exits (`location_type=2`) and generic nodes (`location_type=3`).
- Optional for stops/platforms (`location_type=0`) and boarding areas (`location_type=4`). |
**Why 8 points, not 4 or 360.**
- **360° (continuous):** rejected for rider display — people understand "North/South", not "187° vs 7°". (A degree value also lives more naturally in GTFS-Realtime, which already has `bearing`.)
- **4 points (N/E/S/W):** rejected for robustness — a road running NE–SW puts both directions near a bin boundary (~45° / ~225°), so different producers/algorithms classify the same stop into different bins. **8 points** place opposite stops a stable 4 bins apart and remain fully human-readable ("Northeast").
### Example — independent stops (typical bus case, no `parent_station`)
```
stop_id,stop_name,stop_lat,stop_lon,location_type,parent_station,stop_direction
S1,Via Roma,41.9010,12.4910,0,,3
S2,Via Roma,41.9011,12.4912,0,,7
```
### Example — with station hierarchy (`parent_station` present)
```
stop_id,stop_name,stop_lat,stop_lon,location_type,parent_station,stop_direction
ST,Via Roma,41.9010,12.4911,1,,
S1,Via Roma,41.9010,12.4910,0,ST,3
S2,Via Roma,41.9011,12.4912,0,ST,7
```
## Relationship to existing fields (no conflicts)
`stop_direction` is **orthogonal** to every existing field; the apparent overlaps answer different questions and **compose** with the new field.
- **`parent_station` / `location_type`** — These express *physical hierarchy* ("these poles belong to one station"), **not** direction. They do not solve the problem: the common bus case models the two opposite poles as **two independent `location_type=0` stops with no `parent_station` at all**, so nothing today indicates which serves which way. `stop_direction` is therefore designed to **work standalone** (no `parent_station` required). When a hierarchy *is* present, the two children naturally carry distinct `stop_direction` values — `parent_station` groups, `stop_direction` orients. The new field is **forbidden** on `location_type` 1/2/3, which have no travel direction.
- **`platform_code`** — A free-text, opaque *identifier* of a platform ("G", "3"). Some agencies overload it with "EB"/"WB", but it is not machine-interpretable or geographic. `stop_direction` **identifies the direction**, `platform_code` **identifies the platform**; they coexist on the same record.
- **`trips.direction_id`** — Binary (`0`/`1`), **per-trip**, agency-defined, explicitly *"should not be used in routing"* and carrying **no geographic meaning**. `stop_direction` is geographic, per-stop, 8-valued. Complementary, different level.
- **`stop_headsign` / `trip_headsign`** — The rider-facing **destination** ("to Termini"), i.e. *where the trip is going*, not *which way it points here*. The two pair well in a UI ("to Termini · heading North").
- **`stop_name` / `tts_stop_name`** — Lets agencies **stop** encoding direction in the name as free text, or keep both consistently.
- **`shapes.txt`** (relationship, not conflict) — The derivation source. `stop_direction` is the static, quantized, **authoritative** counterpart (the agency knows ground truth; many feeds have no shapes). A validator *may* warn if `stop_direction` disagrees with shape geometry where shapes exist.
- **GTFS-Realtime `VehiclePosition.position.bearing`** (relationship, not conflict) — The **same physical quantity**, realtime and continuous. Aligning the static field's semantics to it is intentional and is what makes use case #2 possible.
- **No interaction at all** with `zone_id`, `level_id`, `pathways.txt`, `levels.txt`, `transfers.txt`, Fares v2 areas.
## Edge cases (handled by value `0`)
- **Terminals, loops, turnarounds** — vehicles depart in differing directions from one pole → `0`.
- **A single stop served in both directions** (rare) → `0`.
- **Stop not split into a pair** — feeds that don't model double stops simply leave it `0`/empty; nothing breaks.
## Backwards compatibility
Fully backwards-compatible: a new **optional** column. Existing feeds remain valid; existing parsers ignore the column. Empty/absent ⇒ `0` (not specified).
## Alternatives considered
1. **Continuous bearing (0–359°) in stops.txt** — better for machine matching but poor for rider display and partly redundant with GTFS-Realtime `bearing`. Rejected in favor of a human-meaningful enum; the 8-point bins are defined in degree ranges so the machine-matching use case is still fully served.
2. **Overload `platform_code` / `stop_name`** — free text, inconsistent, not localizable, not machine-readable. Rejected (same reasoning the spec already gives for `platform_code` i18n).
3. **4-point enum** — unstable on diagonal roads (bin-boundary ambiguity). Rejected in favor of 8 points.
4. **New file** — over-engineering; direction is an intrinsic 1:1 attribute of the stop, so it belongs in stops.txt.
## Additional information
- **Prototype / testing plan:** the field can be prototyped immediately as an extra column (ignored by official parsers, per the Guiding Principles). It is being prototyped on a realtime backend acting as a **consumer** (for stop disambiguation and realtime-to-stop matching); a **producer** willing to populate `stop_direction` on a real feed would be needed to complete the testing phase.
- **Prior art:** issue #228 ("Direction names") explored direction at the *trip/destination* (semantic) level and stalled on *where* the data belongs. This proposal is deliberately narrower and at a **different level** — a geometric, per-stop attribute — so it sidesteps that ambiguity.
- **Proposed discussion period:** at least one month.
Contributor guide
Research direction
Start by reading the existing stops.txt specification and the GTFS-Realtime VehiclePosition bearing semantics referenced in the proposal. Check how the proposed optional field interacts with location_type, platform_code, and parent_station. Done means the proposal has a resolved design decision and, if accepted, the stops.txt definition and constraints are updated consistently.
Written by the indexing model from the issue text.
Assessment
- Domain
- backend-api-design, data
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100