Calculating Trip Fares from GTFS in Python

Split the itinerary into legs at every boarding, price the first leg by matching its route and zones, then let each subsequent leg consume the first fare’s transfer allowance while it lasts — both the count in transfers and the window in transfer_duration. When either runs out, a new fare starts and the clock restarts with it. If any leg cannot be matched, mark the whole journey unpriced rather than returning a total that quietly omits it. This builds directly on the rule index from parsing GTFS fare rules in Python.

The ticket's life, not the gap between vehicles A three-leg journey against a 90-minute transfer window: the window starts at the first boarding, so the third leg is charged again. 09:00 09:30 10:00 10:30 11:00 leg 1 boards ticket bought, clock starts leg 3 boards 104 minutes later — charged again transfer_duration measures from the first boarding, never from the last

Root Cause Analysis

Pricing one leg is a lookup. Pricing a journey is a state machine, and the state is the ticket the rider is currently holding.

The v1 model expresses that ticket in two fields on fare_attributes.txt. transfers says how many further boardings the ticket covers — 0, 1, 2, or empty for unlimited. transfer_duration says how long it remains valid, in seconds. Neither is attached to the journey; they are attached to the fare product, which means the entitlement a rider holds depends on which fare they happened to buy at the start.

Three mistakes recur.

Summing the legs. Adding each leg’s matched fare ignores the entitlement entirely and overcharges every journey that includes a transfer — which, on a network where most journeys pass through a central interchange, is most of them.

Ignoring the window. Counting transfers but not elapsed time gives a rider unlimited hours to complete a two-boarding journey. This matters most on infrequent services, where a genuine wait can exceed the window and the agency really does charge again.

Starting the clock at the wrong moment. transfer_duration is the life of the ticket, not the permitted gap between vehicles. Restarting it at each transfer turns a 90-minute ticket into an indefinite one.

There is a fourth, quieter problem: an itinerary is not the same thing as a set of trips. Two consecutive trips.txt rows on the same vehicle — a block-linked continuation — are one leg, not two, because the rider never gets off. Splitting on trip rather than on boarding invents transfers that did not happen and charges for them.

Production-Ready Python Implementation

python
"""Price a multi-leg GTFS journey under the Fares v1 model."""
from __future__ import annotations

import logging
from dataclasses import dataclass, field

log = logging.getLogger("gtfs.fares.journey")


@dataclass(frozen=True)
class Leg:
    """One continuous ride on one vehicle."""
    route_id: str
    trip_id: str
    board_stop_id: str
    alight_stop_id: str
    board_epoch: int              # POSIX seconds, from the normalised schedule
    alight_epoch: int
    block_id: str | None = None


@dataclass
class PricedLeg:
    leg: Leg
    fare_id: str | None
    minor_units: int              # what this leg actually adds to the total
    reason: str                   # "charged", "covered by transfer", or "unpriced"


@dataclass
class Journey:
    legs: list[PricedLeg] = field(default_factory=list)
    currency: str | None = None

    @property
    def priced(self) -> bool:
        return all(p.reason != "unpriced" for p in self.legs)

    @property
    def total_minor_units(self) -> int | None:
        return sum(p.minor_units for p in self.legs) if self.priced else None


def merge_block_continuations(legs: list[Leg]) -> list[Leg]:
    """Two trips the rider stays aboard for are one leg, not two."""
    if not legs:
        return legs
    merged = [legs[0]]
    for leg in legs[1:]:
        previous = merged[-1]
        same_vehicle = (leg.block_id is not None
                        and leg.block_id == previous.block_id
                        and leg.board_stop_id == previous.alight_stop_id)
        if same_vehicle:
            merged[-1] = Leg(
                route_id=previous.route_id, trip_id=previous.trip_id,
                board_stop_id=previous.board_stop_id,
                alight_stop_id=leg.alight_stop_id,
                board_epoch=previous.board_epoch, alight_epoch=leg.alight_epoch,
                block_id=previous.block_id)
            log.debug("merged block continuation %s + %s", previous.trip_id, leg.trip_id)
        else:
            merged.append(leg)
    return merged


def price_journey(legs: list[Leg], index, zone_of: dict[str, str | None]) -> Journey:
    """index: the FareIndex from the v1 loader. zone_of: stop_id -> zone_id."""
    journey = Journey()
    legs = merge_block_continuations(legs)

    ticket_fare = None            # the fare product currently held
    ticket_started = 0            # POSIX seconds when it was bought
    transfers_left = 0

    for leg in legs:
        origin = zone_of.get(leg.board_stop_id)
        destination = zone_of.get(leg.alight_stop_id)

        covered = False
        if ticket_fare is not None:
            within_window = (
                ticket_fare.transfer_duration is None
                or leg.board_epoch - ticket_started <= ticket_fare.transfer_duration)
            has_allowance = transfers_left is None or transfers_left > 0
            covered = within_window and has_allowance
            if not covered:
                log.debug("ticket %s exhausted before leg on %s (window=%s allowance=%s)",
                          ticket_fare.fare_id, leg.route_id, within_window, has_allowance)

        if covered:
            if transfers_left is not None:
                transfers_left -= 1
            journey.legs.append(PricedLeg(leg, ticket_fare.fare_id, 0,
                                          "covered by transfer"))
            continue

        fare = index.match(leg.route_id, origin, destination) if index else None
        if fare is None:
            log.warning("no fare rule matches route=%s %s->%s",
                        leg.route_id, origin, destination)
            journey.legs.append(PricedLeg(leg, None, 0, "unpriced"))
            ticket_fare, transfers_left = None, 0
            continue

        if journey.currency is None:
            journey.currency = fare.currency
        elif journey.currency != fare.currency:
            log.error("journey mixes %s and %s — refusing to total it",
                      journey.currency, fare.currency)
            journey.legs.append(PricedLeg(leg, fare.fare_id, 0, "unpriced"))
            continue

        journey.legs.append(PricedLeg(leg, fare.fare_id, fare.minor_units, "charged"))
        ticket_fare = fare
        ticket_started = leg.board_epoch          # the ticket's life starts here
        transfers_left = fare.transfers           # None means unlimited

    return journey
