Tracking trip_id Match Rates in Python

Build a set of trip identifiers from the static feed you currently have loaded, resolve every realtime entity’s TripDescriptor against it, and — this is the part that makes the metric useful — classify every miss by cause rather than lumping them into one number. Then alert on a step change against a rolling baseline rather than on an absolute threshold, because every agency’s steady-state rate is different and only movement is diagnostic. This is the second of the metrics in monitoring realtime feed quality.

Four reasons an entity does not match The causes have different owners and different fixes, which is why a single miss count is not actionable. The entity did not resolve — why? trip_id absent from static Version drift the static feed was republished no trip_id at all Producer gap or a vehicle between assignments trip not running today Service-date bug usually the midnight boundary marked ADDED Not a defect the trip is deliberately not in the schedule

Root Cause Analysis

The realtime and static halves of GTFS are published independently, versioned independently, and joined by identifiers neither side guarantees to keep stable. Everything a consumer does with realtime data depends on that join succeeding: without a matched trip there is no scheduled time to compare a delay against, no stop sequence to attach a prediction to, and no route to label a vehicle with. An unmatched entity is not degraded data, it is data that has to be discarded.

The join fails for four distinct reasons, and they are routinely reported as one.

Version drift. The agency republishes the static feed and identifiers change. The realtime producer catches up within a cycle or two; until it does, a large share of entities reference trips that no longer exist. This is the cause of nearly every sudden drop, and it resolves itself — which is why alerting on it as an incident annoys people, and why not noticing it at all means a genuine, non-recovering drift looks the same.

No descriptor at all. Some producers publish vehicle positions with a vehicle identifier and no trip_id, especially for vehicles between assignments. These cannot be matched by definition and should not count against the match rate the same way a broken reference does.

A trip that exists but is not running today. The trip_id resolves to a row in trips.txt, but its service_id is not active on the current service date. The join technically succeeds and the result is still wrong, because the entity claims a trip that today’s service calendar says is not operating. This one is invisible unless you check for it deliberately.

Deadheading and out-of-service vehicles. Real, expected, and correctly unmatched. A monitoring system that treats these as failures will report a permanent 3–5% miss rate that nobody can ever fix.

Production-Ready Python Implementation

python
"""Measure and diagnose the GTFS-Realtime to static trip join."""
from __future__ import annotations

import logging
import statistics
from collections import Counter, deque
from dataclasses import dataclass
from datetime import date

log = logging.getLogger("gtfs.rt.match")

BASELINE_POLLS = 60            # roughly half an hour at a 30-second cadence
STEP_DROP = 0.10               # a fall this far below baseline is a step change
MIN_ENTITIES = 25              # below this, the rate is too noisy to judge

# Miss causes, in the order they are tested.
NO_DESCRIPTOR = "no trip_id in the descriptor"
UNKNOWN_TRIP = "trip_id not in the static feed"
NOT_SCHEDULED_TODAY = "trip exists but its service is not active today"
ADDED_TRIP = "trip added by the realtime feed (schedule_relationship ADDED)"


@dataclass(frozen=True)
class MatchResult:
    entities: int
    matched: int
    causes: Counter

    @property
    def rate(self) -> float:
        return self.matched / self.entities if self.entities else 0.0

    @property
    def matchable(self) -> int:
        """Entities that could in principle have matched — excludes ADDED and
        descriptor-less vehicles, which are not defects."""
        excused = self.causes[NO_DESCRIPTOR] + self.causes[ADDED_TRIP]
        return self.entities - excused

    @property
    def strict_rate(self) -> float:
        return self.matched / self.matchable if self.matchable else 0.0


