Skip to contents

Finds trips that describe the same vehicle journey twice and removes the redundant copies, leaving the rest of the feed untouched. A duplicate is a property of the feed rather than of the road: two identical journeys on the same day is one vehicle described twice, and it inflates every count made from the feed.

Usage

gtfs_deduplicate(
  gtfs,
  match_route = c("short_name", "route_id", "none"),
  match_operator = c("name", "agency_id", "noc"),
  match_block = FALSE,
  noc = NULL,
  quiet = FALSE
)

Arguments

gtfs

a gtfs object

match_route

how much the routes of two trips must agree before they can be called duplicates. `"short_name"` (the default) requires the same operator, `route_type` and `route_short_name`, so the same service published twice under different `route_id`s is caught; `"route_id"` requires the identical `route_id`, which is stricter; `"none"` compares itineraries alone, which is the least cautious.

match_operator

how two routes must agree on their operator when `match_route = "short_name"`. `"name"` (the default) compares the operator's name from `agency.txt`, ignoring case and punctuation; `"agency_id"` requires the identical `agency_id`, which is stricter; and `"noc"` asks Traveline's National Operator Codes register which company each agency record belongs to, which is the most complete. One operator is quite often filed under two `agency_id`s in the same feed - Arriva London North appears in the DfT's GTFS as both `OP401` (NOC `ARVA`) and `OP16197` (NOC `ALNO`) - and with `"agency_id"` its duplicate journeys land in different groups and survive.

match_block

logical, whether `block_id` must agree before two trips can be called duplicates. `FALSE` by default, because feeds routinely fill `block_id` with a value generated per dataset revision rather than a stable reference to a vehicle's day: in the DfT's GTFS it is a 40 character hash, so two copies of one journey never agree on it and requiring agreement would let every such duplicate through. Set `TRUE` for a feed whose `block_id` really does identify a vehicle block.

noc

the NOC database, as returned by [get_noc()]. Required when `match_operator = "noc"` and ignored otherwise. It is a parameter rather than a download so that a caller deduplicating many feeds fetches it once.

quiet

logical, suppress the summary message

Value

the gtfs object with duplicate trips, and their `stop_times` and `frequencies` rows, removed

Details

Two trips are treated as the same journey only when all of the following hold.

1. **Identical itineraries.** The whole sequence of (`stop_id`, `arrival_time`, `departure_time`) must match, in order, at every stop - together with `pickup_type` and `drop_off_type` where the feed supplies them. Times are compared as seconds since midnight, so times past 24:00:00 and the different classes a time column can arrive in (lubridate Period, `ITime`, text) all compare correctly. Trips with fewer than two stops, or with fewer than two stops that carry a time, are never removed, because their signature is too weak to be sure. 2. **The same route**, to the degree set by `match_route` and `match_operator`. 3. **The same trip attributes.** Where the feed has them, `direction_id`, `wheelchair_accessible` and `bikes_allowed` must agree - and `block_id` too when `match_block = TRUE`. Trips differing in any of these carry information that removal would lose: an accessible journey is not interchangeable with one not marked accessible. 4. **Redundant operating dates.** `calendar.txt` and `calendar_dates.txt` are expanded to the actual dates each service runs, and a copy is removed only when every date it runs is also run by a copy that is kept. Nothing that would leave a date with less service than it started with is touched.

The defaults of `match_operator` and `match_block` are deliberately the looser of the two settings each offers, because the stricter reading turned out to key identity on fields that carry no information about the road. Both were measured against published timetables on the July 2026 DfT GTFS: with `match_block = TRUE` and `match_operator = "agency_id"` the feed's copy of First Bristol's 21 stayed at 5,816 journeys over a four week window against the 3,296 its operator prints, and with the defaults it lands on 3,296 exactly - as does First Bristol's A1, at 6,916 against 6,916. Nationally the defaults raise the trips removed from that feed from 3.2 either setting for a feed that fills the field meaningfully.

The fourth test is the one that matters most. GTFS models a school-term journey and its school-holiday twin as two trips with identical times and complementary calendars, which is correct modelling and not duplication; a test that ignored dates would remove one of them and delete real service. Where two copies overlap only partly, both are kept - trimming a calendar to resolve the overlap would change service the caller did not ask to change.

Trips listed in `frequencies.txt` are never removed. There the stop times are a template rather than a journey, so two identical templates with different headways are not duplicates.

Only `trips`, `stop_times` and `frequencies` are altered, because leaving the rows of a removed trip behind would make the feed invalid. Routes, calendars, shapes and stops are left exactly as they were, even where a removal leaves one unused - that is valid GTFS, and [gtfs_clean()] will tidy it if the caller wants it tidied. With the default `match_route`, a service published twice under two `route_id`s loses the trips of one of them, so that `route_id` is left in `routes.txt` with no trips against it.

Expanding the calendars is the expensive step, so it is done only for the services of trips that have already matched on every other test.

Examples

if (FALSE) { # \dontrun{
gtfs <- gtfs_read("feed.zip")
gtfs <- gtfs_deduplicate(gtfs)
} # }