--- title: "GTFS-Realtime Vehicle Positions to GTFS, natively" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{GTFS-Realtime Vehicle Positions to GTFS, natively} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) has_gtfsrealtime <- requireNamespace("gtfsrealtime", quietly = TRUE) ``` 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**. ```{r setup} library(gps2gtfs) library(data.table) ``` ## Reading an archived feed The [gtfsrealtime](https://cran.r-project.org/package=gtfsrealtime) package parses GTFS-RT protobuf files — single snapshots, gzip/bzip2 files, or a daily ZIP of archived polls — into a plain data frame: ```{r read-feed, eval = has_gtfsrealtime} positions <- gtfsrealtime::read_gtfsrt_positions( system.file("nyc-vehicle-positions.pb.bz2", package = "gtfsrealtime"), timezone = "America/New_York" ) names(positions) ``` 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. ```{r synthetic} 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: ```{r baseline} 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 stops ``` 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. ```{r pipeline} 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 ) result$trips result$stop_times ``` 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`](https://github.com/e-kotov/gtfsrt2static) (a separate, optional install), which produces a feed the MobilityData `gtfs-validator` accepts: ```{r assemble, eval = FALSE} # 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: ```{r shapes} shapes <- g2g_shapes_from_trips(result$trajectory) head(shapes) ``` ## 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.