class MatchMonitor:
    def __init__(self, feed_name: str):
        self.feed_name = feed_name
        self._history: deque[float] = deque(maxlen=BASELINE_POLLS)

    def evaluate(self, message, static_trips: dict[str, str],
                 active_services: set[str], service_day: date) -> MatchResult:
        """static_trips: trip_id -> service_id. active_services: service_ids running today."""
        causes: Counter = Counter()
        matched = entities = 0

        for entity in message.entity:
            descriptor = None
            if entity.HasField("vehicle") and entity.vehicle.HasField("trip"):
                descriptor = entity.vehicle.trip
            elif entity.HasField("trip_update"):
                descriptor = entity.trip_update.trip
            else:
                continue                       # alerts carry no trip descriptor
            entities += 1

            relationship = descriptor.schedule_relationship
            if relationship == 1:              # ADDED — no static counterpart exists
                causes[ADDED_TRIP] += 1
                continue

            trip_id = descriptor.trip_id
            if not trip_id:
                causes[NO_DESCRIPTOR] += 1
                continue

            service_id = static_trips.get(trip_id)
            if service_id is None:
                causes[UNKNOWN_TRIP] += 1
                continue
            if service_id not in active_services:
                causes[NOT_SCHEDULED_TODAY] += 1
                continue
            matched += 1

        result = MatchResult(entities=entities, matched=matched, causes=causes)
        if entities >= MIN_ENTITIES:
            self._history.append(result.strict_rate)
        return result

    def baseline(self) -> float | None:
        if len(self._history) < 10:
            return None
        return statistics.median(self._history)

    def assess(self, result: MatchResult) -> list[str]:
        if result.entities < MIN_ENTITIES:
            return []                          # too few entities to say anything

        problems = []
        base = self.baseline()
        if base is not None and result.strict_rate < base - STEP_DROP:
            dominant = result.causes.most_common(1)
            detail = f"; mostly {dominant[0][0]}" if dominant else ""
            problems.append(
                f"match rate stepped down: {result.strict_rate:.0%} against a "
                f"{base:.0%} baseline{detail}")

        if result.causes[UNKNOWN_TRIP] > result.entities * 0.25:
            problems.append(
                f"{result.causes[UNKNOWN_TRIP]} of {result.entities} entities "
                "reference trips absent from the static feed — the static feed has "
                "probably been republished")

        if result.causes[NOT_SCHEDULED_TODAY] > result.entities * 0.10:
            problems.append(
                f"{result.causes[NOT_SCHEDULED_TODAY]} entity(s) name trips that are "
                "not scheduled today — check the service-date resolution, especially "
                "either side of midnight")
        return problems
A steady-state match breakdown Where 1,284 entities land on an ordinary poll: the excused categories are why the strict rate is the one worth monitoring. Matched 1247 resolved to a trip running today Added by the realtime feed 21 excused; no static counterpart exists Trip absent from static 12 genuine drift Trip not scheduled today 4 worth checking the service date the strict rate excludes only entities that could never have matched

Step-by-Step Walkthrough

Alerts are skipped entirely. A ServiceAlert entity carries no TripDescriptor, so counting it as an unmatched entity would drag the rate down by however many disruptions the agency currently has published. The continue before entities += 1 keeps them out of the denominator.

ADDED trips are excused, not counted as misses. schedule_relationship = ADDED means the producer is announcing a trip that is deliberately not in the static feed — an extra service laid on for an event. It cannot match, and it is not a defect. Counting it as one gives a permanently depressed rate on any agency that runs extras.

Two rates are exposed. rate is over everything; strict_rate excludes the excused categories. The strict rate is what should be monitored, because it moves only when something is actually wrong. The raw rate is still useful for reporting how much of the feed you are able to use.

The NOT_SCHEDULED_TODAY check is the one nobody writes. The trip exists, the join succeeds, and the entity is still wrong — because that trip’s service is not running today. It is nearly always a service-date bug: a vehicle running at 00:30 belongs to the previous service day, and resolving it against today’s calendar makes every overnight trip look unscheduled. That is the same service-day boundary that catches out schedule normalisation.

The baseline is a median over recent polls, not a configured constant. Every agency has a different steady state, and a threshold that works for one is wrong for the next. Using the feed’s own recent history means the check needs no per-agency tuning and adapts when a feed genuinely improves.