The state of the ticket a rider is holding A journey is a state machine over the ticket: it is bought, spends its transfer allowance, and expires when either the count or the window runs out. NO TICKET HELD EXHAUSTED leg charged, clock starts transfer used window or count exhausted next leg is charged afresh

Step-by-Step Walkthrough

merge_block_continuations runs first. A rider who stays on the vehicle through a trip change has made one boarding, and charging them for a transfer they did not make is both wrong and hard to spot. The merge requires the block to match and the alighting stop to be the boarding stop, so two unrelated trips that share a block identifier at opposite ends of the city are not merged.

ticket_started is set when the fare is charged, never when it is used. This is the line that implements “the window is the life of the ticket”. Moving it inside the covered branch would restart the clock at every transfer and make a 90-minute ticket last all day.

transfers_left distinguishes None from 0. None comes from an empty transfers column and means unlimited, so has_allowance treats it as always true and the decrement is skipped. Collapsing the two is the same bug as in the loader, arriving one layer later.

An unpriced leg clears the ticket. If the pipeline cannot match a leg, it also cannot know what entitlement that leg conferred, so carrying the previous ticket forward would be a guess. Clearing it means the next leg is charged, which errs towards over-reporting the fare — the safer direction when the answer is already known to be incomplete.

Currency is fixed by the first priced leg. A journey that crosses two agencies pricing in different currencies has no meaningful total. Rather than adding numbers of different kinds, the journey is marked unpriced and the reason is logged.

Every leg produces a PricedLeg, including free ones. The itemisation is the output that can be checked against a printed fare table, and a leg that contributes zero because it was covered by a transfer is exactly the row a rider will ask about.

Verification and Output

python
def verify(journey: Journey) -> None:
    if not journey.priced:
        assert journey.total_minor_units is None, "an unpriced journey has a total"
        return

    charged = [p for p in journey.legs if p.reason == "charged"]
    covered = [p for p in journey.legs if p.reason == "covered by transfer"]
    assert charged, "a priced journey with no charged leg"
    assert journey.total_minor_units == sum(p.minor_units for p in charged)
    assert all(p.minor_units == 0 for p in covered), "a covered leg was charged"

    boards = [p.leg.board_epoch for p in journey.legs]
    assert boards == sorted(boards), "legs are out of chronological order"

A three-leg journey on a network with a 90-minute, two-transfer ticket:

text
leg 1  bus 47    zone A -> B   09:04  charged             2.40 EUR   (fare_id=urban_single)
leg 2  metro M1  zone B -> B   09:21  covered by transfer 0.00 EUR
leg 3  bus 12    zone B -> C   10:48  charged             2.40 EUR   (fare_id=urban_single)
total                                                     4.80 EUR

The third leg is charged again because it boards 104 minutes after the journey started, past the 90-minute window — not because the transfer allowance ran out. That distinction is what the log line records, and it is the first thing to check when a computed fare disagrees with an agency’s own table.

What starts a new fare leg, and what does not A grid over four things that happen during a journey and whether each creates a new leg for pricing purposes. New leg Why Boarding a second vehicle yes a leg is one ride Staying aboard through a block no the rider never alighted Walking between stops no but the clock keeps running Re-boarding the same route yes, in v1 the model cannot say otherwise

Gotchas and Edge Cases

  • Walking legs. They create no boarding and cost nothing, but the time they consume counts against the transfer window. Omitting them from the leg list entirely is fine as long as the following leg’s board_epoch is real, because the window is measured against the clock rather than against the legs.
  • A leg whose stops have no zone_id. Matches only unqualified or route-only rules. On a fully zonal feed this yields no match and an unpriced journey, which is correct but worth reporting as a feed problem rather than a pricing one.
  • Re-boarding the same route. Some agencies do not consider this a transfer at all — a rider who alights and catches the next vehicle on the same line is treated as continuing. v1 cannot express that, so the implementation above charges again. If the agency’s own tables disagree, the model is the limitation, not the code.
  • Overnight journeys. Epoch arithmetic handles the day boundary correctly, which is precisely why the leg times should come from times normalised to UTC rather than from raw GTFS clock strings, where 25:15:00 does not compare usefully against 01:15:00.
  • Fares v2 feeds. None of this applies. The leg-and-transfer model prices the joins explicitly and does not use transfers or transfer_duration at all — see comparing GTFS Fares v1 and v2.

Frequently Asked Questions

Where does a fare leg begin and end?

At each boarding. A leg is one continuous ride on one vehicle, so an itinerary with two buses and a train has three legs regardless of how many stops each one calls at. Walking between stops does not create a leg but does consume time against the transfer window.

Does the transfer window start at the first boarding or the first transfer?

At the first boarding, in every implementation that matches how agencies describe their own products. transfer_duration is the life of the ticket, not the gap allowed between vehicles, so a 90-minute window means 90 minutes from when the journey started.

What if the second leg's fare is more expensive than the first?

The v1 model has no upgrade concept, so the strict reading is that the first fare’s allowance covers the transfer whatever the second leg would have cost. Agencies that charge an upgrade cannot express it in v1 at all, which is one of the reasons Fares v2 exists.

Should an unpriceable leg make the whole journey unpriced?

Yes. A total that silently omits one leg is worse than no total, because it looks authoritative. Return the itemised legs with the unpriced one marked, and let the caller decide whether to show a partial answer.