R/get_summary.R

Defines functions get_summary

Documented in get_summary

#' Get a summary of a GTFS feed
#'
#' Runs a set of diagnostics on a GTFS feed (optionally restricted to some
#' dates, routes, modes, agencies and a time of day), writes them to a report
#' with tables and figures, renders the report to HTML and opens it in the
#' browser (in interactive sessions). The diagnostics are also returned as a
#' list of `data.table`s. This function does not validate or look for quality
#' issues in the GTFS feed. To do that, use [gtfstools::validate_gtfs()].
#'
#' @param gtfs A GTFS object, as created by [read_gtfs()], or the path to a
#'   GTFS file.
#' @param date A `Date` vector (including `IDate`) or a character vector of
#'   dates in the `"YYYYMMDD"` or `"YYYY-MM-DD"` formats. If given, only the
#'   services that run on these dates are described. If `NULL` (the default),
#'   the whole period covered by the feed is described.
#' @param route_id A character vector. If given, only these routes are
#'   described. If `NULL` (the default), all routes are described.
#' @param route_type An integer vector of `route_type`s (see
#'   [filter_by_route_type()]) or, unlike in other functions, a character
#'   vector of mode names (`"tram"`, `"subway"`, `"rail"`, `"bus"`, `"ferry"`,
#'   `"cable_tram"`, `"aerial_lift"`, `"funicular"`, `"trolleybus"` and
#'   `"monorail"`, which correspond to the basic `route_type`s). If given, only
#'   routes of these modes are described. If `NULL` (the default), all modes
#'   are described.
#' @param agency_id A character vector of `agency_id`s. If given, only the
#'   services of these agencies are described. If `NULL` (the default), all
#'   agencies are described.
#' @param from,to Strings in the `"HH:MM:SS"` format. If given (both must be),
#'   only the trips that run within this time of day are described. If `NULL`
#'   (the default), the whole day is described.
#' @param output_path A string. The directory in which the report is saved
#'   (created if needed). If `NULL` (the default), a `gtfstools` directory
#'   inside [tempdir()] is used. Existing reports in this directory are
#'   overwritten.
#' @param html_preview A logical. Whether to open the HTML report in the
#'   browser, in interactive sessions (defaults to `TRUE`).
#' @param by_route_type A logical. Whether to break down the totals, speeds,
#'   distances and frequencies by transport mode (`route_type`), in addition
#'   to the whole selection (defaults to `FALSE`).
#' @param unit A string. Either `"km/h"` (the default), to report distances in
#'   km and speeds in km/h, or `"mph"`, to report them in miles and miles per
#'   hour.
#'
#' @return A named list of `data.table`s (invisibly, if the report was opened
#'   in the browser):
#'   - `files`: the tables in the feed (`file`) and their number of rows
#'     (`n_rows`), before any filtering;
#'   - `agency`: the `agency_id`, `agency_name`, `agency_timezone`,
#'     `agency_url` and `agency_lang` columns (those present in the feed) of
#'     the agencies that run the selected trips (all agencies, if the
#'     selected routes have no `agency_id`);
#'   - `services`: a single row summarising the calendar of the selected
#'     services, with the columns `start_date` and `end_date` (the first and
#'     last days with service), `n_days` (the number of days between them,
#'     both included), `n_service_days` (the number of days with service),
#'     `n_services` (the number of `service_id`s), `mean_trips_per_day` (over
#'     the days with service), `busiest_date` and `max_trips_per_day` (the day
#'     with the most trips and its number of trips) and `feed_start_date` and
#'     `feed_end_date` (from `feed_info`, `NA` if absent);
#'   - `totals`: the columns `indicator`, `value` and `unit`, with the number
#'     of agencies, routes, trips and stops, the total and mean length of the
#'     shapes and of the trips (in km), the vehicle-kilometres travelled
#'     (`vkt_total`, in km) and their mean per day (`vkt_daily_mean`, in
#'     km/day), the total duration of the trips (in hours), the mean duration
#'     of the trips (in minutes), the mean trip speed (in km/h) and the mean
#'     route headway (in minutes). The means of lengths, durations and speeds
#'     are taken over the shapes and trips whose value is known, and
#'     `vkt_daily_mean` over the days with counted departures (see Details). If
#'     `by_route_type = TRUE`, `value` is replaced by one column per mode
#'     followed by the column `all` (codes of extended `route_type`s, such as
#'     `"700"`, are non-syntactic names, so they need backticks);
#'   - `speed`, `distance` and `frequency`: summary statistics (`n`, `min`,
#'     `q25`, `median`, `mean`, `q75`, `max` and `unit`) of the trip speeds
#'     (in km/h), of the trip lengths (in km) and of the route headways (in
#'     minutes). `frequency` also has the columns `date`, `from` and `to`, with
#'     the day and the time of day in which the headways were measured. If
#'     `by_route_type = TRUE`, these tables start with a `mode` column and
#'     have one row per mode followed by the row `all`.
#'
#'   With `unit = "mph"`, distances are in miles (`mi` and `mi/day`) and speeds
#'   in miles per hour (`mph`), and the rows `vkt_total` and `vkt_daily_mean`
#'   of `totals` are named `vmt_total` and `vmt_daily_mean` (vehicle-miles
#'   travelled).
#'
#'   The report is saved as `gtfs_summary.md` and `gtfs_summary.html`
#'   (together with its figures) in `output_path`.
#'
#' @section Details:
#' The report requires the `{ggplot2}`, `{knitr}` and `{rmarkdown}` packages
#' and [Pandoc](https://pandoc.org/).
#'
#' The time-of-day filter keeps whole trips (as [filter_by_time_of_day()] with
#' `full_trips = TRUE`), so trip speeds and lengths refer to whole trips.
#'
#' Trip lengths, durations and speeds are calculated with [get_trip_length()],
#' [get_trip_duration()] and [get_trip_speed()], counting each trip once,
#' however many days it runs on or departures `frequencies` lists for it.
#' Trips with a zero or negative duration count in the durations but have no
#' speed. Shape lengths count each shape used by the selected trips once, and
#' the number of stops counts the stops visited by the selected trips.
#'
#' The VKT are calculated as in [get_vkt()] (or [get_vmt()], with
#' `unit = "mph"`): the length of each selected trip counts once per departure
#' on every counted day on which its service runs. The counted days are the
#' given `date`s or, if `date` is `NULL`, every day on which the selected
#' services run; `vkt_daily_mean` is the mean over the counted days on which
#' at least one counted departure runs. With `from` and `to`, only the
#' departures at or after `from` and at or before `to` are counted, as listed
#' before the feed is filtered by time of day, so selected trips that depart
#' before `from` add nothing. `vkt_total` and `vkt_daily_mean` are `NA` if no
#' selected trip departs on the counted days (within the time of day, if
#' given). An error is raised if `frequencies` has invalid entries for
#' selected trips that run on the counted days.
#'
#' `services` and the calendar chart describe the whole calendar of the
#' selected services: with `date`, the services running on these dates are
#' kept whole (as in [filter_by_date()]), so they may cover other days too.
#' The trips per day are counted as in [get_calendar_overlap()].
#'
#' Route headways are calculated with [get_route_frequency()], by route (and
#' `direction_id`, if present in `trips`), on a single day: the one with the
#' most trips among the given `date`s or in the whole feed (the earliest, on
#' ties). They are measured within the given time of day (excluding `to`, as
#' in [get_route_frequency()], unlike the other indicators) or, without `from`
#' and `to`, from the first to the last departure of that day, rounded out to
#' whole hours. Trips of the previous service day running after midnight are
#' not counted. With `from` and `to`, headways and trips per day are counted on
#' the time-filtered feed, whose `frequencies` entries crossing `from` start
#' at `from` (unless `exact_times` is 1), so they may differ from
#' [get_route_frequency()] and [get_calendar_overlap()] on the full feed.
#'
#' With `by_route_type = TRUE`, modes are named as in the `route_type`
#' argument, extended `route_type`s by their code and trips of unknown
#' `route_type` as `"unknown"`, ordered by `route_type` with `"unknown"` last.
#' Each mode is described as if selected alone (except that all headways share
#' the same day and time of day), so stops, shapes and agencies shared by
#' modes count in each. Only the numbers of routes and trips, the total trip
#' lengths and durations and `vkt_total` (or `vmt_total`) add up to `all`.
#' Modes whose routes have blank `agency_id`s count all the agencies of the
#' selection. Trips listed in `stop_times` but not in `trips` count only in
#' `all` (and not in the VKT, as they have no service).
#'
#' @examples
#' \dontshow{
#'   old_dt_threads <- data.table::setDTthreads(1)
#'   on.exit(data.table::setDTthreads(old_dt_threads), add = TRUE)
#' }
#' if (
#'   requireNamespace("ggplot2", quietly = TRUE) &&
#'     requireNamespace("knitr", quietly = TRUE) &&
#'     requireNamespace("rmarkdown", quietly = TRUE) &&
#'     rmarkdown::pandoc_available()
#' ) {
#'   data_path <- system.file("extdata/spo_gtfs.zip", package = "gtfstools")
#'
#'   feed_summary <- get_summary(
#'     data_path,
#'     output_path = tempdir(),
#'     html_preview = FALSE
#'   )
#'   feed_summary$totals
#'
#'   \donttest{
#'   feed_summary <- get_summary(
#'     data_path,
#'     route_type = "bus",
#'     from = "07:00:00",
#'     to = "09:00:00",
#'     output_path = tempdir(),
#'     html_preview = FALSE
#'   )
#'   feed_summary$frequency
#'
#'   feed_summary <- get_summary(
#'     data_path,
#'     output_path = tempdir(),
#'     html_preview = FALSE,
#'     by_route_type = TRUE
#'   )
#'   feed_summary$totals
#'   }
#' }
#'
#' @export
get_summary <- function(
  gtfs,
  date = NULL,
  route_id = NULL,
  route_type = NULL,
  agency_id = NULL,
  from = NULL,
  to = NULL,
  output_path = NULL,
  html_preview = TRUE,
  by_route_type = FALSE,
  unit = "km/h"
) {
  if (!report_deps_available()) {
    cli::cli_abort(
      c(
        "{.fn get_summary} requires the {.pkg ggplot2}, {.pkg knitr} and
         {.pkg rmarkdown} packages, and Pandoc.",
        "i" = "Pandoc is shipped with RStudio, or see
               {.url https://pandoc.org/installing.html}."
      ),
      class = "gtfstools_report_dependencies_error"
    )
  }

  checkmate::assert(
    checkmate::check_string(gtfs),
    checkmate::check_class(gtfs, "gtfs"),
    .var.name = "gtfs"
  )
  if (is.character(gtfs)) {
    checkmate::assert_file_exists(gtfs)
  }
  if (!is.null(date)) {
    date <- parse_dates(date)
  }
  checkmate::assert_character(route_id, any.missing = FALSE, null.ok = TRUE)
  checkmate::assert(
    checkmate::check_null(route_type),
    checkmate::check_integerish(route_type, any.missing = FALSE, min.len = 1),
    checkmate::check_character(route_type, any.missing = FALSE, min.len = 1),
    .var.name = "route_type"
  )
  checkmate::assert_character(agency_id, any.missing = FALSE, null.ok = TRUE)
  checkmate::assert_string(output_path, null.ok = TRUE)
  checkmate::assert_flag(html_preview)
  checkmate::assert_flag(by_route_type)
  checkmate::assert(
    checkmate::check_string(unit),
    checkmate::check_names(unit, subset.of = c("km/h", "mph")),
    combine = "and"
  )

  checkmate::assert_string(from, pattern = time_pattern, null.ok = TRUE)
  checkmate::assert_string(to, pattern = time_pattern, null.ok = TRUE)
  if (xor(is.null(from), is.null(to))) {
    cli::cli_abort(
      "{.arg from} and {.arg to} must be given together.",
      class = "gtfstools_invalid_time_of_day"
    )
  }
  if (!is.null(from) && string_to_seconds(from) >= string_to_seconds(to)) {
    cli::cli_abort(
      "{.arg from} ({.val {from}}) must be earlier than {.arg to}
       ({.val {to}}).",
      class = "gtfstools_invalid_time_of_day"
    )
  }

  mode_route_types <- c(
    tram = 0L,
    subway = 1L,
    rail = 2L,
    bus = 3L,
    ferry = 4L,
    cable_tram = 5L,
    aerial_lift = 6L,
    funicular = 7L,
    trolleybus = 11L,
    monorail = 12L
  )
  if (is.character(route_type)) {
    checkmate::assert_names(route_type, subset.of = names(mode_route_types))
    route_type_codes <- unname(mode_route_types[route_type])
  } else {
    route_type_codes <- route_type
  }

  if (is.null(output_path)) {
    output_path <- file.path(tempdir(), "gtfstools")
  }

  progress_bar <- cli::cli_progress_bar(
    total = 9,
    format = paste0(
      "{cli::pb_spin} Summarising GTFS feed {cli::pb_bar} ",
      "{cli::pb_current}/{cli::pb_total} | {cli::pb_status}"
    ),
    status = "Reading the feed"
  )

  if (is.character(gtfs)) {
    gtfs <- read_gtfs(gtfs)
  } else {
    gtfs <- assert_and_assign_gtfs_object(gtfs)
  }

  # filter_by_agency_id() doesn't filter the routes of feeds whose 'routes'
  # table has no agency_id field, so unknown agencies are caught beforehand

  if (!is.null(agency_id)) {
    invalid_agency <- agency_id[!agency_id %chin% gtfs$agency$agency_id]

    if (length(invalid_agency) > 0) {
      cli::cli_abort(
        "{.arg agency_id} contains agency_id{?s} not listed in {.file agency}:
         {.val {invalid_agency}}.",
        class = "gtfstools_invalid_agency_id_error"
      )
    }
  }

  cli::cli_progress_update(id = progress_bar, status = "Applying filters")

  feed <- gtfs
  if (!is.null(agency_id)) {
    # feeds whose routes' agency_id is missing or blank have a single agency
    # (the up-front check ensures it's the given one), so they're not filtered

    route_agencies <- feed$routes$agency_id
    route_agencies <- route_agencies[!is.na(route_agencies)]
    if (any(route_agencies != "")) {
      feed <- filter_by_agency_id(feed, agency_id)
    }
  }
  if (!is.null(route_type_codes)) {
    feed <- filter_by_route_type(feed, route_type_codes)
  }
  if (!is.null(route_id)) {
    feed <- filter_by_route_id(feed, route_id)
  }
  if (!is.null(date)) {
    feed <- filter_by_date(feed, date)
  }

  # filter_by_time_of_day() moves the start of 'frequencies' entries to 'from',
  # which shifts their departures, so the vkt counts the departures of the
  # selected trips in the feed as it was before this filter

  untimed_feed <- feed
  if (!is.null(from)) {
    feed <- filter_by_time_of_day(feed, from, to, full_trips = TRUE)
  }

  if (NROW(feed$trips) == 0) {
    cli::cli_abort(
      "No trips match the given {.arg date}, {.arg route_id},
       {.arg route_type}, {.arg agency_id}, {.arg from} and {.arg to}.",
      class = "gtfstools_empty_selection_error"
    )
  }

  is_table <- vapply(gtfs, data.table::is.data.table, logical(1))
  files <- data.table::data.table(
    file = names(gtfs)[is_table],
    n_rows = vapply(gtfs[is_table], nrow, integer(1))
  )

  agency_cols <- c(
    "agency_id",
    "agency_name",
    "agency_timezone",
    "agency_url",
    "agency_lang"
  )
  agency_table <- data.table::data.table()
  if (gtfsio::check_file_exists(feed, "agency")) {
    agency_table <- feed$agency[,
      intersect(agency_cols, names(feed$agency)),
      with = FALSE
    ]
  }

  # the agencies are narrowed to those that run the selected trips, when the
  # 'routes' table links routes to agencies. single-agency feeds often leave
  # routes' agency_id blank, in which case no narrowing is done

  if (
    gtfsio::check_field_exists(feed, "routes", "agency_id") &&
      "agency_id" %chin% names(agency_table)
  ) {
    selected_routes <- feed$routes[route_id %chin% feed$trips$route_id]
    route_agencies <- selected_routes$agency_id
    route_agencies <- route_agencies[!is.na(route_agencies)]
    route_agencies <- route_agencies[route_agencies != ""]

    if (length(route_agencies) > 0) {
      agency_table <- agency_table[agency_id %chin% route_agencies]
    }
  }
  agency_table <- unique(agency_table)

  # speeds are calculated from the lengths and durations as in
  # get_trip_speed(), so that trip geometries are only built once

  cli::cli_progress_update(
    id = progress_bar,
    status = "Calculating trip lengths"
  )

  trip_stats <- get_trip_length(feed, unit = "km")

  # with unit = "mph", the trip and shape lengths are converted to miles as
  # soon as they are calculated, so that the speeds, the vmt and every
  # distance derived from them are in miles too. calculate_vkt() must
  # therefore always be given these lengths: without them, it would calculate
  # lengths in km

  length_unit <- if (unit == "mph") "mi" else "km"
  if (unit == "mph") {
    trip_stats[, length := length / km_per_mile]
  }

  cli::cli_progress_update(
    id = progress_bar,
    status = "Calculating trip speeds"
  )

  durations <- get_trip_duration(feed, unit = "h")
  trip_stats[durations, on = "trip_id", duration := i.duration]
  trip_stats[,
    speed := data.table::fifelse(
      is.finite(duration) & duration > 0,
      length / duration,
      NA_real_
    )
  ]

  cli::cli_progress_update(id = progress_bar, status = "Calculating VKT")

  vkt_window <- NULL
  if (!is.null(from)) {
    vkt_window <- time_of_day_window(from, to)
  }
  vkt <- calculate_vkt(
    untimed_feed,
    feed$trips[, .(trip_id, service_id, group = "all")],
    date,
    vkt_window,
    lengths = trip_stats
  )

  cli::cli_progress_update(
    id = progress_bar,
    status = "Calculating shape lengths"
  )

  # only the shapes used by the selected trips are summed

  shape_lengths <- NULL
  total_shape_length <- NA_real_
  mean_shape_length <- NA_real_
  if (
    gtfsio::check_file_exists(feed, "shapes") &&
      gtfsio::check_field_exists(feed, "trips", "shape_id")
  ) {
    shape_lengths <- get_shape_length(feed)
    shape_lengths <- shape_lengths[shape_id %chin% feed$trips$shape_id]
    if (unit == "mph") {
      shape_lengths[, length := length / km_per_mile]
    }
    total_shape_length <- sum_or_na(shape_lengths$length)
    mean_shape_length <- summarise_distribution(
      shape_lengths$length,
      length_unit
    )$mean
  }

  cli::cli_progress_update(
    id = progress_bar,
    status = "Summarising services"
  )

  service_days <- get_service_days(feed)
  services <- summarise_services(feed, service_days)

  calendar_plot <- NULL
  if (nrow(service_days) > 0) {
    calendar_plot <- get_calendar_overlap(list(feed = feed), plot = TRUE)
  }

  cli::cli_progress_update(
    id = progress_bar,
    status = "Calculating route frequencies"
  )

  frequency <- summarise_route_frequency(feed, service_days, date, from, to)

  cli::cli_progress_update(id = progress_bar, status = "Writing the report")

  speed <- summarise_distribution(trip_stats$speed, unit)
  distance <- summarise_distribution(trip_stats$length, length_unit)

  totals <- data.table::data.table(
    indicator = c(
      "n_agencies",
      "n_routes",
      "n_trips",
      "n_stops",
      "total_shape_length",
      "mean_shape_length",
      "total_trip_length",
      "mean_trip_length",
      if (unit == "mph") "vmt_total" else "vkt_total",
      if (unit == "mph") "vmt_daily_mean" else "vkt_daily_mean",
      "total_trip_duration",
      "mean_trip_duration",
      "mean_trip_speed",
      "mean_route_headway"
    ),
    value = c(
      nrow(agency_table),
      data.table::uniqueN(feed$trips$route_id),
      nrow(feed$trips),
      data.table::uniqueN(feed$stop_times$stop_id),
      total_shape_length,
      mean_shape_length,
      sum_or_na(trip_stats$length),
      distance$mean,
      vkt$vkt,
      vkt$vkt_daily_mean,
      sum_or_na(trip_stats$duration),
      summarise_distribution(trip_stats$duration * 60, "min")$mean,
      speed$mean,
      frequency$stats$mean
    ),
    unit = c(
      NA, NA, NA, NA, rep(length_unit, 5), paste0(length_unit, "/day"), "h",
      "min", unit, "min"
    )
  )

  frequency_table <- cbind(
    frequency$stats,
    date = frequency$date,
    from = frequency$from,
    to = frequency$to
  )

  plot_modes <- list()
  if (by_route_type) {
    # each trip is labelled with the mode of its route. trips whose route_type
    # is unknown (missing route, field or value) are grouped as "unknown",
    # after the modes, which are ordered by route_type

    trip_ids <- feed$trips$trip_id
    trip_routes <- feed$trips$route_id
    trip_codes <- rep(NA_integer_, length(trip_ids))
    if (gtfsio::check_field_exists(feed, "routes", "route_type")) {
      trip_codes <- suppressWarnings(
        as.integer(
          feed$routes$route_type[match(trip_routes, feed$routes$route_id)]
        )
      )
    }

    trip_labels <- names(mode_route_types)[match(trip_codes, mode_route_types)]
    is_extended <- is.na(trip_labels) & !is.na(trip_codes)
    trip_labels[is_extended] <- as.character(trip_codes[is_extended])
    trip_labels[is.na(trip_labels)] <- "unknown"
    mode_labels <- unique(trip_labels[order(trip_codes, na.last = TRUE)])

    # stops are counted in a single grouped pass, which only reads the
    # 'stop_id' column of 'stop_times'

    trip_modes <- data.table::data.table(trip_id = trip_ids, mode = trip_labels)
    stop_counts <- feed$stop_times[
      trip_modes,
      on = "trip_id",
      nomatch = NULL,
      .(stop_id, mode = i.mode)
    ][, .(n_stops = data.table::uniqueN(stop_id)), by = "mode"]

    # agencies are narrowed as in the 'agency' table above: modes whose routes
    # have blank agency_ids count all the agencies

    narrow_agencies <- gtfsio::check_field_exists(
      feed,
      "routes",
      "agency_id"
    ) &&
      "agency_id" %chin% names(agency_table)
    trip_shapes <- feed$trips$shape_id

    # calculate_vkt() returns a row for every mode, including those that don't
    # run on any counted day

    mode_vkt <- calculate_vkt(
      untimed_feed,
      data.table::data.table(
        trip_id = trip_ids,
        service_id = feed$trips$service_id,
        group = trip_labels
      ),
      date,
      vkt_window,
      lengths = trip_stats
    )

    mode_stats <- lapply(
      mode_labels,
      function(group_label) {
        in_mode <- trip_labels == group_label
        mode_trips <- trip_ids[in_mode]
        mode_routes <- unique(trip_routes[in_mode])

        n_agencies <- nrow(agency_table)
        if (narrow_agencies) {
          mode_agencies <- feed$routes$agency_id[
            feed$routes$route_id %chin% mode_routes
          ]
          mode_agencies <- mode_agencies[!is.na(mode_agencies)]
          mode_agencies <- mode_agencies[mode_agencies != ""]

          if (length(mode_agencies) > 0) {
            n_agencies <- sum(agency_table$agency_id %chin% mode_agencies)
          }
        }

        n_stops <- stop_counts$n_stops[stop_counts$mode == group_label]
        if (length(n_stops) == 0) {
          n_stops <- 0L
        }

        mode_shape_lengths <- NA_real_
        if (!is.null(shape_lengths)) {
          mode_shape_lengths <- shape_lengths$length[
            shape_lengths$shape_id %chin% trip_shapes[in_mode]
          ]
        }

        in_stats <- trip_stats$trip_id %chin% mode_trips
        mode_lengths <- trip_stats$length[in_stats]
        mode_durations <- trip_stats$duration[in_stats]
        mode_headways <- frequency$headways[
          names(frequency$headways) %chin% mode_routes
        ]

        mode_speed <- summarise_distribution(trip_stats$speed[in_stats], unit)
        mode_distance <- summarise_distribution(mode_lengths, length_unit)
        mode_frequency <- summarise_distribution(mode_headways, "min")

        is_mode_vkt <- mode_vkt$group == group_label

        mode_totals <- c(
          n_agencies,
          length(mode_routes),
          length(mode_trips),
          n_stops,
          sum_or_na(mode_shape_lengths),
          summarise_distribution(mode_shape_lengths, length_unit)$mean,
          sum_or_na(mode_lengths),
          mode_distance$mean,
          mode_vkt$vkt[is_mode_vkt],
          mode_vkt$vkt_daily_mean[is_mode_vkt],
          sum_or_na(mode_durations),
          summarise_distribution(mode_durations * 60, "min")$mean,
          mode_speed$mean,
          mode_frequency$mean
        )

        return(
          list(
            totals = mode_totals,
            speed = mode_speed,
            distance = mode_distance,
            frequency = mode_frequency
          )
        )
      }
    )
    names(mode_stats) <- mode_labels

    totals <- data.table::as.data.table(
      c(
        list(indicator = totals$indicator),
        lapply(mode_stats, `[[`, "totals"),
        list(all = totals$value, unit = totals$unit)
      )
    )

    stats_by_mode <- function(stat, all_stats) {
      mode_rows <- c(lapply(mode_stats, `[[`, stat), list(all = all_stats))
      return(data.table::rbindlist(mode_rows, idcol = "mode"))
    }

    speed <- stats_by_mode("speed", speed)
    distance <- stats_by_mode("distance", distance)
    frequency_table <- cbind(
      stats_by_mode("frequency", frequency$stats),
      date = frequency$date,
      from = frequency$from,
      to = frequency$to
    )

    # the plotted values are labelled by mode with all the modes as levels, so
    # that each mode has the same colour in every figure

    stats_labels <- trip_labels[match(trip_stats$trip_id, trip_ids)]
    headway_labels <- trip_labels[
      match(names(frequency$headways), trip_routes)
    ]
    plot_modes <- list(
      distance = factor(stats_labels, levels = mode_labels),
      speed = factor(stats_labels, levels = mode_labels),
      frequency = factor(headway_labels, levels = mode_labels)
    )
  }

  result <- list(
    files = files,
    agency = agency_table,
    services = services,
    totals = totals,
    speed = speed,
    frequency = frequency_table,
    distance = distance
  )

  selection <- list(
    date = if (is.null(date)) NULL else format(date),
    route_id = route_id,
    route_type = route_type,
    agency_id = agency_id,
    time_of_day = if (is.null(from)) NULL else paste(from, "-", to)
  )
  values <- list(
    speed = trip_stats$speed,
    distance = trip_stats$length,
    frequency = frequency$headways,
    calendar = calendar_plot,
    modes = plot_modes
  )
  report_path <- write_summary_report(result, values, selection, output_path)

  cli::cli_progress_done(id = progress_bar)

  if (html_preview && interactive()) {
    utils::browseURL(report_path)
    return(invisible(result))
  }

  return(result)
}



#' Check whether the report dependencies are available
#'
#' @return `TRUE` if `{ggplot2}`, `{knitr}`, `{rmarkdown}` and Pandoc are
#'   available, `FALSE` otherwise.
#'
#' @keywords internal
report_deps_available <- function() {
  return(
    requireNamespace("ggplot2", quietly = TRUE) &&
      requireNamespace("knitr", quietly = TRUE) &&
      requireNamespace("rmarkdown", quietly = TRUE) &&
      rmarkdown::pandoc_available()
  )
}



#' Summarise a distribution
#'
#' @param x A numeric vector. `NA`s are ignored.
#' @param unit A string. The unit of `x`.
#'
#' @return A one-row `data.table` with the columns `n` (the number of non-`NA`
#'   values), `min`, `q25`, `median`, `mean`, `q75`, `max` (all `NA` if there
#'   are no non-`NA` values) and `unit`.
#'
#' @keywords internal
summarise_distribution <- function(x, unit) {
  x <- x[!is.na(x)]

  stats <- rep(NA_real_, 6)
  if (length(x) > 0) {
    quartiles <- stats::quantile(x, c(0, 0.25, 0.5, 0.75, 1), names = FALSE)
    stats <- c(quartiles[1:3], mean(x), quartiles[4:5])
  }

  distribution <- data.table::data.table(
    n = length(x),
    min = stats[1],
    q25 = stats[2],
    median = stats[3],
    mean = stats[4],
    q75 = stats[5],
    max = stats[6],
    unit = unit
  )

  return(distribution)
}



#' Summarise the calendar of the services of a feed
#'
#' @param feed A `dt_gtfs` object, already filtered.
#' @param service_days The service days of `feed`, as returned by
#'   `get_service_days()`.
#'
#' @return A one-row `data.table`, as described in the `services` element of
#'   the [get_summary()] output.
#'
#' @keywords internal
summarise_services <- function(feed, service_days) {
  has_days <- nrow(service_days) > 0
  start_date <- as.Date(NA)
  end_date <- as.Date(NA)
  busiest_date <- as.Date(NA)

  if (has_days) {
    start_date <- min(service_days$date)
    end_date <- max(service_days$date)
    busiest_date <- service_days$date[which.max(service_days$n_trips)]
  }

  # feed_info dates are read as Dates, but a feed built by hand may have them
  # as "YYYYMMDD" strings

  feed_info_date <- function(field) {
    if (!gtfsio::check_field_exists(feed, "feed_info", field)) {
      return(as.Date(NA))
    }

    value <- feed$feed_info[[field]][1]
    if (!inherits(value, "Date")) {
      value <- as.Date(as.character(value), format = "%Y%m%d")
    }

    return(value)
  }

  services <- data.table::data.table(
    start_date = start_date,
    end_date = end_date,
    n_days = as.integer(end_date - start_date) + 1L,
    n_service_days = nrow(service_days),
    n_services = data.table::uniqueN(feed$trips$service_id),
    mean_trips_per_day = if (has_days) mean(service_days$n_trips) else NA_real_,
    busiest_date = busiest_date,
    max_trips_per_day = if (has_days) {
      as.numeric(max(service_days$n_trips))
    } else {
      NA_real_
    },
    feed_start_date = feed_info_date("feed_start_date"),
    feed_end_date = feed_info_date("feed_end_date")
  )

  return(services)
}



#' Calculate the route headways of a feed on its busiest day
#'
#' @param feed A `dt_gtfs` object, already filtered.
#' @param service_days The service days of `feed`, as returned by
#'   `get_service_days()`.
#' @param date A `Date` vector, or `NULL`. The day is chosen among these dates.
#' @param from,to Strings in the `"HH:MM:SS"` format, or `NULL`, in which case
#'   the time of day spans the departures of the day, rounded out to whole
#'   hours.
#'
#' @return A list with the elements `headways` (the headway of each route and
#'   direction, in minutes, named by `route_id`), `stats` (their summary, as
#'   created by [summarise_distribution()]), `date`, `from` and `to` (the day
#'   and time of day in which the headways were measured, `NA` if there is
#'   none).
#'
#' @keywords internal
summarise_route_frequency <- function(feed, service_days, date, from, to) {
  user_dates <- date
  headways <- numeric(0)
  frequency_day <- as.Date(NA)
  frequency_from <- NA_character_
  frequency_to <- NA_character_

  if (!is.null(user_dates)) {
    service_days <- service_days[service_days$date %in% user_dates]
  }

  if (nrow(service_days) == 0) {
    cli::cli_warn(
      "The selected services run on no day, so route frequencies were not
       calculated.",
      class = "gtfstools_no_service_days"
    )
  } else {
    busiest_day <- service_days$date[which.max(service_days$n_trips)]
    day_feed <- filter_by_date(feed, busiest_day)

    # the default time of day spans the departures of the day (which include
    # those listed in 'frequencies'), and ends one hour after the last
    # departure is rounded down, because 'to' itself is excluded

    if (is.null(from)) {
      departures <- get_trip_departures(day_feed, day_feed$trips$trip_id)

      if (nrow(departures) > 0) {
        first_departure <- min(departures$departure_secs, na.rm = TRUE)
        last_departure <- max(departures$departure_secs, na.rm = TRUE)
        from <- seconds_to_string(as.integer(first_departure %/% 3600 * 3600))
        to <- seconds_to_string(
          as.integer((last_departure %/% 3600 + 1) * 3600)
        )
      }
    }

    if (!is.null(from)) {
      frequency_day <- busiest_day
      frequency_from <- from
      frequency_to <- to

      route_frequency <- get_route_frequency(day_feed, from = from, to = to)
      group_cols <- setdiff(
        names(route_frequency),
        c("service_id", "departures", "mean_headway")
      )
      route_frequency <- route_frequency[,
        .(departures = sum(departures)),
        by = group_cols
      ]

      window_minutes <- (string_to_seconds(to) - string_to_seconds(from)) / 60
      headways <- window_minutes / route_frequency$departures
      names(headways) <- route_frequency$route_id
    }
  }

  frequency <- list(
    headways = headways,
    stats = summarise_distribution(headways, "min"),
    date = frequency_day,
    from = frequency_from,
    to = frequency_to
  )

  return(frequency)
}



#' Write and render the report of get_summary()
#'
#' @param result The list of diagnostics returned by [get_summary()].
#' @param values A named list with the numeric vectors to be plotted
#'   (`speed`, `distance` and `frequency`), the calendar chart created by
#'   [get_calendar_overlap()] (`calendar`, `NULL` if there is none) and a
#'   named list with the mode of each plotted value, as factors (`modes`,
#'   empty if the values are not grouped by mode).
#' @param selection A named list with the filters used (`NULL` elements mean
#'   no filter).
#' @param output_dir A string. The directory in which the report is saved.
#'
#' @return The path to the HTML report.
#'
#' @keywords internal
write_summary_report <- function(result, values, selection, output_dir) {
  dir.create(output_dir, recursive = TRUE, showWarnings = FALSE)

  # large numbers (e.g. the vkt) are shown in full, not in scientific notation,
  # in the tables and in the axes of the figures

  old_options <- options(scipen = 999)
  on.exit(options(old_options), add = TRUE)

  # text from the feed is escaped so that pandoc doesn't read it as markdown
  # (e.g. "$...$" as math). pipes are escaped by knitr::kable(), and the
  # escaped brackets aren't read as math because tex_math_single_backslash is
  # turned off when rendering

  md_escape <- function(x) {
    return(gsub("([\\\\`*_$<>#~^\\[\\]])", "\\\\\\1", x, perl = TRUE))
  }

  # missing strings and dates (e.g. the unit of counts) are shown as blank
  # cells

  md_table <- function(x) {
    x <- as.data.frame(x)
    is_text <- vapply(
      x,
      function(col) is.character(col) || inherits(col, "Date"),
      logical(1)
    )
    x[is_text] <- lapply(
      x[is_text],
      function(col) ifelse(is.na(col), "", md_escape(as.character(col)))
    )

    table <- knitr::kable(
      x,
      format = "pipe",
      digits = 2,
      format.args = list(drop0trailing = TRUE, big.mark = ",")
    )
    return(c("", as.character(table), ""))
  }

  # every figure uses the same theme, which is built only once because
  # building it takes a noticeable share of the report time

  report_theme <- ggplot2::theme_minimal()

  md_saved_plot <- function(plot, name) {
    plot_file <- paste0("gtfs_summary_", name, ".png")
    ggplot2::ggsave(
      file.path(output_dir, plot_file),
      plot + report_theme,
      width = 7,
      height = 4,
      dpi = 150,
      bg = "white"
    )

    return(c("", paste0("![](", plot_file, ")"), ""))
  }

  # each distribution is plotted as a histogram, unless it has no values. with
  # modes, the histograms of the modes are overlaid

  md_plot <- function(x, label, name, mode = NULL) {
    if (!is.null(mode)) {
      keep <- !is.na(x)
      if (!any(keep)) {
        return(c("", "No data to plot.", ""))
      }

      plot <- ggplot2::ggplot(
        data.frame(value = x[keep], mode = mode[keep]),
        ggplot2::aes(x = value, fill = mode)
      ) +
        ggplot2::geom_histogram(
          bins = 30,
          position = "identity",
          alpha = 0.4
        ) +
        ggplot2::scale_fill_viridis_d(drop = FALSE, end = 0.85) +
        ggplot2::labs(x = label, y = "Count", fill = "Mode")

      return(md_saved_plot(plot, name))
    }

    x <- x[!is.na(x)]
    if (length(x) == 0) {
      return(c("", "No data to plot.", ""))
    }

    plot <- ggplot2::ggplot(data.frame(value = x), ggplot2::aes(x = value)) +
      ggplot2::geom_histogram(bins = 30, fill = "#0072B2") +
      ggplot2::labs(x = label, y = "Count")

    return(md_saved_plot(plot, name))
  }

  md_calendar_plot <- function(plot) {
    if (is.null(plot)) {
      return(c("", "No service days to plot.", ""))
    }

    return(md_saved_plot(plot, "calendar"))
  }

  selection_lines <- vapply(
    names(selection),
    function(name) {
      value <- selection[[name]]
      value <- if (is.null(value)) {
        "all"
      } else {
        md_escape(paste(value, collapse = ", "))
      }
      paste0("- **", name, "**: ", value)
    },
    character(1)
  )

  # the frequency table has a row per mode when grouped by route_type, all
  # measured on the same day and time of day

  frequency <- result$frequency
  frequency_note <- if (is.na(frequency$from[1])) {
    "Route frequencies could not be calculated."
  } else {
    paste0(
      "Headways by route (and direction) on ",
      format(frequency$date[1]),
      ", between ",
      frequency$from[1],
      " and ",
      frequency$to[1],
      "."
    )
  }

  lines <- c(
    "---",
    "title: \"GTFS feed description\"",
    paste0("date: \"", format(Sys.Date()), "\""),
    "---",
    "",
    "## Selection",
    "",
    selection_lines,
    "",
    "## Files",
    md_table(result$files),
    "## Agencies",
    md_table(result$agency),
    "## Services",
    md_table(result$services),
    md_calendar_plot(values$calendar),
    "## Totals",
    md_table(result$totals),
    "## Trip speed",
    md_table(result$speed),
    md_plot(
      values$speed,
      paste0("Trip speed (", result$speed$unit[1], ")"),
      "speed",
      values$modes$speed
    ),
    "## Trip distance",
    md_table(result$distance),
    md_plot(
      values$distance,
      paste0("Trip length (", result$distance$unit[1], ")"),
      "distance",
      values$modes$distance
    ),
    "## Route frequency",
    "",
    frequency_note,
    md_table(result$frequency),
    md_plot(
      values$frequency,
      "Route headway (min)",
      "frequency",
      values$modes$frequency
    )
  )

  md_path <- file.path(output_dir, "gtfs_summary.md")
  writeLines(enc2utf8(lines), md_path, useBytes = TRUE)

  html_path <- rmarkdown::render(
    md_path,
    output_format = rmarkdown::html_document(
      md_extensions = "-tex_math_single_backslash"
    ),
    output_file = "gtfs_summary.html",
    output_dir = output_dir,
    intermediates_dir = output_dir,
    envir = new.env(),
    quiet = TRUE
  )

  return(html_path)
}

Try the gtfstools package in your browser

Any scripts or data that you put into this service are public.

gtfstools documentation built on Oct. 28, 2026, 5:13 p.m.