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. tram0, subway1, bus3)trips.txt: individual runs of a routestop_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) / 2Download 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())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_stopsStops: 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
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)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
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.