Arrival Predictions from TripUpdates

A live arrival prediction is the scheduled time of a stop plus the current delay reported for that stop: predicted = scheduled + delay. When a stop has no delay of its own in the TripUpdates feed, carry the most recent downstream delay forward — the last StopTimeUpdate before that stop is the one that applies. The script below converts each scheduled stop_times.txt time to an absolute epoch (correctly handling GTFS times past 24:00:00), joins the realtime delays, forward-fills the last known delay per trip, and adds it to produce a predicted arrival timestamp for every remaining stop.

Root Cause Analysis

The reason arrival prediction is more than “add a number to a time” is that the two inputs live in different time representations and the delay input is deliberately incomplete.

Scheduled times in stop_times.txt are wall-clock strings measured from noon on the service date, and they routinely exceed 24:00:00 for trips that cross midnight — 25:14:00 means 1:14 AM on the following calendar day. A realtime delay, by contrast, is a plain count of seconds. You cannot add seconds to a HH:MM:SS string; both must be reduced to the same absolute epoch first, which means resolving the trip’s service date and time zone before any arithmetic. This is the same conversion covered in converting local transit times to UTC in Python, applied per trip rather than per feed.

The second issue is sparsity. A producer sends a delay for the next stop the vehicle will reach and usually nothing for the stops beyond it. If you predict only the announced stops, an arrival board shows one live time and a column of blanks. The specification’s rule is that a StopTimeUpdate applies forward until superseded, so the fix is a forward-fill of the delay along stop_sequence within each trip. Getting the join key right — the composite (trip_id, stop_sequence) — matters, because a stop_id-only join fans out on loop routes; the stops.txt and stop_times.txt relationship model explains why stop_sequence is the safe key.

Production-Ready Python Implementation

The single script below fetches a TripUpdates feed, builds a delay table, converts the scheduled stop_times.txt times to absolute epochs for the correct service date, forward-fills the last known delay per trip, and emits a predicted arrival epoch and ISO timestamp for every remaining stop.

python
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import pandas as pd
import requests
from google.transit import gtfs_realtime_pb2

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

TRIPUPDATES_URL = "https://api.example-agency.gov/gtfs-rt/trip-updates.pb"
AGENCY_TZ = ZoneInfo("America/New_York")
SR_SCHEDULED = gtfs_realtime_pb2.TripUpdate.StopTimeUpdate.SCHEDULED


def gtfs_time_to_seconds(value: str) -> int:
    """Convert a GTFS HH:MM:SS string (hours may exceed 23) to seconds since noon-12h."""
    hours, minutes, seconds = (int(part) for part in value.split(":"))
    return hours * 3600 + minutes * 60 + seconds


def service_date_epoch(service_date: str, offset_seconds: int, tz: ZoneInfo) -> int:
    """
    Absolute POSIX epoch for a GTFS time on a service date.
    GTFS times are measured from noon minus 12h (i.e. midnight of the service
    day in civil time), so offsets above 24h correctly roll into the next day.
    """
    year, month, day = int(service_date[:4]), int(service_date[4:6]), int(service_date[6:8])
    civil_midnight = datetime(year, month, day, 12, 0, 0, tzinfo=tz) - timedelta(hours=12)
    return int((civil_midnight + timedelta(seconds=offset_seconds)).timestamp())


