Modeling Parent Stations and Station Hierarchy

Read stops.txt with location_type and parent_station as text, then check three things: that every parent reference resolves, that each child’s parent is the legal kind of parent for its own location_type, and that following parents upward always terminates. A boarding area’s parent must be a platform; an entrance’s or a node’s parent must be a station; a station may have no parent at all. Getting this wrong does not usually raise an error — it quietly removes the grouping that interchange, accessibility and place-based queries all depend on. The stop and stop-time model this sits inside is covered in mastering stops.txt and stop_times.txt relationships.

Which parent each kind of stop may have A grid over the five location_type values, the parent each requires, and what breaks when the rule is not followed. Legal parent If wrong 0 platform a station, or none no station grouping 1 station none at all an impossible tree 2 entrance a station, required unreachable from outside 3 generic node a station, required orphaned in the graph 4 boarding area a platform, required wrong level in the tree

Root Cause Analysis

stops.txt carries five different kinds of thing in one table, distinguished only by location_type, and relates them through a single self-referencing parent_station column. That is a compact design and an easy one to populate incorrectly, because nothing in the CSV structure enforces which kind of parent each kind of child may have.

location_type What it is Legal parent
0 or empty Platform or stop — where vehicles call a station, or nothing
1 Station — a container for platforms nothing
2 Entrance or exit a station (required)
3 Generic node, e.g. a concourse junction a station (required)
4 Boarding area within a platform a platform (required)

Four failures follow from getting this wrong, and none of them raises.

Ungrouped platforms. The most common by far. Every platform is a standalone location_type = 0 stop with no parent, so nothing in the feed says they belong to the same station. Interchange must then be derived from proximity, which is a guess, and pathways cannot be modelled at all.

Stations referenced by stop_times.txt. A vehicle cannot call at a container. When it happens, every prediction and every fare calculation for that call attaches to a place with no platform, and any code that resolves a call to a physical location gets a station’s nominal centroid instead.

Wrong-level parents. A boarding area whose parent is a station rather than a platform, or a platform whose parent is another platform. The reference resolves, so a naive check passes, and the resulting tree has a shape no consumer expects.

Cycles. Two stops each naming the other as parent. Rare, and it turns any upward walk into an infinite loop — which is why every resolver needs a depth bound rather than a while on truthiness.

Production-Ready Python Implementation

python
"""Resolve and validate the GTFS station hierarchy."""
from __future__ import annotations

import logging
from collections import defaultdict
from dataclasses import dataclass, field

import pandas as pd

log = logging.getLogger("gtfs.stops.hierarchy")

PLATFORM, STATION, ENTRANCE, NODE, BOARDING_AREA = "0", "1", "2", "3", "4"
KIND_NAMES = {PLATFORM: "platform", STATION: "station", ENTRANCE: "entrance",
              NODE: "generic node", BOARDING_AREA: "boarding area"}

# Which location_type a given child's parent is REQUIRED to be, and whether a
# parent is mandatory at all.
PARENT_RULES = {
    PLATFORM: (STATION, False),
    STATION: (None, False),
    ENTRANCE: (STATION, True),
    NODE: (STATION, True),
    BOARDING_AREA: (PLATFORM, True),
}
MAX_DEPTH = 4          # boarding area -> platform -> station is the deepest legal chain


@dataclass
class Hierarchy:
    kind: dict[str, str]                      # stop_id -> location_type
    parent: dict[str, str | None]             # stop_id -> parent_station
    station_of: dict[str, str | None]         # stop_id -> containing station
    children: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))

    def platforms_of(self, station_id: str) -> list[str]:
        return [s for s in self.children.get(station_id, ())
                if self.kind.get(s) in (PLATFORM, "")]

    def is_callable(self, stop_id: str) -> bool:
        """Whether stop_times.txt may legally reference this stop."""
        return self.kind.get(stop_id, PLATFORM) in (PLATFORM, "")


