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.

Matching a trip-less vehicle fix to a scheduled trip A vehicle fix with route_id but no trip_id feeds two parallel filters — spatial snap to the route shape and temporal filter by active service window — whose intersection yields a single matched trip_id. fix: route_id, no trip_id snap to nearest shapes.txt line (metres) active service window calendar + trip span matched trip_id min(distance + time)

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.

python
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.

python
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:

text
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_id from the realtime trip (if present) to the candidate filter, or compare the fix bearing to 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’s route_id is 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:00 belongs to the previous service day. Comparing a fix taken at 00:40 local 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’s stop_times.txt joined to stops.txt instead of from shapes.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.