GTFS-Realtime Vehicle Positions
are raw GPS pings with transit annotations — exactly the data
gps2gtfs converts into GTFS-shaped trips and
stop_times tables. Since version 0.2.0, the package’s
canonical column names follow the GTFS-Realtime convention
(vehicle_id, latitude, longitude,
timestamp, speed), so the output of
gtfsrealtime::read_gtfsrt_positions() feeds the pipeline
directly, with no adapter.
library(gps2gtfs)
library(data.table)
#>
#> Attaching package: 'data.table'
#> The following object is masked from 'package:base':
#>
#> %notin%The gtfsrealtime package parses GTFS-RT protobuf files — single snapshots, gzip/bzip2 files, or a daily ZIP of archived polls — into a plain data frame:
positions <- gtfsrealtime::read_gtfsrt_positions(
system.file("nyc-vehicle-positions.pb.bz2", package = "gtfsrealtime"),
timezone = "America/New_York"
)
names(positions)
#> [1] "id" "latitude"
#> [3] "longitude" "bearing"
#> [5] "odometer" "speed"
#> [7] "trip_id" "route_id"
#> [9] "direction_id" "start_time"
#> [11] "start_date" "schedule_relationship"
#> [13] "stop_id" "current_stop_sequence"
#> [15] "current_status" "timestamp"
#> [17] "congestion_level" "occupancy_status"
#> [19] "occupancy_percentage" "vehicle_id"
#> [21] "vehicle_label" "vehicle_license_plate"
#> [23] "vehicle_wheelchair_accessible" "file_timestamp"
#> [25] "file_index"Every column of that data frame is either consumed by
gps2gtfs (vehicle_id, latitude,
longitude, timestamp, speed) or
passed through untouched (trip_id, route_id,
stop_id, current_status, bearing,
…). Pass the timezone the agency operates in:
g2g_clean_gps() preserves it, so service days split at
local midnight.
A single feed snapshot contains one ping per vehicle — a trajectory
needs an archive of polls. For a reproducible example we
simulate what a day-long archive of one bus on a two-terminal route
looks like after parsing: two runs (A → B, then B → A) with GTFS-RT
trip_id annotations, plus one unannotated layover ping.
lat <- seq(7.290, 7.320, length.out = 4)
lon <- seq(80.630, 80.660, length.out = 4)
base <- as.POSIXct("2026-06-06 08:00:00", tz = "UTC")
positions <- data.frame(
vehicle_id = "7482",
latitude = c(lat, 7.321, rev(lat)),
longitude = c(lon, 80.661, rev(lon)),
timestamp = base + c(0, 300, 600, 900, 1200, 1800, 2100, 2400, 2700),
speed = c(0, 20, 20, 0, 0, 0, 20, 20, 0),
trip_id = c(rep("CS_1", 4), NA, rep("CS_2", 4))
)The pipeline needs terminal and stop locations. With a planned (baseline) static GTFS feed for the same system — a gtfsio/gtfstools-style object or a path to a GTFS zip — both are derived automatically:
baseline <- list(
trips = data.frame(
trip_id = c("CS_1", "CS_2"),
route_id = "R1",
service_id = "wk"
),
stop_times = data.frame(
trip_id = rep(c("CS_1", "CS_2"), each = 4),
stop_id = c("A", "S1", "S2", "B", "B", "S2", "S1", "A"),
stop_sequence = rep(1:4, 2)
),
stops = data.frame(
stop_id = c("A", "S1", "S2", "B"),
stop_name = c("Terminal A", "Stop 1", "Stop 2", "Terminal B"),
stop_lat = lat,
stop_lon = lon
)
)
terminals <- g2g_terminals_from_gtfs(baseline, route_id = "R1")
stops <- g2g_stops_from_gtfs(baseline, route_id = "R1")
terminals
#> terminal_id latitude longitude
#> <char> <num> <num>
#> 1: A 7.29 80.63
#> 2: B 7.32 80.66
stops
#> Key: <direction, stop_id>
#> stop_id latitude longitude direction
#> <char> <num> <num> <char>
#> 1: S1 7.30 80.64 A
#> 2: S2 7.31 80.65 A
#> 3: S1 7.30 80.64 B
#> 4: S2 7.31 80.65 BWithout a baseline, supply the two tables by hand, or — when
positions carry stop_id annotations — estimate stop
coordinates from the pings themselves with
g2g_stops_from_positions().
Because the positions carry trip_id annotations, we can
skip spatial trip inference entirely (trip_col = "trip_id",
the fast path). Without the annotation, drop that argument and
trips are inferred from terminal-buffer crossings instead — same output
either way.
result <- g2g_extract_trips_and_stop_times(
gps_data = positions,
terminals_data = terminals,
stops_data = stops,
terminals_buffer_radius = 100,
stops_buffer_radius = 100,
stops_extended_buffer_radius = 150,
trip_col = "trip_id",
return_trajectory = TRUE
)
#> Starting Pipeline for extracting Trip and Bus Stop Data using backend: rust
#> [INFO] Auto-detected local UTM projection EPSG:32644 (Zone 44N)
#> [INFO] Using supplied trip identities from column(s) 'trip_id' (fast path).
#> Pipeline finished successfully!
#> Warning: gps2gtfs extraction lost coverage: kept 2 trips from 9 input pings.
#> Dropped rows_dropped_no_trip_identity (1), pings_dropped_not_in_trip (1).
#> Inspect the full coverage table with g2g_diagnostics(result) (also attr(result,
#> "diagnostics")). Silence with diagnostics_warn = FALSE or
#> options(gps2gtfs.diagnostics_warn = FALSE).
result$trips
#> trip_id vehicle_id date start_terminal end_terminal direction
#> <int> <char> <char> <char> <char> <int>
#> 1: 1 7482 2026-06-06 A B 1
#> 2: 2 7482 2026-06-06 B A 2
#> start_time end_time provided_trip_id duration_in_mins
#> <POSc> <POSc> <char> <num>
#> 1: 2026-06-06 08:00:00 2026-06-06 08:15:00 CS_1 15
#> 2: 2026-06-06 08:30:00 2026-06-06 08:45:00 CS_2 15
#> day_of_week hour_of_day is_weekday orientation_id orientation_status
#> <ord> <int> <lgcl> <int> <fctr>
#> 1: Saturday 8 FALSE NA none
#> 2: Saturday 8 FALSE NA none
#> orientation_confidence pattern_ref start_anchor_ref end_anchor_ref
#> <num> <char> <char> <char>
#> 1: NA <NA> <NA> <NA>
#> 2: NA <NA> <NA> <NA>
result$stop_times
#> trip_id vehicle_id date direction stop_id arrival_time
#> <int> <char> <char> <int> <char> <POSc>
#> 1: 1 7482 2026-06-06 1 S1 2026-06-06 08:05:00
#> 2: 1 7482 2026-06-06 1 S2 2026-06-06 08:10:00
#> 3: 2 7482 2026-06-06 2 S2 2026-06-06 08:35:00
#> 4: 2 7482 2026-06-06 2 S1 2026-06-06 08:40:00
#> departure_time dwell_time_in_seconds day_of_week hour_of_day is_weekday
#> <POSc> <num> <ord> <int> <lgcl>
#> 1: 2026-06-06 08:05:00 0 Saturday 8 FALSE
#> 2: 2026-06-06 08:10:00 0 Saturday 8 FALSE
#> 3: 2026-06-06 08:35:00 0 Saturday 8 FALSE
#> 4: 2026-06-06 08:40:00 0 Saturday 8 FALSE
#> provided_trip_id orientation_id orientation_status orientation_confidence
#> <char> <int> <fctr> <num>
#> 1: CS_1 NA none NA
#> 2: CS_1 NA none NA
#> 3: CS_2 NA none NA
#> 4: CS_2 NA none NA
#> pattern_ref
#> <char>
#> 1: <NA>
#> 2: <NA>
#> 3: <NA>
#> 4: <NA>These are inference tables, not finished GTFS files:
GTFS-style names, but no route_id, service_id,
or stop_sequence, and times are absolute
POSIXct values (input timezone) rather than GTFS clock
strings — so trips running past midnight stay unambiguous.
gps2gtfs stops at coordinate inference on purpose:
route_id, service_id, and canonical stop
identities cannot be inferred from GPS alone, so the package does not
invent them. Turning the inference tables into a standard-compliant feed
— deriving stop_sequence, attributing each trip to a
service day, encoding >24:00:00 clock strings, and
linking or synthesizing IDs — is the job of the companion package gtfsrt2static
(a separate, optional install), which produces a feed the MobilityData
gtfs-validator accepts:
# install.packages("pak"); pak::pak("e-kotov/gtfsrt2static")
library(gtfsrt2static)
events <- snapshot_from_stop_times(
result$stop_times,
trip_id_col = "provided_trip_id" # preserve official trip IDs when present
)
# Baseline mode: inherit official route/service/stop IDs from a planned feed.
feed <- snapshot_assemble(events)
# ...or baseline-free, synthesizing a compliant feed (strict = publish gate):
# feed <- snapshot_scaffold(events, strict = TRUE)
gtfsio::export_gtfs(feed, "realized_gtfs.zip") # gtfstools/tidytransit-readyThe chunk is not evaluated here because gtfsrt2static is
a separate package; see its documentation for baseline vs. scaffold
assembly and the strict-mode publish gate.
return_trajectory = TRUE exposes the ping-level
trajectory, which converts into a shapes.txt-shaped table —
unlike planned shapes, these traces record what the vehicle actually
drove, including detours:
shapes <- g2g_shapes_from_trips(result$trajectory)
head(shapes)
#> shape_id shape_pt_lat shape_pt_lon shape_pt_sequence shape_dist_traveled
#> <char> <num> <num> <int> <num>
#> 1: SHP_1 7.29 80.63 1 0.000
#> 2: SHP_1 7.30 80.64 2 1566.182
#> 3: SHP_1 7.31 80.65 3 3132.347
#> 4: SHP_1 7.32 80.66 4 4698.495
#> 5: SHP_2 7.32 80.66 1 0.000
#> 6: SHP_2 7.31 80.65 2 1566.147g2g_clean_gps() (called internally by the pipeline)
deduplicates repeated (vehicle_id, timestamp) observations
— archived feeds re-report unchanged positions every poll — and drops
pings with missing vehicle_id with a warning.speed in meters per second; only
speed == 0 is used (dwell-time detection). A missing speed
column degrades dwell estimates and triggers a warning.vehicle_col = / time_col = — no
pre-processing step needed.