Low-entity polls are excluded from the baseline and from assessment. At 03:00 there may be four vehicles running, and one unmatched entity is then a 25% miss rate. MIN_ENTITIES keeps that noise out of both the history and the alerts.

Verification and Output

python
def verify(result: MatchResult) -> None:
    accounted = result.matched + sum(result.causes.values())
    assert accounted == result.entities, (
        f"{result.entities} entities but {accounted} accounted for — a code path "
        "neither matches nor records a cause")
    assert 0.0 <= result.rate <= 1.0
    assert result.matchable <= result.entities
    assert result.strict_rate >= result.rate - 1e-9, (
        "the strict rate must be at least the raw rate, since it excludes only "
        "entities that could never have matched")

The first assertion is the one that catches real bugs. Every entity must either match or produce exactly one cause; a continue added later without a corresponding counter increment silently removes entities from the accounting, and the rate then drifts without any visible reason.

Steady state:

text
INFO gtfs.rt.match: mbta_trip_updates: 1284 entities, 1247 matched (97%), strict 99%, causes: {'trip added by the realtime feed (schedule_relationship ADDED)': 21, 'trip_id not in the static feed': 12, 'trip exists but its service is not active today': 4}

A static republication in progress:

text
WARN gtfs.rt.match: mbta_trip_updates: match rate stepped down: 68% against a 99% baseline; mostly trip_id not in the static feed
WARN gtfs.rt.match: 402 of 1290 entities reference trips absent from the static feed — the static feed has probably been republished

And the service-date bug, which looks nothing like the others:

text
WARN gtfs.rt.match: night_network: 61 entity(s) name trips that are not scheduled today — check the service-date resolution, especially either side of midnight
What a static republication looks like The match rate steps down when identifiers change and recovers within a cycle or two — which is why alerting on a single poll is noise. 08:00 08:30 09:00 09:30 10:00 static republished rate falls 99% → 68% producer catches up rate returns to 99% alert on a step change against a rolling baseline, not on an absolute level

Gotchas and Edge Cases

  • Reloading the static feed mid-poll. If the static trip set is swapped while a message is being evaluated, some entities are matched against the old feed and some against the new. Pin the static snapshot for the duration of each evaluation, as the matching pipeline does.
  • Producers that populate trip_id inconsistently. Some feeds supply it on TripUpdate and omit it on VehiclePosition, so the two entity types have very different match rates. Track them separately where the difference is large, or the combined figure describes neither.
  • schedule_relationship values beyond ADDED. CANCELED trips still reference a real static trip and should match normally; UNSCHEDULED behaves like ADDED. Test the enum explicitly rather than assuming anything non-zero is exceptional.
  • A baseline poisoned by a long outage. If the rate sits at 40% for an hour, the median baseline drifts down and the recovery to 99% is never flagged as unusual — which is correct — but a subsequent drop to 60% is then compared against a contaminated baseline. Freezing the baseline while an alert is active avoids this.
  • Feeds with very small fleets. A rural operator with eight vehicles never reaches MIN_ENTITIES, so nothing is ever assessed. Lower the threshold per feed rather than removing it, and accept that the rate will be noisy.

Frequently Asked Questions

What match rate should I expect?

Above 90% for a mature feed, and often above 98%. The absolute figure matters less than its stability: a rate that has sat at 94% for months and drops to 71% overnight is a far stronger signal than one that has always been 80%.

Why classify misses instead of just counting them?

Because the causes have different owners and different fixes. A trip_id absent from the static feed means version drift; an entity with no trip_id at all means the producer is not populating the descriptor; a trip that exists but is not scheduled today means the service date resolution is wrong. One number cannot distinguish them.

Should a low match rate stop predictions being served?

No, but it should change what is served. Entities that do not match cannot be turned into stop-level predictions at all, so they simply drop out. The value of the metric is telling you how much of the fleet you have silently stopped reporting on.

How quickly does a match rate recover after the static feed is republished?

Usually within one realtime publication cycle, because producers generate identifiers from the same source as the static feed. A rate that stays low for hours after a static republication means the two systems are genuinely out of step, not merely lagging.