def build_hierarchy(stops: pd.DataFrame) -> Hierarchy:
    kind = {str(r.stop_id): (str(r.location_type) if pd.notna(r.location_type)
                             and str(r.location_type) != "" else PLATFORM)
            for r in stops.itertuples(index=False)}
    parent = {str(r.stop_id): (str(r.parent_station)
                               if pd.notna(r.parent_station)
                               and str(r.parent_station) != "" else None)
              for r in stops.itertuples(index=False)}

    children: dict[str, list[str]] = defaultdict(list)
    for stop_id, parent_id in parent.items():
        if parent_id:
            children[parent_id].append(stop_id)

    station_of: dict[str, str | None] = {}
    for stop_id in kind:
        current, depth = stop_id, 0
        while current is not None and kind.get(current) != STATION and depth < MAX_DEPTH:
            current = parent.get(current)
            depth += 1
        station_of[stop_id] = current if kind.get(current) == STATION else None

    hierarchy = Hierarchy(kind=kind, parent=parent, station_of=station_of,
                          children=children)
    grouped = sum(1 for v in station_of.values() if v)
    log.info("%d stop(s): %d grouped under %d station(s)",
             len(kind), grouped, sum(1 for k in kind.values() if k == STATION))
    return hierarchy


def validate(hierarchy: Hierarchy, stop_times_stop_ids: set[str]) -> list[str]:
    problems: list[str] = []

    for stop_id, parent_id in hierarchy.parent.items():
        own_kind = hierarchy.kind[stop_id]
        required_kind, parent_required = PARENT_RULES.get(own_kind, (None, False))

        if parent_id is None:
            if parent_required:
                problems.append(
                    f"{KIND_NAMES.get(own_kind, own_kind)} {stop_id} has no "
                    "parent_station, which its location_type requires")
            continue

        if parent_id not in hierarchy.kind:
            problems.append(f"{stop_id} names a parent {parent_id} that does not exist")
            continue

        actual_kind = hierarchy.kind[parent_id]
        if required_kind is None:
            problems.append(
                f"station {stop_id} has a parent_station, which a station may not have")
        elif actual_kind != required_kind:
            problems.append(
                f"{KIND_NAMES.get(own_kind, own_kind)} {stop_id} has a "
                f"{KIND_NAMES.get(actual_kind, actual_kind)} as its parent; it must "
                f"be a {KIND_NAMES[required_kind]}")

    # Cycles: any stop whose upward walk never reaches a root within MAX_DEPTH.
    for stop_id in hierarchy.kind:
        current, seen, depth = stop_id, set(), 0
        while current is not None and depth <= MAX_DEPTH:
            if current in seen:
                problems.append(f"parent_station cycle involving {stop_id}")
                break
            seen.add(current)
            current = hierarchy.parent.get(current)
            depth += 1
        else:
            if current is not None:
                problems.append(
                    f"{stop_id} sits more than {MAX_DEPTH} levels deep — the "
                    "hierarchy allows at most boarding area, platform, station")

    # Calls at something that is not a platform.
    for stop_id in stop_times_stop_ids:
        if stop_id in hierarchy.kind and not hierarchy.is_callable(stop_id):
            problems.append(
                f"stop_times.txt calls at {stop_id}, which is a "
                f"{KIND_NAMES.get(hierarchy.kind[stop_id], '?')} — vehicles call at "
                "platforms, not containers")
    return problems
Resolving any stop upward to its station The upward walk crosses at most two levels, and terminates on a depth bound so a cyclic feed cannot loop forever. Boarding area location_type 4 — parent is a platform Platform location_type 0 — parent is a station Station location_type 1 — the top; the walk stops here Depth bound four levels, so a cycle terminates rather than hangs

Step-by-Step Walkthrough

location_type is normalised to "0" when empty. The specification treats an empty value as a platform, and leaving it empty means every later comparison has to remember that. Normalising once at build time removes a whole class of or "0" scattered through the codebase.

PARENT_RULES states both the required parent kind and whether a parent is mandatory. Those are two separate facts — a platform may have a station parent, an entrance must — and encoding them as one table keeps the validation loop free of special cases.

station_of walks upward with a depth bound, not a while parent. The bound is what makes a cyclic feed terminate. It is set to 4 rather than 3 so that an over-deep hierarchy fails the explicit depth check rather than being silently truncated by the resolver.

