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.
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
"""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
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
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:
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.
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_epochis 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
transfersortransfer_durationat 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.
Related
- Parsing GTFS Fare Rules in Python — the index this pricing calls into
- Comparing GTFS Fares v1 and v2 — why the transfer logic here has no v2 equivalent
- Up: Fare Rules and Pathways Modeling — the fare tables in context
- Section: GTFS Feed Architecture & Fundamentals · Home