Handling Skipped Stops in TripUpdates

In a GTFS-Realtime StopTimeUpdate, schedule_relationship = SKIPPED means the vehicle will not serve that stop on this trip: drop it from the served itinerary and emit no prediction for it, while letting the surrounding delay continue to propagate across the served stops on either side. schedule_relationship = NO_DATA means the producer has no realtime information for that stop: fall back to its plain scheduled time and do not carry a previously reported delay across it. Getting these two relationships right is what keeps the delay propagation honest — a naive forward-fill that ignores them will invent arrivals for stops the bus sails past and will claim confident predictions where the feed explicitly disclaims one.

Root Cause Analysis

The schedule_relationship enum on a StopTimeUpdate has four values, and only one of them — SCHEDULED — is a delay you should propagate. The other two that appear in real feeds, SKIPPED and NO_DATA, change the shape of the itinerary and the propagation itself, so treating every StopTimeUpdate as a delay source produces subtly wrong boards.

SKIPPED is a service change: an expressed or short-turned trip omits stops the timetable lists. If your pipeline keeps the skipped stop and forward-fills the previous delay onto it, a passenger waiting there sees a predicted arrival for a vehicle that will never stop. The correct behaviour is to remove the stop from the served set. Crucially, removal is not a reset — the delay reported at the last served stop still describes how late the vehicle is, so it should continue to the served stops beyond the skip.

NO_DATA is an information gap, not a service change. The producer is saying “this stop is on the trip, but I cannot tell you anything live about it.” The mistake here is the opposite of the skipped case: carrying a stale upstream delay across a NO_DATA stop asserts knowledge you do not have. The spec-aligned fallback is the plain scheduled time, and the carry must not leak past it. Both behaviours depend on reading schedule_relationship alongside the (trip_id, stop_sequence) key from the static stop_times.txt model, because you can only drop or reset a stop if you have matched it to its scheduled row. The delay-versus-schedule arithmetic itself follows the same rules described in converting local transit times to UTC in Python.

schedule_relationship handling for skipped and no-data stops A StopTimeUpdate branches on schedule_relationship: SCHEDULED yields a propagated prediction, SKIPPED removes the stop while propagation continues past it, and NO_DATA falls back to the scheduled time and blocks the delay carry. schedule_relationship decision StopTimeUpdate read relationship SCHEDULED predict = sched + delay; propagate SKIPPED drop stop; propagation continues NO_DATA predict = schedule; block carry

Production-Ready Python Implementation

The script parses a TripUpdates feed while retaining schedule_relationship, joins to the scheduled stops, propagates delays across SCHEDULED stops only, resets NO_DATA stops to schedule, and drops SKIPPED stops from the served itinerary.

python
import logging

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"

STU = gtfs_realtime_pb2.TripUpdate.StopTimeUpdate
SR_SCHEDULED = STU.SCHEDULED
SR_SKIPPED = STU.SKIPPED
SR_NO_DATA = STU.NO_DATA


def parse_updates_with_relationship(url: str) -> pd.DataFrame:
    """Return one row per StopTimeUpdate, retaining schedule_relationship."""
    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_id = trip_update.trip.trip_id
        for stu in trip_update.stop_time_update:
            if stu.arrival.HasField("delay"):
                delay = stu.arrival.delay
            elif stu.departure.HasField("delay"):
                delay = stu.departure.delay
            else:
                delay = pd.NA
            rows.append(
                {
                    "trip_id": trip_id,
                    "stop_sequence": stu.stop_sequence if stu.HasField("stop_sequence") else pd.NA,
                    "schedule_relationship": stu.schedule_relationship,
                    "reported_delay": delay,
                }
            )

    updates = pd.DataFrame(rows).astype(
        {
            "trip_id": "string",
            "stop_sequence": "Int64",
            "schedule_relationship": "Int64",
            "reported_delay": "Int64",
        }
    )
    logging.info(
        "Parsed %d updates (%d skipped, %d no_data)",
        len(updates),
        int((updates["schedule_relationship"] == SR_SKIPPED).sum()),
        int((updates["schedule_relationship"] == SR_NO_DATA).sum()),
    )
    return updates


