As well as converting UK formats to GTFS, UK2GTFS
provides a toolkit for reading, cleaning, checking, reshaping and
analysing GTFS feeds - whether they came from UK2GTFS
itself or from anywhere else.
The General Transit Feed
Specification (GTFS) is a .zip file containing a set of
related CSV tables - agency.txt, stops.txt,
routes.txt, trips.txt,
stop_times.txt, calendar.txt,
calendar_dates.txt, and optional extras such as
shapes.txt and frequencies.txt. The tables are
linked by ID columns (a trip belongs to a route, has many stop_times,
and runs on a service_id calendar).
In UK2GTFS a GTFS feed is held in memory as a
named list of data frames - one element per table,
named after the file (gtfs$stops, gtfs$trips,
and so on). Every function below takes and/or returns such an object, so
you can chain them together and inspect any table directly as an
ordinary data frame.
| Function | Purpose |
|---|---|
gtfs_read(path) |
Read a GTFS .zip into a gtfs object |
gtfs_write(gtfs, folder, name) |
Write a gtfs object back to a .zip
|
gtfs <- gtfs_read("myfeed.zip")
# ... work on it ...
gtfs_write(gtfs, folder = "C:/GTFS", name = "myfeed_clean")gtfs_write() has options to strip commas, tabs and
newlines from text fields (stripComma,
stripTab, stripNewline) - useful because stray
delimiters in operator or stop names can break strict GTFS
consumers.
Real-world feeds - and conversions of messy source data - contain errors. Two families of function help.
gtfs_clean() - fix common errors
gtfs_clean(gtfs) removes a set of well-known problems,
including: stops with no coordinates or no location information; trips
with fewer than two stops; stops that are never used; and (with
public_only = TRUE) non-public services such as freight or
empty-stock moves that have no GTFS route type and would otherwise break
tools like OpenTripPlanner. It also tidies up orphaned shapes and
frequencies.
gtfs <- gtfs_clean(gtfs)gtfs_validate_internal() - check for problems
gtfs_validate_internal(gtfs) checks the feed against the
GTFS specification and reports what it finds at three severities:
Error (breaks the spec), Warning
(probably a mistake) and Note (worth knowing). Checks
cover every table - including shapes, frequencies, transfers and the
fare tables - and include: required tables and columns, duplicated ids,
referential integrity of every cross-table reference (for example
stop_times referencing a stop that isn’t in stops.txt),
coordinate ranges, enum and date/time values, times that go backwards
along a trip, and services that can never run. It reports problems but
does not change the data; it invisibly returns a data frame of the
problems for programmatic use.
gtfs_validate_internal(gtfs)
problems <- gtfs_validate_internal(gtfs) # severity / table / messagegtfs_force_valid() - make it valid by removing
problems
Where gtfs_validate_internal() only reports,
gtfs_force_valid(gtfs) acts: it drops stops with missing
locations, routes not present in agency, trips not in
routes, stop_times not in trips or
stops, and calendar entries with no matching trips. Use it
as a last resort to guarantee referential integrity when a downstream
tool refuses a slightly broken feed.
gtfs_fast_trips() and gtfs_fast_stops() -
catch mislocated stops
A frequent, sneaky error is a stop placed in the wrong location,
which makes a vehicle appear to “teleport” across the country between
two calls. gtfs_fast_trips(gtfs) returns the
trip_ids whose fastest segment exceeds
maxspeed (default 83 m/s ≈ 185 mph, the top speed on HS1);
gtfs_fast_stops() returns an sf data frame of
the suspect stops for mapping.
bad_trips <- gtfs_fast_trips(gtfs, maxspeed = 45) # ~100 mph, for a bus feed
bad_stops <- gtfs_fast_stops(gtfs)gtfs_merge(gtfs_list) combines a list of gtfs objects
into one, handling ID clashes (if duplicate IDs are found across feeds
it regenerates fresh IDs). If small inconsistencies (such as an operator
name spelt two ways) block a clean merge, pass force = TRUE
to take the first instance as authoritative.
combined <- gtfs_merge(list(gtfs_bus, gtfs_rail), force = TRUE)gtfs_split(gtfs, n_split = 2) divides a large feed into
a list of smaller, size-balanced feeds (split by
agency_id), which is helpful when a feed is too big for a
downstream tool. gtfs_split_ids(gtfs, trip_ids) splits on a
specific set of trip_ids, returning a $true
feed (matching trips) and a $false feed (everything
else).
parts <- gtfs_split(gtfs, n_split = 4)
sel <- gtfs_split_ids(gtfs, trip_ids = bad_trips)gtfs_clip(gtfs, bounds) restricts a feed to a geographic
area. bounds is an sf polygon (or
multi-polygon) in CRS 4326. Trips crossing the boundary are truncated at
the edge, and trips that call only once inside the area are dropped. The
optional tables - shapes, frequencies, transfers, pathways and the fare
tables - are pruned so they only reference the stops, routes and trips
that remain.
gtfs_trim_dates(gtfs, startdate, enddate) keeps only
services that run between two dates and shrinks the calendars
accordingly - useful for cutting a multi-year feed down to the week or
month you care about. Frequencies and shapes belonging to removed trips
are removed with them.
gtfs_march <- gtfs_trim_dates(gtfs,
startdate = as.Date("2024-03-01"),
enddate = as.Date("2024-03-31"))gtfs_compress(gtfs) reduces file size by replacing the
long, human-readable IDs that UK2GTFS preserves during
conversion (such as NaPTAN stop codes) with compact integers. Every
table that references a rewritten id - stop_times, transfers, pathways,
frequencies, shapes, stop_areas, fare_rules and route_networks - is
updated together, so the feed stays consistent. Do this once you no
longer need to trace IDs back to the source data.
gtfs <- gtfs_compress(gtfs)gtfs_summary(gtfs) prints the number of rows in each
table and the date range covered by the calendar - the fastest way to
sanity-check a feed.
gtfs_summary(gtfs)gtfs_stop_frequency(gtfs, startdate, enddate) counts how
many trips call at each stop over a date range, returning the
stops table with stops_total and
stops_per_week columns added. This accounts for the
calendar and calendar exceptions, so it reflects services
actually running - a good proxy for “how well served is this stop?”.
stops <- gtfs_stop_frequency(gtfs,
startdate = as.Date("2024-03-01"),
enddate = as.Date("2024-03-31"))Note the date range must fall inside the feed’s calendar or you will get no results - a common gotcha with short feeds such as those from the NPTDR.
gtfs_trips_per_zone(gtfs, zone, ...) aggregates trips
into your own set of geographic zones (an sf polygon data
frame), broken down by day of week, time band and (optionally) mode. The
default time bands split the day into Night, Morning Peak, Midday,
Afternoon Peak and Evening, and you can supply your own via the
time_bands argument.
Three helpers turn GTFS tables into sf objects you can
plot with plot(), ggplot2 or
tmap:
| Function | Returns |
|---|---|
gtfs_stops_sf(gtfs) |
Stops as points |
gtfs_trips_sf(gtfs) |
Every trip as a line |
gtfs_routes_sf(gtfs) |
One representative line per route (lighter to plot) |
library(sf)
stops_sf <- gtfs_stops_sf(gtfs)
routes_sf <- gtfs_routes_sf(gtfs)
plot(st_geometry(routes_sf))Some bus timetables give several consecutive stops the same
time rather than a unique time each.
gtfs_interpolate_times(gtfs) spreads these out, assigning
each stop a distinct arrival and departure time by interpolating between
the known times - which makes speed and journey-time analysis far more
meaningful.
gtfs <- gtfs_interpolate_times(gtfs)Putting it together, a common end-to-end pattern is:
library(UK2GTFS)
gtfs <- gtfs_read("myfeed.zip") # 1. read
gtfs_summary(gtfs) # look
gtfs <- gtfs_clean(gtfs) # 2. clean
gtfs_validate_internal(gtfs) # 3. check what remains
gtfs <- gtfs_clip(gtfs, bounds = my_area) # 4. subset to area
gtfs <- gtfs_trim_dates(gtfs, start_date, end_date) # and to dates
stops <- gtfs_stop_frequency(gtfs, start_date, end_date) # 5. analyse
gtfs_write(gtfs, folder = "C:/GTFS", name = "myfeed_clean") # 6. save