vignettes/Fares.Rmd
Fares.RmdThis vignette explains how to add fares to a GTFS
feed built with UK2GTFS, using the NeTEx fare files published by the UK
Bus Open Data Service (BODS). It covers where the data
comes from, how the functions fit together, and - most importantly - the
conceptual mismatches between NeTEx and GTFS that force some trade-offs
when you convert. The functions described here all live in the
netex_* / gtfs_add_fares* family and are
independent of the existing timetable conversion code.
(For heavy rail fares - the National Rail fares feed
used with atoc2gtfs() - see the Heavy Rail CIF to
GTFS vignette and ?gtfs_add_railfares; rail uses a
completely different source format.)
Timetables and fares are published separately in the UK:
transxchange2gtfs()].
This gives you stops, routes,
trips, stop_times and calendars, but
no fares.The job of these functions is to read the NeTEx fares, line them up with the GTFS routes you already built, and write out fares in either GTFS fares specification:
fare_attributes.txt + fare_rules.txt (plus a
zone_id on each stop).areas, fare_products,
fare_leg_rules, rider_categories,
fare_media and networks tables.The two are genuinely different data models, so both are supported
and you choose with fares_version.
In the BODS fares archive, one XML file describes the fares for one line, in one direction, for one product - for example “Beestons line 91, Inbound, Adult Single”. A single registered service therefore spreads across many files (adult/child x single/return x inbound/outbound).
The fares themselves are usually a zone-to-zone fare triangle:
FareZone -> a list of
ScheduledStopPointRef);DistanceMatrixElement ->
PriceGroupRef);PriceGroup -> Amount).So the meaning is “travelling from zone A to zone B on this line costs GBP X for this passenger type”. A smaller number of files instead describe a single flat fare (one price for the whole line, no zones); these are read as a single zone-less fare.
library(UK2GTFS)
# 1. Unpack the BODS fares archive (follows nested zips). Keep the output
# folder path SHORT on Windows - see "Long file paths" below.
xml_files <- netex_unzip("bodds_fares_archive_20260703.zip", exdir = "C:/nfx")
# 2. Read every file. Reading is CPU-bound, so use several cores.
netex <- netex_read_fares_multiple(xml_files, ncores = 20)
# Files that could not be parsed are reported, not fatal:
netex_read_failures(netex)netex_read_fares() reads a single file into a small list
of tidy data.tables (meta, stops,
zones, prices, fares) if you want
to inspect one file directly.
A national archive contains many products per line.
netex_fare_types() shows you what is available, and
netex_filter_fares() lets you pick:
# What products exist?
netex_fare_types(netex)
#> idx operator_noc line_public_code direction product_name trip_type user_type ...
#> 1: 1 BEES 91 inbound Adult Return return adult
#> 2: 2 BEES 91 inbound Adult Single single adult
#> 3: 3 BEES 91 inbound Child Single single child
#> ...
# Keep only adult single tickets
singles <- netex_filter_fares(netex, trip_type = "single", user_type = "adult")Every attribute shown by netex_fare_types() can be used
as a filter (trip_type, user_type,
product_name, direction,
line_public_code, operator_noc), and any
combination is valid, so all fare types are selectable.
NeTEx and GTFS do not share route identifiers. The GTFS
route_id produced by transxchange2gtfs() comes
from the TransXChange ServiceCode, whereas NeTEx identifies the
line by its public number. Matching is therefore done on:
operator_noc == GTFS agency_id
(e.g. BEES), and
line_public_code == GTFS
route_short_name (e.g. 91).
gtfs <- transxchange2gtfs("bodds_timetable_archive.zip", ncores = 20)
# How much of the fares data can we actually attach?
netex_fares_report(netex, gtfs)
#> NeTEx fares report
#> files: 151691 (failed to parse: 0)
#> operators: 440
#> lines: 8875
#> zonal fare files: 77213
#> flat fare files: 74478
#> lines matched to GTFS route: 5204 / 9116The match rate depends entirely on the overlap between the fares archive (national, 440 operators) and the timetable feed you built. Matching the full national fares against a regional GTFS naturally leaves most lines unmatched; against a national timetable feed the rate is much higher.
netex_fares_report() is the tool for national runs: it
summarises the product mix and, crucially, how many lines could be
matched to a route. Unmatched lines are returned in
$unmatched.
Reading the whole GB Bus Open Data Service fares archive (July 2026) gives a sense of the scale and variety the functions have to cope with:
| Measure | Value |
|---|---|
| NeTEx fare files | ~151,700 |
| Failed to parse | 0 |
| Operators / lines | 440 / 8,875 |
| Zonal fare files (fare triangle) | ~77,200 |
| Flat fare files (single price) | ~74,500 |
| Flat fares with no line number (operator-wide) | ~42,100 |
Two things stand out. First, roughly half of all fare files are flat fares, so flat-fare support is not an edge case. Second, a large share of those flat fares carry no line number because they apply across an operator’s whole network; these cannot be matched to a single GTFS route by line number and are reported as unmatched (see limitation 8).
The one-step wrapper unpacks, reads, reports and attaches fares:
# GTFS v1, adult single tickets only
gtfs_v1 <- netex_fares_from_archive(
"bodds_fares_archive_20260703.zip",
gtfs = gtfs,
fares_version = 1,
ncores = 20,
trip_type = "single",
user_type = "adult"
)
gtfs_write(gtfs_v1, "output", name = "gtfs_with_fares_v1")
# GTFS-Fares-v2, all products at once
gtfs_v2 <- netex_fares_from_archive(
"bodds_fares_archive_20260703.zip",
gtfs = gtfs,
fares_version = 2,
ncores = 20
)
gtfs_write(gtfs_v2, "output", name = "gtfs_with_fares_v2")Or, if you already have the parsed netex list, call
gtfs_add_fares() (or the lower-level
gtfs_add_fares_v1() / gtfs_add_fares_v2())
directly.
| NeTEx concept | GTFS v1 | GTFS-Fares-v2 |
|---|---|---|
| Fare zone -> stops | stops.zone_id |
areas + stop_areas
|
| O/D zone pair |
fare_rules origin/dest |
fare_leg_rules from/to area |
| Price band amount | fare_attributes.price |
fare_products.amount |
| Line | fare_rules.route_id |
networks + route_networks
|
| Passenger type (adult…) | (not representable) | rider_categories |
| Ticket medium / channel |
payment_method (approx.) |
fare_media |
Converting NeTEx to GTFS is lossy in both directions of the specification. These are the issues to be aware of; where a choice exists it is exposed as a function argument.
fare_attributes/fare_rules cannot say “this
price is for children”. GTFS v1 can therefore only carry one
product cleanly. Select it explicitly, e.g.
trip_type = "single", user_type = "adult". If you leave
several products in, they will all be emitted and a rider-facing planner
will simply apply the cheapest matching fare, which is usually wrong.
Use GTFS-Fares-v2 if you need adult and child, or
single and return.
stops.zone_id is a single value. But a stop can
legitimately sit in different fare zones on different lines, in
different directions, or (in the source data) even within one file. When
conversions collide, gtfs_add_fares_v1() keeps the first
assignment, drops the now-orphaned fare rules, and warns. At national
scale this is severe - converting the whole archive at once reports on
the order of 80,000 conflicted stops - which is exactly why v1 is best
used per route or per direction. The practical advice is to build v1
feeds one direction at a time
(direction = "inbound" or "outbound"), or to
use v2, where stop_areas is many-to-many and a stop can
belong to as many areas as needed.
NeTEx fares are directional (the triangle is not symmetric). GTFS networks are not direction-aware, so both directions’ leg rules attach to the same network. For typical symmetric bus fares this is harmless; where inbound and outbound prices genuinely differ, a planner cannot always pick the right one. Areas are namespaced by direction so the data is at least internally consistent.
A fare zone may list stops that are not in the matched GTFS route (the route version you converted may serve fewer stops). Zones with no stop in the feed, and any fare rule that references them, are dropped so the output has no dangling references.
About half of the national archive uses flat fares
(one price for the whole line) rather than a zonal fare triangle. These
are read (meta$fare_kind is "flat") and
converted to a single route-wide fare: in v1 a fare_rule
with a route_id but no origin/destination; in v2 a
fare_leg_rule on the network with no
from_area_id/to_area_id. No
zone_id/areas are produced for a flat-fare
line.
Many flat fares (about 42,000 files nationally) carry no line
number because they apply to a whole operator’s network, not a
single service. Matching is by operator + line number, so these have
nothing to match against and appear in the report’s
$unmatched. Attaching them would require a policy decision
(apply to every route of the operator), which is deliberately
left to the caller rather than assumed.
Each NeTEx file is assumed to carry one line/direction/product with a single tariff, which matches the BODS export convention. Files that pack multiple tariffs are read using the first.
The rich NeTEx SalesOfferPackage (distribution channel,
payment methods, ticket type) is collapsed to a GTFS
payment_method (v1) or a single fare_media row
(v2). Both default to on-board cash payment and are exposed as arguments
(payment_method, fare_media_name,
fare_media_type).
BODS NeTEx file names routinely exceed 100 characters. Nested inside
long operator-folder names they blow past the Windows 260-character path
limit, which makes extraction silently fail. netex_unzip()
avoids this by extracting nested archives into short, sequentially-named
folders - but keep your exdir short as well (a short
drive-root path such as C:/nfx is safest).
Reading the ~150,000-file national NeTEx archive is by far the
expensive step and is parallelised with
furrr/future. Pass ncores to
netex_read_fares_multiple() (or the
netex_fares_from_archive() wrapper) to spread the work over
up to ~20 cores, with a progress bar. On a 20-core machine the full
national read takes on the order of an hour; a single operator is a few
seconds.
The matching step (netex_match_routes()) is vectorised
and handles tens of thousands of routes without a per-file loop. The
conversion step (gtfs_add_fares_*()) runs single-core but
is fast, and it silently skips files whose line does not match any route
(reporting the count once) so that running a national fares archive
against a regional GTFS does not flood the log.
transxchange2gtfs() has its own ncores
argument for building the timetable feed in parallel.