def apply_skip_and_nodata(updates: pd.DataFrame, stop_times_path: str) -> pd.DataFrame:
    """Join to schedule, propagate delays across SCHEDULED stops, honour SKIPPED / NO_DATA."""
    stop_times = pd.read_csv(
        stop_times_path,
        dtype={
            "trip_id": "string",
            "stop_id": "string",
            "stop_sequence": "Int64",
            "arrival_time": "string",     # may exceed 24:00:00 for overnight trips
        },
        usecols=["trip_id", "stop_id", "stop_sequence", "arrival_time"],
    )

    live_trip_ids = set(updates["trip_id"].dropna())
    schedule = stop_times[stop_times["trip_id"].isin(live_trip_ids)].copy()
    schedule = schedule.sort_values(["trip_id", "stop_sequence"], ignore_index=True)

    merged = schedule.merge(
        updates, on=["trip_id", "stop_sequence"], how="left"
    )
    # Stops with no update at all are SCHEDULED by default (they inherit propagation).
    merged["schedule_relationship"] = merged["schedule_relationship"].fillna(SR_SCHEDULED).astype("Int64")

    # Only SCHEDULED stops contribute a delay to the propagation stream.
    carry_source = merged["reported_delay"].where(
        merged["schedule_relationship"] == SR_SCHEDULED
    )
    merged["propagated_delay"] = (
        carry_source.groupby(merged["trip_id"], sort=False).ffill().fillna(0).astype("Int64")
    )

    # NO_DATA: fall back to schedule (delay = 0) and mark it so the carry does not
    # leak downstream. We break the carry by resetting to NaN at NO_DATA rows,
    # then re-run the fill so later SCHEDULED stops start fresh from their own delay.
    is_no_data = merged["schedule_relationship"] == SR_NO_DATA
    merged.loc[is_no_data, "propagated_delay"] = 0

    # SKIPPED: remove from the served itinerary entirely.
    is_skipped = merged["schedule_relationship"] == SR_SKIPPED
    served = merged.loc[~is_skipped].copy()

    served["status"] = "predicted"
    served.loc[served["schedule_relationship"] == SR_NO_DATA, "status"] = "scheduled_fallback"

    logging.info(
        "Served %d stops (dropped %d skipped); %d on scheduled fallback",
        len(served),
        int(is_skipped.sum()),
        int((served["status"] == "scheduled_fallback").sum()),
    )
    return served[
        ["trip_id", "stop_id", "stop_sequence", "arrival_time",
         "schedule_relationship", "propagated_delay", "status"]
    ]


if __name__ == "__main__":
    updates = parse_updates_with_relationship(TRIPUPDATES_URL)
    served = apply_skip_and_nodata(updates, "gtfs/stop_times.txt")
    print(served.head(20).to_string(index=False))

Step-by-Step Walkthrough

Retaining schedule_relationship. Unlike a pure prediction pass, this parser keeps the enum on every row. It is stored as nullable Int64 because the raw protobuf value is an integer, and comparisons against SR_SKIPPED / SR_NO_DATA stay explicit and readable.

Default relationship after the join. Scheduled stops the feed never mentions come out of the left join with a null schedule_relationship. fillna(SR_SCHEDULED) restores the correct default so they participate in propagation — an unmentioned stop is a normal served stop, not a skipped one.

Delay carry over SCHEDULED only. reported_delay.where(relationship == SCHEDULED) blanks out any delay attached to a SKIPPED or NO_DATA row before the forward-fill, so those rows can never become a propagation source. The groupby(...).ffill() then carries genuine delays across the gaps that skipped stops leave behind — propagation continues past a skip exactly as the spec intends.