The walk stops at the first STATION, and returns None if it never finds one. A platform with no parent resolves to None, which is the honest answer: the feed does not say which station it belongs to. Returning the stop itself would make an ungrouped platform indistinguishable from a station, which is precisely the confusion the hierarchy exists to remove.

Cycle detection uses for/else. The loop breaks on a repeat; the else runs only when it completed without breaking, which is where an over-deep chain is caught. The two failures are genuinely different — one is circular, the other merely too deep — and reporting them identically would send someone looking for a cycle that is not there.

The stop_times check takes a set of identifiers rather than the frame. Callers usually have that set already from another validation pass, and passing 1.8 million rows in to test membership against a few thousand stops would be the most expensive part of the function.

Verification and Output

python
def verify(hierarchy: Hierarchy) -> None:
    for stop_id, station_id in hierarchy.station_of.items():
        if station_id is not None:
            assert hierarchy.kind[station_id] == STATION, (
                f"{stop_id} resolved to {station_id}, which is not a station")

    for parent_id, kids in hierarchy.children.items():
        for child in kids:
            assert hierarchy.parent[child] == parent_id, "children map disagrees"

    for station_id, kind in hierarchy.kind.items():
        if kind == STATION:
            assert hierarchy.station_of[station_id] == station_id, (
                "a station must resolve to itself")

A well-modelled rail feed:

text
INFO gtfs.stops.hierarchy: 3218 stop(s): 2904 grouped under 412 station(s)

A bus feed with no hierarchy at all — valid, and much less useful:

text
INFO gtfs.stops.hierarchy: 8904 stop(s): 0 grouped under 0 station(s)

And a feed with real defects:

text
boarding area plat_2a_north has a station as its parent; it must be a platform
entrance ent_west has no parent_station, which its location_type requires
stop_times.txt calls at place_union, which is a station — vehicles call at platforms, not containers
parent_station cycle involving plat_7
How much of each feed is grouped under a station Share of stops that resolve to a containing station, by kind of network — the number that decides whether interchange is known or guessed. Metro feed 96 % platforms, entrances and nodes all parented Regional rail feed 90 % a few rural halts left standalone Mixed multimodal feed 34 % rail grouped, bus not Urban bus feed 0 % every stop is a pole on a street zero is valid; it means every interchange has to be inferred

Gotchas and Edge Cases

  • A feed with zero stations. Entirely valid, and normal for bus networks where every stop is a pole on a street. Report it as information, not as a defect — but know that transfers and interchange for that feed will be proximity guesses.
  • Stations with coordinates far from their platforms. Legal, and it usually means the station coordinate is a postal address rather than the physical site. It matters because any distance measured from the station rather than the platform will be wrong; measure from the platform.
  • parent_station populated on a station. Some feeds use it to model a station complex — several stations under one interchange. The specification does not allow it, and the code above reports it, but the intent behind it is real, and the right response is usually to talk to the publisher rather than to strip the value.
  • Merged multi-agency feeds. parent_station references must be namespaced along with stop_id during the merge, or two agencies’ platforms end up sharing a station that exists in only one of them.
  • Boarding areas in bus feeds. Rare, and worth checking when they appear: a location_type = 4 under a stop rather than under a platform is the most common wrong-level parent in practice, because the distinction between a stop and a platform is not obvious at a bus stop.

Frequently Asked Questions

Which location_type values may have a parent?

Platforms, entrances, generic nodes and boarding areas may. A station may not — it is the top of the hierarchy. A boarding area’s parent must be a platform, and an entrance’s or node’s parent must be a station, so the legal parent kind depends on the child’s own type.

Do stop_times reference stations or platforms?

Platforms, always. A station is a container and no vehicle calls at it. A stop_times row referencing a location_type 1 station is invalid, and it is a defect that a schema-only validator often lets through.

What breaks when platforms are not grouped under a station?

Interchange modelling, accessibility routing and any grouping by place. Without a parent, two platforms of the same station are unrelated stops that merely happen to be near each other, so transfers have to be guessed from proximity instead of being known.

Can the hierarchy be more than two levels deep?

Yes, but only in one specific way: a boarding area sits under a platform, which sits under a station. That is three levels, and it is the deepest the specification allows. Anything deeper is a defect.