def parse_trip_update_delays(url: str) -> pd.DataFrame:
    """Fetch TripUpdates and return one row per announced StopTimeUpdate."""
    response = requests.get(url, timeout=30)
    response.raise_for_status()
    feed = gtfs_realtime_pb2.FeedMessage()
    feed.ParseFromString(response.content)

    rows: list[dict] = []
    for entity in feed.entity:
        if not entity.HasField("trip_update"):
            continue
        trip_update = entity.trip_update
        trip = trip_update.trip
        # Service date defaults to today in the agency time zone when omitted.
        start_date = trip.start_date or datetime.now(AGENCY_TZ).strftime("%Y%m%d")
        trip_level_delay = trip_update.delay if trip_update.HasField("delay") else pd.NA
        for stu in trip_update.stop_time_update:
            if stu.schedule_relationship != SR_SCHEDULED:
                continue  # SKIPPED / NO_DATA handled separately; not a delay source
            if stu.arrival.HasField("delay"):
                arr_delay = stu.arrival.delay
            elif stu.departure.HasField("delay"):
                arr_delay = stu.departure.delay
            else:
                arr_delay = trip_level_delay
            rows.append(
                {
                    "trip_id": trip.trip_id,
                    "start_date": start_date,
                    "stop_sequence": stu.stop_sequence if stu.HasField("stop_sequence") else pd.NA,
                    "reported_delay": arr_delay,
                }
            )

    delays = pd.DataFrame(rows).astype(
        {
            "trip_id": "string",
            "start_date": "string",
            "stop_sequence": "Int64",
            "reported_delay": "Int64",
        }
    )
    logging.info("Parsed %d announced stop delays across %d trips",
                 len(delays), delays["trip_id"].nunique())
    return delays


def compute_arrival_predictions(delays: pd.DataFrame, stop_times_path: str) -> pd.DataFrame:
    """Apply delays to scheduled stop_times, propagating the last known delay forward."""
    stop_times = pd.read_csv(
        stop_times_path,
        dtype={
            "trip_id": "string",
            "stop_id": "string",
            "stop_sequence": "Int64",
            "arrival_time": "string",     # kept as string; may exceed 24:00:00
        },
        usecols=["trip_id", "stop_id", "stop_sequence", "arrival_time"],
    )

    # One service date per live trip (assumes a single active date per trip_id).
    trip_dates = delays.dropna(subset=["start_date"]).drop_duplicates("trip_id")[
        ["trip_id", "start_date"]
    ]
    live = stop_times.merge(trip_dates, on="trip_id", how="inner")
    live = live.sort_values(["trip_id", "stop_sequence"], ignore_index=True)

    # Absolute scheduled epoch for each stop.
    live["sched_epoch"] = [
        service_date_epoch(sd, gtfs_time_to_seconds(at), AGENCY_TZ)
        for sd, at in zip(live["start_date"], live["arrival_time"])
    ]

    # Join announced delays and forward-fill within each trip.
    live = live.merge(
        delays[["trip_id", "stop_sequence", "reported_delay"]],
        on=["trip_id", "stop_sequence"],
        how="left",
    )
    live["effective_delay"] = (
        live.groupby("trip_id", sort=False)["reported_delay"].ffill().fillna(0).astype("Int64")
    )

    # predicted = scheduled + delay
    live["predicted_epoch"] = live["sched_epoch"] + live["effective_delay"].astype("int64")
    live["predicted_arrival"] = pd.to_datetime(live["predicted_epoch"], unit="s", utc=True)
    logging.info("Computed predictions for %d stops", len(live))
    return live[
        ["trip_id", "stop_id", "stop_sequence", "arrival_time",
         "effective_delay", "predicted_epoch", "predicted_arrival"]
    ]


if __name__ == "__main__":
    delay_table = parse_trip_update_delays(TRIPUPDATES_URL)
    predictions = compute_arrival_predictions(delay_table, "gtfs/stop_times.txt")
    print(predictions.head(20).to_string(index=False))

Step-by-Step Walkthrough

GTFS times past 24:00 stay as strings until conversion. arrival_time is read with dtype string, never parsed by pandas as a time. gtfs_time_to_seconds splits HH:MM:SS and lets the hour exceed 23, so 25:14:00 becomes 90840 seconds rather than raising or wrapping to 01:14.

Service-date anchoring. service_date_epoch builds civil midnight of the service day (noon minus twelve hours, which handles the rare DST-affected days without a fixed 86 400-second assumption) and adds the second offset. A 90840-second offset therefore lands correctly on 1:14 AM of the next calendar day. The start_date comes from the TripDescriptor, defaulting to today in the agency zone when the producer omits it.

