vignettes/ATOC.Rmd
ATOC.RmdThis vignette explains how to convert Great Britain’s heavy rail timetables into GTFS, where the source data comes from, and why there are two different conversion functions for what looks like the same format.
Great Britain’s national rail timetable is published in the
Common Interface File (CIF) format - a fixed-width text
format dating back to the mainframe era. Each line is a record whose
meaning is given by its first two characters (for example
BS = basic schedule, LO = origin location,
LI = intermediate location, LT = terminating
location). It is compact and complete but thoroughly unfriendly to
modern tools: there are no column headers, positions are counted in
fixed character offsets, and locations are given as
TIPLOC codes (Timing Point Locations) rather than
coordinates.
A full timetable download is actually a bundle of related files:
| Extension | Contents | Used by UK2GTFS |
|---|---|---|
.MCA |
The main timetable (schedules, calls, times) | Yes (required) |
.MSN |
Master Station Names - station names and (rough) locations | Yes, for names and fallback locations |
.FLF |
Fixed Links - interchange/transfer times between stations | Optional (transfers = TRUE) |
.ALF |
Additional fixed links | Not used |
.TSI, .SET, .DAT,
.ZTR, .REJ
|
Ancillary/rejected data | Not used |
Only the .MCA, .MSN and .FLF
files are needed to build a GTFS feed.
There are two sources, and this determines which function you use.
The National Rail Data
Portal (NRDP) publishes the Rail Delivery Group’s timetable feed -
historically known as the ATOC feed. This is the
public passenger timetable and is the right choice for most
users. It arrives as a zip folder (with a name like
ttis627.zip) containing the .MCA,
.MSN, .FLF and other files described
above.
Convert it with atoc2gtfs().
Network Rail publishes
its own CIF feed through its data feeds portal. It contains essentially
the same schedules but is packaged differently - a single
gzip-compressed file (for example toc-full.CIF.gz) rather
than a zip of separate files - and has small formatting differences. It
also tends to include more operational detail (such as empty-stock and
freight movements).
Convert it with nr2gtfs().
In short: RDG/ATOC zip →
atoc2gtfs(); Network Rail.CIF.gz→nr2gtfs(). The two functions share most of their internals but expect different packaging.
You need a free account at https://opendata.nationalrail.co.uk/. Store your
credentials as NRDP_username and NRDP_password
in your .Renviron file -
usethis::edit_r_environ() opens it for you. Restart R after
editing.
library(UK2GTFS)
nrdp_timetable("myfolder/timetable.zip")The companion functions nrdp_fares() and
nrdp_routing() download the fares and routeing feeds
respectively. The fares feed can be converted to GTFS fare tables
alongside the timetable - see Adding fares
below. The routeing guide is not used.
path_in <- "myfolder/timetable.zip"
gtfs <- atoc2gtfs(path_in = path_in, ncores = 3)
gtfs_write(gtfs, folder = "C:/GTFS", name = "gtfs_rail")This writes C:/GTFS/gtfs_rail.zip.
Useful arguments to atoc2gtfs():
ncores - cores for parallel processing (leave one free
for the OS).locations - where to get TIPLOC coordinates; see TIPLOC locations. Default
"tiplocs" uses the package’s improved dataset.agency - where to get agency.txt; see Agency.transfers - if TRUE (default) build
transfers.txt from the .FLF file.shapes - if TRUE, build
shapes.txt by routing each service over a map of the rail
network. Default FALSE; see Shapes
below.working_timetable - if TRUE, use the
Working Timetable (WTT) times (used operationally) instead of the public
times. Default FALSE.public_only - if TRUE (default) keep only
calls/services intended for public pickup and set-down, dropping
empty-stock and non-passenger moves.The interface is almost identical, but the input is the
.CIF.gz file:
gtfs <- nr2gtfs(path_in = "toc-full.CIF.gz", ncores = 3)
gtfs_write(gtfs, folder = "C:/GTFS", name = "gtfs_rail_nr")nr2gtfs() shares the locations,
agency, working_timetable and
public_only arguments with atoc2gtfs().
Both functions are wrappers around lower-level importers, which you can call directly if you need finer control:
importMCA() - reads the main .MCA
timetable file (the core data).importMSN() - reads the .MSN station file
(used when locations = "file" or to fill in missing
TIPLOCs).importFLF() - reads the .FLF fixed-links
file (used when transfers = TRUE).Other files in the bundle are currently ignored.
The timetable refers to stations, junctions and other points by
TIPLOC code. The .MSN file does contain
coordinates for these, but they are often only accurate to about a
kilometre and are occasionally badly wrong. UK2GTFS
therefore ships an improved tiplocs dataset with corrected
coordinates, used by default (locations = "tiplocs").
You have alternatives:
locations = "file" - use the (lower quality)
coordinates from the .MSN file instead.sf data frame of points in the same
format as tiplocs.stops.txt.If you use the improved dataset but the timetable references a TIPLOC
not in it, atoc2gtfs() (with
missing_tiplocs = TRUE, the default) falls back to the
.MSN location and warns you. The improved locations are
maintained in the UK2GTFS-data
repository - corrections are welcome via pull request. You can also
build the stops manually from the station file with
station2stops().
The CIF files do not contain the information needed
to build GTFS agency.txt (operator names, URLs, timezones).
UK2GTFS therefore ships an example
agency file which is used by default
(agency = "atoc_agency"). You can pass your own data frame
of agency information instead. Contributions to the shared file are
welcome via pull request.
The timetable feed contains no prices. Fares are published separately
by the Rail Delivery Group as the fares feed
(specification RSPS5045), which you can download from the same National
Rail Data Portal account with
nrdp_fares("myfolder/fares.zip"). The feed is a set of
fixed-width text files (RJFAF756.FFL, .LOC,
.TTY, …) listing every advertised point-to-point fare in
Great Britain - the same data ticket machines use.
The simplest route is to pass the fares zip straight to
atoc2gtfs():
gtfs <- atoc2gtfs("myfolder/timetable.zip", ncores = 3,
fares = "myfolder/fares.zip",
fares_version = 2)Or do it as a separate step, which lets you inspect the fares data and convert the same feed several ways:
fares <- atoc_fares_read("myfolder/fares.zip")
fares$ticket_type # every ticket code, with description, class and type
fares$railcard # every railcard code
# GTFS v1: the cheapest standard-class single per station pair
gtfs_v1 <- gtfs_add_railfares(gtfs, fares, fares_version = 1)
# GTFS Fares v2: adult, child and 16-25 Railcard singles and returns
gtfs_v2 <- gtfs_add_railfares(gtfs, fares, fares_version = 2,
railcards = "YNG")Rail fares are set per flow: an origin/destination pair, a fare route (“via any permitted”, “not via London”, …) and a ticket type. A flow endpoint is not always a single station - it can be a station cluster (stations that share fares) or a group station such as “LONDON TERMINALS”. This maps onto the two GTFS fare models as follows:
| Fares feed concept | GTFS v1 | GTFS Fares v2 |
|---|---|---|
| Station |
stops.zone_id (CRS code) |
member of areas/stop_areas
|
| Cluster / group station | expanded to member stations | one area with many stops |
| Flow (O/D pair) |
fare_rules origin/destination |
fare_leg_rules from/to area |
| Ticket type + price |
fare_attributes (cheapest only) |
fare_products |
| Adult / child / railcard | (not representable) | rider_categories |
Which tickets are converted is controlled by arguments:
fares_ticket_class (standard/first),
fares_ticket_type (single/return/season) or explicit
fares_ticket_codes; fares_rider_categories and
fares_railcards choose the passenger types (v2 only). By
default only walk-up tickets - the Anytime, Off-Peak
and Super Off-Peak families - are converted
(fares_walkup_only = TRUE); the feed also prices trade,
carnet and advance-purchase tier tickets, which are misleadingly cheap
in a journey planner. See ?gtfs_add_railfares for
details.
GTFS cannot carry the full restriction model of rail ticketing (validity varies by date, time, route and train). What the converter can do is evaluate those restrictions for you at conversion time, for one concrete scenario, and output plain GTFS containing exactly the fares available in that scenario:
# Fares for a journey departing 08:00 on Monday 3 August,
# with the ticket bought on 1 July
gtfs <- atoc2gtfs("myfolder/timetable.zip", ncores = 3,
fares = "myfolder/fares.zip",
fares_version = 2,
fares_travel_date = as.Date("2026-08-03"),
fares_travel_time = "08:00",
fares_booking_date = as.Date("2026-07-01"))Three things happen:
This is the recommended way to do fare-sensitive analysis
(e.g. comparing peak vs off-peak accessibility, or walk-up vs advance
pricing): build one feed per scenario and compare. Omit
fares_travel_time to apply only date-level restrictions;
omit fares_booking_date for walk-up fares only.
Two caveats. Advance availability is quota-controlled per train by the reservation system and is in no public feed, so Advance prices are the bookable tiers - a best case. And restriction evaluation covers date bands and departure-time bands (network-wide or origin-station-specific); arrival-based, via-based and train-specific restrictions, easements and minimum-fare windows are treated as “fare remains valid”, so the filter errs towards keeping a fare.
Like the bus fares conversion (see the Adding Fares - NeTEx to GTFS vignette), converting rail fares to GTFS is lossy. Choices are surfaced as arguments; the losses you cannot buy back are listed here.
fares_version = 1 every station pair
gets exactly one price: the cheapest fare among the ticket
types you selected (default: standard-class singles). Child, railcard
and return fares are ignored. Use v2 if you need them.fares_travel_date/fares_travel_time above),
or restrict conversion to Anytime products (e.g.
fares_ticket_codes = c("SDS", "SOS")).amount is the
price of the whole return journey attached to a single leg. Season
tickets are excluded by default for the same reason (their feed price is
the weekly rate).fares_walkup_only = TRUE); with a
fares_booking_date scenario the bookable tiers are included
as best-case prices.fare_rules.txt; v2 stays close to the source size because
clusters become areas. Both are written fine by
gtfs_write(), but consumers vary in what they accept.Route shapes - the geographic path each train follows - are not part
of the CIF data, but atoc2gtfs() can build them for
you:
gtfs <- atoc2gtfs(path_in = path_in, ncores = 3, shapes = TRUE)With shapes = TRUE each heavy-rail service
(route_type = 2) is routed over an internal map of the UK
rail network, producing a shapes.txt table, a
shape_id column in trips.txt and
shape_dist_traveled values in stop_times.txt.
Services that are not heavy rail (buses, ships, metro) are left without
shapes.
This is off by default (shapes = FALSE) because the
routing step adds noticeable processing time for a national timetable.
If you already have a converted feed you can add shapes afterwards with
the underlying ATOC_shapes() function, which takes and
returns a gtfs object.
After conversion, use the tools in the Working
with GTFS files vignette - in particular gtfs_clean()
and gtfs_fast_trips(), which is handy for spotting
schedules with implausible speeds caused by a mislocated TIPLOC.