Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Working with GTFS

Authors
Affiliations
TU Wien
TU Wien
TU Wien
Binder

In the previous notebook we downloaded street data from OpenStreetMap for a Vienna bounding box. Open geospatial workflows often combine several sources for the same area. One of the most common companions to OSM is GTFS: schedule and stop data for public transport.

Unlike OSM (fetched live via Overpass), here we download a prepared Vienna GTFS sample from the cookbook data project on GitLab LFS (same access pattern as intersections.csv). The goal is to understand the feed structure, load it in Python, and map the stops. Then we continue the main pipeline with feature engineering.

Data attribution: Derived from the open Wiener Linien GTFS feed (data.gv.at / wienerlinien.at).

What is GTFS?

GTFS (General Transit Feed Specification) is an open standard for publishing transit schedules. A static feed is a set of CSV-like .txt tables (often zipped together). The tables you will use most are:

  • stops.txt: stop locations (stop_lat, stop_lon)

  • routes.txt: lines and their mode (route_type: e.g. tram 0, subway 1, bus 3)

  • trips.txt: individual runs of a route

  • stop_times.txt: which stops each trip visits, in order

They link as route -> trips -> stop_times -> stops. Files such as shapes.txt can draw route geometries. Spec: https://gtfs.org/

Imports and Configuration

We reuse the same Vienna bounding box and Folium helper as in the OSM notebooks.

import sys
import zipfile
from pathlib import Path

import folium
import geopandas as gpd
import pandas as pd
import requests

# ruff: noqa: T201

sys.path.append(str(Path("..").resolve()))
from src.geoai.cookbook_functions import get_base_map

CITY_NAME = "Vienna"
BBOX = "16.335005,48.187854,16.400923,48.209995"
CACHE_DIR = Path("./gtfs_data")
OUTPUT_DIR = "./output"

# GitLab data project (cookbooks/data/geospatial-data), same LFS pattern as intersections.csv
GTFS_ZIP_URL = (
    "https://gitlab.tuwien.ac.at/api/v4/projects/15378/repository/files/"
    "vienna_gtfs.zip/raw?ref=main&lfs=true"
)

ROUTE_TYPE_LABELS = {
    0: "Tram",
    1: "Subway",
    2: "Rail",
    3: "Bus",
}

min_lon, min_lat, max_lon, max_lat = map(float, BBOX.split(","))
bounds = [[min_lat, min_lon], [max_lat, max_lon]]
center_lat = (min_lat + max_lat) / 2
center_lon = (min_lon + max_lon) / 2

Download the GTFS feed

We download vienna_gtfs.zip from the cookbook data project on GitLab LFS and extract it. If the zip is already cached locally, we skip the download (same idea as caching intersections.csv).

CACHE_DIR.mkdir(parents=True, exist_ok=True)
zip_path = CACHE_DIR / "vienna_gtfs.zip"
feed_dir = CACHE_DIR / CITY_NAME

if not zip_path.exists():
    print(f"Downloading {GTFS_ZIP_URL}")
    response = requests.get(GTFS_ZIP_URL, timeout=120)
    response.raise_for_status()
    zip_path.write_bytes(response.content)
    print(f"Saved {zip_path}")
else:
    print(f"Using cached {zip_path}")

if not (feed_dir / "stops.txt").exists():
    feed_dir.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(zip_path) as archive:
        archive.extractall(feed_dir)
    print(f"Extracted feed to {feed_dir}")

print("Files in the feed:")
for path in sorted(feed_dir.glob("*.txt")):
    print(f"  {path.name:20s}  {path.stat().st_size:8d} bytes")
Downloading https://gitlab.tuwien.ac.at/api/v4/projects/15378/repository/files/vienna_gtfs.zip/raw?ref=main&lfs=true
Saved gtfs_data/vienna_gtfs.zip
Extracted feed to gtfs_data/Vienna
Files in the feed:
  agency.txt                 254 bytes
  calendar.txt             15686 bytes
  calendar_dates.txt      145946 bytes
  routes.txt               22596 bytes
  shapes.txt             3825588 bytes
  stop_times.txt         3472621 bytes
  stops.txt                23817 bytes
  trips.txt               715985 bytes

Load the tables with pandas

Each .txt file is a CSV table. We read every table in the feed into a dictionary of DataFrames and print its size.

gtfs = {}
for path in sorted(feed_dir.glob("*.txt")):
    name = path.stem
    gtfs[name] = pd.read_csv(path, dtype=str)

for name, table in gtfs.items():
    print(f"{name:16s}  rows={len(table):7d}  columns={list(table.columns)}")
agency            rows=      2  columns=['agency_id', 'agency_name', 'agency_url', 'agency_timezone', 'agency_lang', 'agency_fare_url']
calendar          rows=    400  columns=['service_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_date', 'end_date']
calendar_dates    rows=   8124  columns=['service_id', 'date', 'exception_type']
routes            rows=    292  columns=['route_id', 'agency_id', 'route_short_name', 'route_long_name', 'route_type', 'route_color', 'route_text_color']
shapes            rows=  72121  columns=['shape_id', 'shape_pt_lat', 'shape_pt_lon', 'shape_pt_sequence', 'shape_dist_traveled']
stop_times        rows=  58301  columns=['trip_id', 'arrival_time', 'departure_time', 'stop_id', 'stop_sequence']
stops             rows=    395  columns=['stop_id', 'stop_name', 'stop_lat', 'stop_lon', 'zone_id']
trips             rows=   8548  columns=['route_id', 'service_id', 'trip_id', 'shape_id', 'trip_headsign', 'direction_id', 'block_id']

Inspect stops and routes

Glance at the first rows and how many routes of each mode appear in the sample. GTFS encodes mode as integer route_type; we map the common codes to short labels.

