Modeling Station Pathways and Levels

Read pathways.txt, group the edges by their stops’ parent_station, and build one directed graph per station in which is_bidirectional = 0 means exactly what it says. Then check that every platform in each station is reachable from every entrance, and compute interchange times as shortest paths rather than as a fixed per-station constant. The two defects worth catching before anything else are a pathway that terminates on the station node itself and a station whose graph is disconnected — both produce interchange times that look reasonable and are wrong. The wider fare-and-pathways model is covered in fare rules and pathways modeling.

One interchange, measured rather than assumed A path from an arrival platform to a departure platform through a concourse and a lift, with the traversal time each edge contributes. platform 2 0 s concourse 70 s lift 190 s platform 7 352 s the lift is why the step-free route takes 138 s longer than a flat five-minute interchange constant gets one of these two riders wrong

Root Cause Analysis

Before pathways existed, a station was a coordinate and an interchange was a guess — a flat five minutes, or whatever the journey planner’s configuration file said. pathways.txt replaced the guess with a graph, and in doing so moved the difficulty rather than removing it. The graph is only as good as the hierarchy underneath it, and that hierarchy is the part feeds most often get wrong.

Three structural mistakes account for nearly all unusable pathway data.

Pathways attached to the station node. A location_type = 1 stop is a container: it has coordinates, but no rider ever stands at it. When a feed connects a pathway from an entrance to the station rather than to a platform, every platform inside becomes implicitly reachable in one hop, and every interchange time collapses to the traversal time of that single edge. The graph is still connected, still valid against the schema, and completely meaningless.

Missing platforms. A station with eight platforms and pathways covering four of them produces a graph with two components. Routing between them fails, and a planner that falls back to a default on failure will quietly report a five-minute interchange for a connection that requires leaving the station and walking round the block.

Undirected modelling. Treating every pathway as traversable in both directions is the convenient assumption, and it routes riders backwards through exit-only gates. is_bidirectional exists precisely because those constraints are real.

Underneath all three is the fact that pathways are the only part of GTFS where the absence of an edge is a meaningful statement. In the schedule, a missing row is missing data. In a station graph, a missing edge asserts that you cannot get there, which is why validating connectivity matters more here than anywhere else in the feed.

Production-Ready Python Implementation

python
"""Build and validate per-station pathway graphs from a GTFS feed."""
from __future__ import annotations

import logging
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from zipfile import ZipFile

import networkx as nx
import pandas as pd

log = logging.getLogger("gtfs.pathways")

PATHWAY_DTYPES = {
    "pathway_id": "string", "from_stop_id": "string", "to_stop_id": "string",
    "pathway_mode": "int8", "is_bidirectional": "int8",
    "length": "float64", "traversal_time": "float64",
    "stair_count": "float64", "max_slope": "float64", "min_width": "float64",
}
STOP_DTYPES = {
    "stop_id": "string", "stop_name": "string", "location_type": "string",
    "parent_station": "string", "level_id": "string",
}

MODE_NAMES = {1: "walkway", 2: "stairs", 3: "moving sidewalk", 4: "escalator",
              5: "elevator", 6: "fare gate", 7: "exit gate"}
# Conservative defaults when traversal_time is absent, in seconds per mode.
DEFAULT_SECONDS = {1: 60.0, 2: 90.0, 3: 45.0, 4: 60.0, 5: 120.0, 6: 30.0, 7: 30.0}
STEP_MODES = {2, 4}          # stairs and escalators are not step-free


@dataclass
class StationGraph:
    station_id: str
    station_name: str
    graph: nx.DiGraph
    platforms: set[str]
    entrances: set[str]
    estimated_edges: int      # edges whose traversal time we had to invent


def read_tables(feed_path: Path) -> tuple[pd.DataFrame, pd.DataFrame]:
    with ZipFile(feed_path) as archive:
        with archive.open("stops.txt") as fh:
            stops = pd.read_csv(fh, dtype=STOP_DTYPES, keep_default_na=False,
                                na_values=[""])
        if "pathways.txt" not in archive.namelist():
            log.info("%s carries no pathways.txt", feed_path.name)
            empty = pd.DataFrame({c: pd.Series(dtype=d)
                                  for c, d in PATHWAY_DTYPES.items()})
            return stops, empty
        with archive.open("pathways.txt") as fh:
            pathways = pd.read_csv(fh, dtype=PATHWAY_DTYPES, keep_default_na=False,
                                   na_values=[""])
    return stops, pathways


