Matching Vehicle Positions to GTFS Trips
When a GTFS-Realtime VehiclePosition arrives with trip.route_id set but trip.trip_id empty, resolve the trip by projecting the fix to a metric coordinate reference system, snapping it to the nearest shapes.txt line for that route, and then keeping only the trips whose service is active on the current date and whose scheduled span brackets the fix timestamp. The trip that minimises a combined distance-and-time score is your match. Never assign a trip_id on spatial proximity alone — every trip on a route shares one shape, so time is what disambiguates them.
Root Cause Analysis
A missing trip descriptor is not corruption; it is a legitimate state that several kinds of feed produce. Understanding which one you are dealing with tells you how much to trust the eventual match.
Frequency-based operation is the leading cause. On a route that runs “every 8 minutes” rather than on a published timetable, the scheduling system tracks the route and the vehicle but never binds a physical bus to a specific trip_id. The realtime feed faithfully reports route_id and leaves trip_id blank because no scheduled trip exists to name. These feeds pair naturally with the expansion logic in converting frequencies.txt to exact departure times, which turns a headway into the concrete departures you match against.
Lost block assignment happens when an AVL unit reboots mid-service or a driver signs on to the wrong run. The hardware knows its GPS position and the route it was dispatched to, but the link back to the day’s scheduled trip is gone until the next manual sign-on. The vehicle keeps broadcasting a route_id with no trip_id.
Deliberate omission appears in feeds that strip trip descriptors for privacy or bandwidth. The agency treats the positions layer as a live map only and expects consumers who need schedule context to reconstruct it — which is exactly this workflow.
In all three cases the recovery strategy is identical: narrow the universe of trips spatially using the route’s shape, then narrow it temporally using the active service window, and score what remains.
Production-Ready Python Implementation
The script resolves every trip-less fix in a positions DataFrame. It projects fixes and shapes to a metric CRS with geopandas, snaps each fix to its route’s shape geometry, filters candidate trips to the active service window derived from calendar.txt, and scores each candidate on a weighted blend of snap distance and schedule offset. It returns the original frame with trip_id and a match_confidence column filled in.
import logging
from datetime import datetime, date
from pathlib import Path
import numpy as np
import pandas as pd
import geopandas as gpd
from shapely.geometry import LineString, Point
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
# Weights for the combined score: metres of snap distance vs seconds of schedule offset.
DISTANCE_WEIGHT = 1.0 # per metre
TIME_WEIGHT = 0.05 # per second
MAX_SNAP_METRES = 120.0 # reject a fix further than this from any shape
def _utm_epsg(lon: float, lat: float) -> int:
"""EPSG code for the UTM zone containing a lon/lat centroid."""
zone = int((lon + 180) // 6) + 1
return (32600 if lat >= 0 else 32700) + zone
def _weekday_column(service_day: date) -> str:
return [
"monday", "tuesday", "wednesday", "thursday",
"friday", "saturday", "sunday",
][service_day.weekday()]
def _seconds_since_midnight(gtfs_time: str) -> int:
"""Parse a GTFS HH:MM:SS time, tolerating hours >= 24 for overnight trips."""
hours, minutes, seconds = (int(part) for part in gtfs_time.split(":"))
return hours * 3600 + minutes * 60 + seconds
def load_shapes(feed_dir: Path, crs_epsg: int) -> gpd.GeoDataFrame:
"""Build one projected LineString per shape_id from shapes.txt."""
shapes = pd.read_csv(
feed_dir / "shapes.txt",
dtype={
"shape_id": "string",
"shape_pt_lat": "float64",
"shape_pt_lon": "float64",
"shape_pt_sequence": "int64",
},
).sort_values(["shape_id", "shape_pt_sequence"])
lines = (
shapes.groupby("shape_id")
.apply(lambda g: LineString(zip(g["shape_pt_lon"], g["shape_pt_lat"])))
.rename("geometry")
)
shape_gdf = gpd.GeoDataFrame(lines.reset_index(), geometry="geometry", crs="EPSG:4326")
return shape_gdf.to_crs(epsg=crs_epsg)
def active_trips(feed_dir: Path, service_day: date) -> pd.DataFrame:
"""Trips whose service_id is active on service_day, with route_id, shape_id and span."""
trips = pd.read_csv(
feed_dir / "trips.txt",
dtype={"trip_id": "string", "route_id": "string",
"service_id": "string", "shape_id": "string"},
)
calendar = pd.read_csv(
feed_dir / "calendar.txt",
dtype={"service_id": "string", "start_date": "string", "end_date": "string"},
)
weekday = _weekday_column(service_day)
day_int = int(service_day.strftime("%Y%m%d"))
running = calendar[
(calendar[weekday] == 1)
& (calendar["start_date"].astype(int) <= day_int)
& (calendar["end_date"].astype(int) >= day_int)
]
active = trips[trips["service_id"].isin(running["service_id"])].copy()
# Derive each trip's scheduled span (first/last departure) from stop_times.txt
stop_times = pd.read_csv(
feed_dir / "stop_times.txt",
dtype={"trip_id": "string", "departure_time": "string", "stop_sequence": "Int64"},
usecols=["trip_id", "departure_time", "stop_sequence"],
)
stop_times["dep_s"] = stop_times["departure_time"].map(_seconds_since_midnight)
span = stop_times.groupby("trip_id")["dep_s"].agg(start_s="min", end_s="max")
active = active.merge(span, on="trip_id", how="inner")
logging.info("%d trips active on %s", len(active), service_day.isoformat())
return active
def match_positions_to_trips(
vehicle_positions: pd.DataFrame, feed_dir: str, service_day: date | None = None
) -> pd.DataFrame:
"""Fill trip_id for positions that carry a route_id but no trip_id."""
feed_path = Path(feed_dir)
service_day = service_day or date.today()
result = vehicle_positions.copy()
unresolved = result[result["trip_id"].isna() & result["route_id"].notna()]
if unresolved.empty:
logging.info("No trip-less positions to resolve")
return result
centroid_lon = float(unresolved["longitude"].mean())
centroid_lat = float(unresolved["latitude"].mean())
crs_epsg = _utm_epsg(centroid_lon, centroid_lat)
shapes = load_shapes(feed_path, crs_epsg)
trips = active_trips(feed_path, service_day)
trips = trips.merge(shapes, on="shape_id", how="inner") # attach projected geometry
fixes = gpd.GeoDataFrame(
unresolved,
geometry=gpd.points_from_xy(unresolved["longitude"], unresolved["latitude"]),
crs="EPSG:4326",
).to_crs(epsg=crs_epsg)
matches: dict[int, tuple[str, float]] = {}
for idx, fix in fixes.iterrows():
candidates = trips[trips["route_id"] == fix["route_id"]]
if candidates.empty:
continue
fix_s = (int(fix["timestamp"]) % 86400) if pd.notna(fix["timestamp"]) else None
best_trip, best_score = None, np.inf
for _, trip in candidates.iterrows():
snap_m = trip["geometry"].distance(fix.geometry)
if snap_m > MAX_SNAP_METRES:
continue
if fix_s is None:
time_offset = 0.0
elif trip["start_s"] <= fix_s <= trip["end_s"]:
time_offset = 0.0
else:
time_offset = min(abs(fix_s - trip["start_s"]), abs(fix_s - trip["end_s"]))
score = DISTANCE_WEIGHT * snap_m + TIME_WEIGHT * time_offset
if score < best_score:
best_trip, best_score = trip["trip_id"], score
if best_trip is not None:
matches[idx] = (best_trip, best_score)
for idx, (trip_id, score) in matches.items():
result.loc[idx, "trip_id"] = trip_id
result.loc[idx, "match_confidence"] = round(1.0 / (1.0 + score), 4)
logging.info(
"Resolved %d/%d trip-less positions", len(matches), len(unresolved)
)
return result
Step-by-Step Walkthrough
Filter to the recoverable rows. Only fixes with a route_id and no trip_id are candidates; a fix missing both carries no anchor to match against and is left untouched. This keeps the expensive spatial loop small.
Derive one metric CRS for the whole batch. The helper picks the UTM zone from the centroid of the unresolved fixes, mirroring the static spatial pipeline. Both the fixes and the shape lines are pushed into that CRS so shapely’s distance returns metres, not degrees.
Build one LineString per shape. shapes.txt stores each route geometry as ordered points; grouping by shape_id and sorting on shape_pt_sequence reconstructs the polyline. This is the same geometry construction covered in spatial analysis and route geometry; when you need attribute joins between fixes and stops rather than lines, the geopandas spatial joins for transit stops guide covers that pattern.
Restrict to the active service window. active_trips intersects calendar.txt on the correct weekday and date range, then attaches each trip’s scheduled span from the first and last departure_time in stop_times.txt. Times are parsed with a helper that tolerates hours >= 24, because overnight trips legitimately use 25:10:00.
Score distance and time together. For each candidate trip on the fix’s route, the snap distance in metres and the schedule offset in seconds are blended with tunable weights. A fix inside a trip’s scheduled span scores zero time-offset; one outside is penalised by how far it falls beyond either end. The lowest combined score wins, and 1/(1+score) gives a bounded confidence in (0, 1].
Verification and Output
Confirm the match set before trusting it downstream. Every resolved fix must point to a real, active trip on the same route it broadcast, and confidence should be sane.
from datetime import date
resolved = match_positions_to_trips(vehicle_positions, "/data/gtfs/agency", date(2026, 7, 13))
matched = resolved[resolved["trip_id"].notna() & resolved["match_confidence"].notna()]
# Every matched trip_id must exist in trips.txt and share the broadcast route_id
trips = pd.read_csv(
"/data/gtfs/agency/trips.txt",
dtype={"trip_id": "string", "route_id": "string"},
).set_index("trip_id")
for _, row in matched.iterrows():
assert row["trip_id"] in trips.index, f"Matched unknown trip_id {row['trip_id']}"
assert trips.loc[row["trip_id"], "route_id"] == row["route_id"], (
f"trip_id {row['trip_id']} does not belong to route {row['route_id']}"
)
assert matched["match_confidence"].between(0.0, 1.0).all(), "Confidence out of range"
print(
f"Matched {len(matched)} positions | "
f"median confidence {matched['match_confidence'].median():.3f}"
)
Sample output on a mid-size agency feed with frequency routes:
INFO: 1183 trips active on 2026-07-13
INFO: Resolved 214/238 trip-less positions
Matched 214 positions | median confidence 0.842
The 24 unresolved fixes are typically vehicles snapped further than MAX_SNAP_METRES from any shape — deadheading, on detour, or laying over off-route — and are correctly left with a null trip_id rather than force-matched.
Gotchas and Edge Cases
-
Loop and figure-eight shapes. A route whose shape crosses itself has two nearby line segments, and the nearest-point snap can land on the wrong pass. Where direction matters, add
direction_idfrom the realtimetrip(if present) to the candidate filter, or compare the fixbearingto the shape’s local tangent heading. -
Overlapping routes on shared corridors. Trunk segments where several routes run the same street produce fixes that snap well to multiple shapes. Because the candidate filter keys on the broadcast
route_id, this is only a problem when the feed’sroute_idis itself coarse (a “family” id). In that case widen the candidate set to the family’s member routes and lean harder on the time score. -
Midnight rollover in the service window. A trip departing
24:40:00belongs to the previous service day. Comparing a fix taken at00:40local against a same-day window will miss it. Evaluate each fix against both today’s and yesterday’s active trips when its local time is in the early-morning band. -
Empty or absent
shapes.txt. Some feeds ship no shapes. Fall back to snapping the fix to the sequence of stop coordinates for each candidate trip — build the LineString from the trip’sstop_times.txtjoined tostops.txtinstead of fromshapes.txt.
Frequently Asked Questions
Why do some VehiclePositions have no trip_id?
Agencies running frequency-based service often know the route a vehicle is on but not which scheduled trip it is operating, so they populate trip.route_id and leave trip.trip_id empty. AVL systems that lost their block assignment, and privacy-conscious feeds, also omit the trip descriptor.
Can I match on coordinates alone without the service window?
No. Two trips on the same route share the same shape, so shape proximity cannot distinguish the 08:14 departure from the 08:44 one. You must intersect the spatial candidates with the trips whose service is active on the current date and whose scheduled span covers the fix timestamp.
Why project to a metric CRS before measuring distance to a shape?
Distance in raw WGS84 degrees is meaningless because a degree of longitude shrinks toward the poles. Projecting both the fix and the shape to a metric CRS such as a UTM zone lets shapely return a true distance in metres, so the nearest-shape test is geometrically correct.
Related
- Vehicle Speed and Bearing from GTFS-RT — the sibling technique for the
speedandbearingfields the feed leaves blank - Geopandas Spatial Joins for Transit Stops — the projected-geometry join patterns this workflow relies on
- Up: Tracking Vehicle Positions in Realtime — the full positions pipeline this fills a gap in
- Section: GTFS-Realtime Integration — the realtime feed family · Home