display(gtfs["stops"].head())

routes = gtfs["routes"].copy()
routes["mode"] = routes["route_type"].astype(int).map(ROUTE_TYPE_LABELS).fillna("Other")
display(routes.head())
print("Routes by mode:")
print(routes["mode"].value_counts())
Loading...
Loading...
Routes by mode:
mode
Tram      140
Bus       104
Subway     48
Name: count, dtype: int64

Stops as geospatial points

Convert stops.txt to a GeoDataFrame (EPSG:4326, same CRS as our OSM data) and plot them on the cookbook bounding box.

stops = gtfs["stops"].copy()
stops["stop_lat"] = stops["stop_lat"].astype(float)
stops["stop_lon"] = stops["stop_lon"].astype(float)
stops_gdf = gpd.GeoDataFrame(
    stops,
    geometry=gpd.points_from_xy(stops["stop_lon"], stops["stop_lat"]),
    crs="EPSG:4326",
)
print(f"Stops: {len(stops_gdf)} points, CRS={stops_gdf.crs}")

m_stops = get_base_map(center_lat, center_lon)
folium.Rectangle(
    bounds=bounds, color="#3388ff", fill=False, weight=2, popup="Cookbook bounding box"
).add_to(m_stops)

for _, row in stops_gdf.iterrows():
    folium.CircleMarker(
        location=[row.stop_lat, row.stop_lon],
        radius=3,
        color="#d62728",
        fill=True,
        fill_opacity=0.7,
        popup=folium.Popup(
            f"<b>{row.stop_name}</b><br>id={row.stop_id}", max_width=250
        ),
    ).add_to(m_stops)

m_stops
Stops: 395 points, CRS=EPSG:4326
/opt/conda/lib/python3.12/site-packages/folium/raster_layers.py:130: UserWarning: CartoDB tiles now require an API key. Please provide one to continue using the tiles. You can request the key at https://carto.com/basemaps/apikey/.
  tiles = tiles.build_url(fill_subdomain=False, scale_factor="{r}")  # type: ignore
Loading...

Join tables: routes serving each stop

Linking stop_times -> trips -> routes shows which lines serve each stop. That relational side of GTFS is what pure geometry does not give you.

st = gtfs["stop_times"][["trip_id", "stop_id"]].drop_duplicates()
trip_route = gtfs["trips"][["trip_id", "route_id"]].drop_duplicates()
linked = st.merge(trip_route, on="trip_id").merge(
    routes[["route_id", "route_short_name", "mode"]],
    on="route_id",
)

per_stop = (
    linked.groupby("stop_id")
    .agg(
        n_routes=("route_id", "nunique"),
        route_names=(
            "route_short_name",
            lambda s: ", ".join(sorted({str(x) for x in s if pd.notna(x)})),
        ),
    )
    .reset_index()
    .merge(gtfs["stops"][["stop_id", "stop_name"]], on="stop_id")
    .sort_values("n_routes", ascending=False)
)

per_stop.head(10)
Loading...

Route shapes on the map

When shapes.txt is present, we can draw a sample of route geometries next to the stops. That helps check that the feed covers the same area as our OSM extract.

if "shapes" in gtfs:
    shapes = gtfs["shapes"].copy()
    shapes["shape_pt_lat"] = shapes["shape_pt_lat"].astype(float)
    shapes["shape_pt_lon"] = shapes["shape_pt_lon"].astype(float)
    shapes["shape_pt_sequence"] = shapes["shape_pt_sequence"].astype(int)
    shapes = shapes.sort_values(["shape_id", "shape_pt_sequence"])

    from shapely.geometry import LineString

    lines = []
    for shape_id, group in shapes.groupby("shape_id"):
        coords = list(zip(group["shape_pt_lon"], group["shape_pt_lat"], strict=True))
        if len(coords) >= 2:
            lines.append({"shape_id": shape_id, "geometry": LineString(coords)})

    shapes_gdf = gpd.GeoDataFrame(lines, crs="EPSG:4326")
    trip_shape = (
        gtfs["trips"][["route_id", "shape_id"]]
        .dropna(subset=["shape_id"])
        .drop_duplicates(subset=["shape_id"])
    )
    shapes_gdf = shapes_gdf.merge(trip_shape, on="shape_id", how="left").merge(
        routes[["route_id", "route_short_name"]], on="route_id", how="left"
    )

    sample = shapes_gdf.head(12)
    m_shapes = get_base_map(center_lat, center_lon)
    folium.Rectangle(bounds=bounds, color="#3388ff", fill=False, weight=2).add_to(
        m_shapes
    )
    for _, row in sample.iterrows():
        folium.GeoJson(
            row.geometry.__geo_interface__,
            style_function=lambda _x: {"color": "#ff7f00", "weight": 3},
            tooltip=f"route {row.get('route_short_name', row.shape_id)}",
        ).add_to(m_shapes)
    print(f"Plotted {len(sample)} of {len(shapes_gdf)} shapes")
    display(m_shapes)
else:
    print("No shapes.txt in this feed sample.")
/opt/conda/lib/python3.12/site-packages/folium/raster_layers.py:130: UserWarning: CartoDB tiles now require an API key. Please provide one to continue using the tiles. You can request the key at https://carto.com/basemaps/apikey/.
  tiles = tiles.build_url(fill_subdomain=False, scale_factor="{r}")  # type: ignore
Plotted 12 of 292 shapes
Loading...

Summary

In this notebook you have learned:

  • What GTFS is and how its core tables relate (route -> trips -> stop_times -> stops)

  • How to download a GTFS zip from GitLab LFS (same pattern as other cookbook data files)

  • How to load the tables with pandas, map stops, join routes to stops, and plot route shapes

Next we return to feature engineering for this area, deriving OSM intersection features and GTFS stop features side by side.