Filtering to delay sources. In parse_trip_update_delays, only SCHEDULED stop updates contribute delays. SKIPPED and NO_DATA are excluded here because they are not deviations to propagate — the dedicated guide on skipped stops shows how to fold those relationships back in. When a stop carries neither an arrival nor a departure delay, the trip-level TripUpdate.delay is used as a fallback.

The forward-fill is the propagation. After the left join, most reported_delay cells are null. groupby("trip_id").ffill() copies each announced delay down to the following stops until the next announcement replaces it. fillna(0) treats the leading stops before the first announcement as on time. This one expression is the entire predicted = scheduled + delay rule, vectorized across every live trip.

The arithmetic. predicted_epoch = sched_epoch + effective_delay is a plain integer addition once both sides are epochs. pd.to_datetime(..., unit="s", utc=True) renders a UTC timestamp you can localize for display.

Verification and Output

Assert the invariants before publishing predictions to an arrival board:

python
import pandas as pd

predictions = compute_arrival_predictions(delay_table, "gtfs/stop_times.txt")

# Every stop must have a non-null prediction after propagation.
assert predictions["predicted_epoch"].notna().all(), "Null prediction survived propagation"

# Predictions within a trip must be non-decreasing in time (a vehicle cannot
# arrive at a later stop before an earlier one).
mono = (
    predictions.sort_values(["trip_id", "stop_sequence"])
    .groupby("trip_id", sort=False)["predicted_epoch"]
    .apply(lambda s: s.is_monotonic_increasing)
    .all()
)
assert mono, "Predicted arrivals not monotonic within a trip — check delay join"

# A propagated stop should share its upstream announced delay.
sample = predictions[predictions["trip_id"] == predictions["trip_id"].iloc[0]]
print(sample[["stop_sequence", "arrival_time", "effective_delay", "predicted_arrival"]]
      .to_string(index=False))
print("Verification passed — predictions safe to publish")

Typical output for one trip shows the announced stop and the propagated stops sharing a delay:

text
 stop_sequence arrival_time  effective_delay          predicted_arrival
             3     08:10:00              360  2024-05-16 12:16:00+00:00
             4     08:17:00              360  2024-05-16 12:23:00+00:00
             5     08:24:00              360  2024-05-16 12:30:00+00:00
             6     08:31:00              360  2024-05-16 12:37:00+00:00

Gotchas and Edge Cases

  • Non-monotonic predictions from mixed delay sources. If one stop reports arrival delay and the next reports a much smaller departure delay, the predicted times can briefly go backwards. Clamp each prediction to be at least the previous stop’s prediction, or prefer a single event type per trip, before rendering.

  • A stale feed producing plausible-but-wrong times. A prediction is only as fresh as the feed. Compare feed.header.timestamp to the current time and suppress predictions older than a minute or two, otherwise you publish a delay that no longer reflects reality. Efficient refresh is covered in polling GTFS-Realtime feeds efficiently.

  • Wrong service date on after-midnight trips. A trip that departs at 25:00:00 belongs to the previous day’s service, not the calendar day it actually runs. Trust the TripDescriptor.start_date; only fall back to “today” when the producer omits it, and log every fallback.

  • Predicting stops the vehicle already passed. This script predicts all scheduled stops. For a live board, drop stops whose predicted_epoch is already in the past relative to the feed timestamp, so passengers only see upcoming arrivals.

Frequently Asked Questions

How do I compute a predicted arrival time from a TripUpdate delay?

Convert the scheduled arrival_time to an absolute POSIX epoch for the trip’s service date, then add the delay in seconds: predicted_epoch = scheduled_epoch + delay. A positive delay pushes the prediction later; a negative delay pulls it earlier.

What prediction do I use for a stop with no delay of its own?

Carry the most recent delay forward. The last StopTimeUpdate before that stop applies to it, so a groupby forward-fill on delay along stop_sequence gives every unannounced stop the delay of the closest announced upstream stop.

How do I handle scheduled times greater than 24:00:00?

Parse the HH:MM:SS string as a raw second offset from noon minus twelve hours on the service date, allowing the hour to exceed 23. Adding the offset to the service-date midnight epoch yields the correct absolute time even for trips that run past midnight.