def build_station_graphs(stops: pd.DataFrame,
                         pathways: pd.DataFrame) -> dict[str, StationGraph]:
    location_type = stops["location_type"].fillna("0")
    parent = dict(zip(stops["stop_id"], stops["parent_station"]))
    name = dict(zip(stops["stop_id"], stops["stop_name"]))
    stations = set(stops.loc[location_type == "1", "stop_id"])

    def station_of(stop_id: str) -> str | None:
        """The containing station, following one level of nesting (boarding areas)."""
        seen = 0
        current = stop_id
        while current is not None and current not in stations and seen < 3:
            current = parent.get(current)
            seen += 1
        return current if current in stations else None

    grouped: dict[str, list] = defaultdict(list)
    orphan_edges = 0
    for row in pathways.itertuples(index=False):
        home = station_of(row.from_stop_id) or station_of(row.to_stop_id)
        if home is None:
            orphan_edges += 1
            continue
        grouped[home].append(row)
    if orphan_edges:
        log.warning("%d pathway(s) belong to no station and are unusable", orphan_edges)

    graphs: dict[str, StationGraph] = {}
    for station_id, rows in grouped.items():
        graph = nx.DiGraph()
        estimated = 0
        for row in rows:
            mode = int(row.pathway_mode)
            seconds = row.traversal_time
            if pd.isna(seconds):
                seconds = DEFAULT_SECONDS.get(mode, 60.0)
                estimated += 1
            attrs = {"seconds": float(seconds), "mode": mode,
                     "mode_name": MODE_NAMES.get(mode, "unknown"),
                     "step_free": mode not in STEP_MODES,
                     "pathway_id": row.pathway_id}
            graph.add_edge(row.from_stop_id, row.to_stop_id, **attrs)
            if int(row.is_bidirectional or 0) == 1:
                graph.add_edge(row.to_stop_id, row.from_stop_id, **attrs)

        members = {s for s in graph.nodes}
        graphs[station_id] = StationGraph(
            station_id=station_id,
            station_name=str(name.get(station_id, station_id)),
            graph=graph,
            platforms={s for s in members if str(location_type[stops["stop_id"] == s]
                                                 .iloc[0]) in ("0", "4")},
            entrances={s for s in members if str(location_type[stops["stop_id"] == s]
                                                 .iloc[0]) == "2"},
            estimated_edges=estimated,
        )
    return graphs


def interchange_seconds(station: StationGraph, origin: str, destination: str,
                        step_free: bool = False) -> float | None:
    graph = station.graph
    if step_free:
        graph = nx.DiGraph(((u, v, d) for u, v, d in graph.edges(data=True)
                            if d["step_free"]))
    if origin not in graph or destination not in graph:
        return None
    try:
        return nx.shortest_path_length(graph, origin, destination, weight="seconds")
    except nx.NetworkXNoPath:
        return None
Pathway modes and what each implies A grid over the seven pathway_mode values, whether each is step-free and whether it is usually traversable in both directions. Step-free Bidirectional 1 walkway yes usually 2 stairs no usually 4 escalator no never 5 elevator yes usually 6 fare gate yes sometimes 7 exit gate yes never

Step-by-Step Walkthrough

station_of walks up the hierarchy rather than reading one field. A boarding area (location_type = 4) has a platform as its parent and the station as its grandparent, so a single parent_station lookup does not reach the station. The loop climbs at most three levels, which covers every nesting the specification allows and terminates on a feed with a cycle.

Edges are grouped by station before any graph is built. Building one graph per station rather than one graph for the feed keeps each traversal small, makes the connectivity check meaningful, and means a defect in one station cannot make another unroutable.

is_bidirectional adds a second edge; it does not make the graph undirected. The two edges share the same attribute dictionary, so a bidirectional lift reports the same mode and duration both ways, but a one-way exit gate contributes exactly one edge and cannot be traversed backwards.

Missing traversal times are defaulted per mode and counted. DEFAULT_SECONDS is deliberately pessimistic — a lift at two minutes, stairs at ninety seconds — because the cost of under-estimating is a missed connection and the cost of over-estimating is a slightly conservative itinerary. estimated_edges records how much of the station’s timing is invented, which is the number to look at before trusting an interchange figure.

step_free is precomputed per edge, not derived at query time. Stairs and escalators are the two modes that are not step-free; an escalator counts as a step because it can be out of service and because it cannot carry a wheelchair. Filtering the graph on the flag gives the accessible subgraph in one pass.