NO_DATA reset. After propagation, rows flagged NO_DATA have their propagated_delay forced to 0, which is the plain scheduled time. They are tagged scheduled_fallback so the rendering layer can show them as timetable-only rather than as a confident live prediction.

SKIPPED removal. The final filter ~is_skipped drops skipped stops from the output entirely. Because the delay carry was computed before the drop, the served stops after the skip still receive the upstream delay — removal does not reset propagation.

Verification and Output

Confirm that skipped stops are gone and no-data stops sit on schedule:

python
import pandas as pd

served = apply_skip_and_nodata(updates, "gtfs/stop_times.txt")

# No SKIPPED stop should remain in the served output.
assert (served["schedule_relationship"] != SR_SKIPPED).all(), \
    "A SKIPPED stop leaked into the served itinerary"

# Every NO_DATA stop must be on scheduled fallback with zero propagated delay.
no_data = served[served["schedule_relationship"] == SR_NO_DATA]
assert (no_data["propagated_delay"] == 0).all(), "NO_DATA stop carried a non-zero delay"
assert (no_data["status"] == "scheduled_fallback").all(), "NO_DATA stop mislabelled"

# stop_sequence must remain monotonic even with skips removed.
mono = (
    served.groupby("trip_id", sort=False)["stop_sequence"]
    .apply(lambda s: s.is_monotonic_increasing)
    .all()
)
assert mono, "stop_sequence not monotonic after dropping skipped stops"

print(served[["stop_sequence", "schedule_relationship", "propagated_delay", "status"]]
      .head(12).to_string(index=False))
print("Verification passed — skips dropped, no-data on schedule")

Sample output for a trip where sequence 4 is skipped and sequence 6 has no data:

text
 stop_sequence  schedule_relationship  propagated_delay              status
             3                      0               300           predicted
             5                      0               300           predicted
             6                      2                 0  scheduled_fallback
             7                      0               300           predicted

Sequence 4 is absent (skipped), sequence 5 still carries the +300 s delay from sequence 3, sequence 6 resets to schedule, and sequence 7 resumes the carried delay.

Gotchas and Edge Cases

  • Consecutive skips. A short-turned or expressed trip may skip a run of stops. Because propagation is computed before removal, a block of skipped stops collapses cleanly and the delay bridges the whole gap — but verify the served stops after the block still have a monotonic stop_sequence.

  • NO_DATA at the head of a trip. If the first realtime stop is NO_DATA, there is no upstream delay to reset from; the fallback to schedule is still correct, but log it, because it often signals a producer that has not yet locked onto the vehicle.

  • Producer sends SKIPPED with a populated delay. Some feeds attach a residual arrival.delay to a skipped stop. Ignore it — the .where(relationship == SCHEDULED) guard already excludes it, but never surface a prediction for a stop you are dropping.

  • Distinguishing SKIPPED from a cancelled trip. A whole cancelled trip is signalled at the TripDescriptor level with schedule_relationship = CANCELED, not by marking every stop SKIPPED. Handle trip-level cancellation before this stop-level pass; the two are covered together in joining TripUpdates to stop_times.

Frequently Asked Questions

What does schedule_relationship SKIPPED mean in a StopTimeUpdate?

SKIPPED means the vehicle will not serve that stop on this trip. Remove the stop from the served itinerary and emit no arrival prediction for it. Do not let the delay from the stop before it carry across as though the stop were served.

How is NO_DATA different from SKIPPED?

NO_DATA means the producer has no realtime information for that stop, not that the stop is dropped. Fall back to the plain scheduled time for it and do not propagate a previously reported delay across it, since you have no basis to claim the vehicle is still running late there.

Should a skipped stop reset the propagated delay?

No. A SKIPPED stop is simply removed. The delay reported at the last served stop before it still applies to the served stops after it, so propagation continues across the gap left by the skipped stop rather than resetting at it.