GTFS-Realtime Vehicle Positions to GTFS, natively

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%

Reading an archived feed

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 worked example

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))
)

Terminals and stops from a baseline feed

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         B

Without 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().

Extracting trips and stop times

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.

From inference tables to a valid GTFS feed

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-ready

The 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.

Shapes: the geometry actually driven

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.147

Notes for real archives

  • g2g_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.
  • GTFS-RT reports speed in meters per second; only speed == 0 is used (dwell-time detection). A missing speed column degrades dwell estimates and triggers a warning.
  • Data in any other column naming (AVL exports, logger CSVs) is mapped with vehicle_col = / time_col = — no pre-processing step needed.