interchange_seconds returns None rather than a default. A missing platform, or a genuinely disconnected pair, is information. Substituting a fallback duration here is precisely how a broken station graph becomes an invisible source of wrong itineraries.

Verification and Output

python
def verify(graphs: dict[str, StationGraph], stops: pd.DataFrame) -> list[str]:
    problems: list[str] = []
    location_type = dict(zip(stops["stop_id"], stops["location_type"].fillna("0")))

    for station in graphs.values():
        on_station = {n for n in station.graph.nodes if location_type.get(n) == "1"}
        if on_station:
            problems.append(
                f"{station.station_name}: pathway terminates on the station node "
                f"{sorted(on_station)} — connect its children instead")

        undirected = station.graph.to_undirected()
        components = list(nx.connected_components(undirected))
        if len(components) > 1:
            sizes = sorted((len(c) for c in components), reverse=True)
            problems.append(
                f"{station.station_name}: pathway graph has {len(components)} "
                f"disconnected components (sizes {sizes}) — some platforms are unreachable")

        for entrance in station.entrances:
            unreachable = {p for p in station.platforms
                           if interchange_seconds(station, entrance, p) is None}
            if unreachable:
                problems.append(
                    f"{station.station_name}: {len(unreachable)} platform(s) cannot be "
                    f"reached from entrance {entrance}")

        if station.estimated_edges:
            log.info("%s: %d of %d edge(s) have an estimated traversal time",
                     station.station_name, station.estimated_edges,
                     station.graph.number_of_edges())
    return problems

A healthy interchange:

text
INFO gtfs.pathways: Union Station: 4 of 46 edge(s) have an estimated traversal time
>>> interchange_seconds(graphs["place_union"], "platform_2", "platform_7")
214.0
>>> interchange_seconds(graphs["place_union"], "platform_2", "platform_7", step_free=True)
352.0

The gap between the two numbers is the entire value of the model. A rider who can take the stairs changes in three and a half minutes; a rider who needs the lift takes nearly six. A planner using one flat figure gets one of those two riders wrong.

Three ways a station graph is unusable Each defect leaves a graph that still routes and still produces interchange times that are wrong. The interchange time looks wrong — which defect is it? every pair is one hop Pathway on the station connect the children instead some platforms unreachable Disconnected graph the feed covers part of the station wrong-way routes appear Modelled undirected honour is_bidirectional

Gotchas and Edge Cases

  • level_id present, levels.txt absent. The stops claim a level that has no definition. Harmless for routing, but it means any vertical reasoning — “this change involves two floors” — has nothing to work from.
  • Escalators modelled as bidirectional. Almost always wrong. A feed that marks every escalator bidirectional is asserting riders can walk up the down escalator, and it will produce step-free routes that do not exist.
  • Fare gates inside the paid area. Some feeds place pathway_mode = 6 edges between platforms rather than at the boundary. It does not break routing but it does break any attempt to reason about where the fare boundary is, which matters for fare rules.
  • Stations with a single platform and no pathways. Not a defect. Skip them rather than reporting an empty graph, or the report drowns in stations that need no interior model at all.
  • min_width and max_slope present but unused. Both matter for accessibility, and a step-free route that involves a 1-in-12 ramp is not equivalent to one that does not. Carrying them on the edge costs nothing and lets a stricter accessibility filter be added later without rebuilding the graph.

Frequently Asked Questions

Can a pathway connect directly to a station?

No. A pathway must connect the station’s children — platforms, entrances, generic nodes and boarding areas. Terminating a pathway on a location_type 1 station makes every platform inside it one hop from every other, which turns every interchange time into nonsense.

What should I assume when traversal_time is missing?

A conservative default, chosen per pathway mode, and recorded as an estimate. Under-estimating an interchange makes a journey planner promise connections riders cannot make. Sixty seconds for a walkway and ninety for stairs or a lift are defensible starting points.

Is a bidirectional pathway the same as two edges?

Yes, and modelling it as one undirected edge is the mistake. Exit gates, one-way escalators and emergency exits are genuinely directional, so the graph must be directed and is_bidirectional must be honoured rather than assumed.

How do I answer step-free routing questions?

Build a second graph with the stairs and escalator edges removed, then route on that. If a platform pair is connected in the full graph and disconnected in the step-free one, the station has no accessible route between them — which is exactly the fact a rider needs.