diff --git a/R/get_trip_speed_profile.R b/R/get_trip_speed_profile.R index 11350b97..4743a1f5 100644 --- a/R/get_trip_speed_profile.R +++ b/R/get_trip_speed_profile.R @@ -9,13 +9,12 @@ #' #' @param rt_speed data.frame or sf data.frame. The result of #' \code{GTFShift::rt_average_speed()}. Must contain at least the columns -#' \code{distance_along_geometry} and \code{timestamp}. +#' \code{distance_along_geometry}, \code{distance_along_geometry_reversed}, and +#' \code{timestamp}. #' @param by Character vector (Default \code{c("trip_id", "route_id", "day")}). #' Columns to aggregate by. If \code{"day"} is included in \code{by} but not present #' in \code{rt_speed}, it is automatically derived from the \code{timestamp} column. #' Set to \code{NULL} or \code{character(0)} to compute metrics across the entire dataset. -#' @param dist_col Character (Default \code{"distance_along_geometry"}). Column name -#' present in \code{rt_speed} representing cumulative distance along geometry (in meters). #' @param speed_col Character (Default \code{"speed_kmh"}). Column name present in #' \code{rt_speed} representing estimated speed between consecutive updates (in km/h). #' @param time_col Character (Default \code{"timestamp"}). Column name present in @@ -26,18 +25,26 @@ #' \code{trip_id}, \code{route_id}, and \code{day}), observations are ordered #' chronologically by \code{timestamp}. #' -#' Let \eqn{\{(d_i, t_i)\}_{i=1}^n} denote the ordered sequence of updates, where -#' \eqn{d_i} is the distance along geometry (meters) and \eqn{t_i} is the timestamp (seconds). +#' Let \eqn{\{(d_i, d_i^{\mathrm{rev}}, t_i)\}_{i=1}^n} denote the ordered sequence of updates, where +#' \eqn{d_i} is the distance along geometry (meters), \eqn{d_i^{\mathrm{rev}}} is the reversed distance +#' along geometry (meters), and \eqn{t_i} is the timestamp (seconds). +#' +#' To accommodate circular geometries (where starting and terminal positions may map to the same +#' location on the shape), the distance traveled between two observations \eqn{i} and \eqn{j} (\eqn{j > i}) +#' is calculated by considering both the normal and reversed distances and taking the maximum: +#' \deqn{\Delta d_{i, j}^{\mathrm{fwd}} = \left| d_j - d_i \right|} +#' \deqn{\Delta d_{i, j}^{\mathrm{circ}} = \left| d_j - d_i^{\mathrm{rev}} \right|} +#' \deqn{\Delta d_{i, j} = \max\left(\Delta d_{i, j}^{\mathrm{fwd}}, \Delta d_{i, j}^{\mathrm{circ}}\right)} #' #' \strong{Commercial speed} is calculated as the total distance traveled between the #' first and last updates divided by the elapsed time: -#' \deqn{v_{\mathrm{commercial}} = \frac{|d_n - d_1|}{1000} \div \frac{t_n - t_1}{3600}} +#' \deqn{v_{\mathrm{commercial}} = \frac{\Delta d_{1, n}}{1000} \div \frac{t_n - t_1}{3600}} #' If \eqn{n < 2} or \eqn{t_n \le t_1}, \code{commercial_speed} is \code{NA}. #' #' \strong{Alternative commercial speed} (\code{commercial_speed_alt}) uses the 2nd and #' penultimate (\eqn{n-1}) observations to eliminate potential dwell times or layovers at #' the terminal stops: -#' \deqn{v_{\mathrm{commercial\_alt}} = \frac{|d_{n-1} - d_2|}{1000} \div \frac{t_{n-1} - t_2}{3600}} +#' \deqn{v_{\mathrm{commercial\_alt}} = \frac{\Delta d_{2, n-1}}{1000} \div \frac{t_{n-1} - t_2}{3600}} #' If \eqn{n < 4} or \eqn{t_{n-1} \le t_2}, \code{commercial_speed_alt} is \code{NA}. #' #' If \code{by} does not include \code{trip_id} (e.g., aggregating at route or day level), @@ -115,7 +122,6 @@ get_trip_speed_profile <- function( rt_speed, by = c("trip_id", "route_id", "day"), - dist_col = "distance_along_geometry", speed_col = "speed_kmh", time_col = "timestamp" ) { @@ -131,7 +137,7 @@ get_trip_speed_profile <- function( rt_speed <- dplyr::ungroup(rt_speed) # Check required columns - required_cols <- c(dist_col, time_col) + required_cols <- c("distance_along_geometry", "distance_along_geometry_reversed", time_col) missing_cols <- setdiff(required_cols, colnames(rt_speed)) if (length(missing_cols) > 0) { stop(paste("rt_speed is missing required column(s):", paste(missing_cols, collapse = ", "))) @@ -196,7 +202,9 @@ get_trip_speed_profile <- function( # First and last updates if (n_trip >= 2) { dt <- get_time_diff_sec(trip_df[[time_col]][1], trip_df[[time_col]][n_trip]) - dd <- abs(trip_df[[dist_col]][n_trip] - trip_df[[dist_col]][1]) + dd_normal <- abs(trip_df[["distance_along_geometry"]][n_trip] - trip_df[["distance_along_geometry"]][1]) + dd_reversed <- abs(trip_df[["distance_along_geometry"]][n_trip] - trip_df[["distance_along_geometry_reversed"]][1]) + dd <- pmax(dd_normal, dd_reversed, na.rm = TRUE) comm_speed <- compute_speed_kmh(dd, dt) } else { comm_speed <- NA_real_ @@ -205,7 +213,9 @@ get_trip_speed_profile <- function( # 2nd and penultimate updates if (n_trip >= 4) { dt_alt <- get_time_diff_sec(trip_df[[time_col]][2], trip_df[[time_col]][n_trip - 1]) - dd_alt <- abs(trip_df[[dist_col]][n_trip - 1] - trip_df[[dist_col]][2]) + dd_alt_normal <- abs(trip_df[["distance_along_geometry"]][n_trip - 1] - trip_df[["distance_along_geometry"]][2]) + dd_alt_reversed <- abs(trip_df[["distance_along_geometry"]][n_trip - 1] - trip_df[["distance_along_geometry_reversed"]][2]) + dd_alt <- pmax(dd_alt_normal, dd_alt_reversed, na.rm = TRUE) comm_speed_alt <- compute_speed_kmh(dd_alt, dt_alt) } else { comm_speed_alt <- NA_real_ diff --git a/R/osm_utils.R b/R/osm_utils.R index 1653032d..d8427d7d 100644 --- a/R/osm_utils.R +++ b/R/osm_utils.R @@ -138,3 +138,29 @@ get_osm_relations <- function(osm_file, q, pb, osm_route_type = "bus", pb_update return(relations_df) } + +#' Ensure OSM file is in a writable location for osmextract +#' +#' @param osm_file character. Path to OSM extract file. +#' @return character. Path to the (potentially copied) OSM extract file. +#' @noRd +prepare_osm_file <- function(osm_file) { + if (is.null(osm_file) || !file.exists(osm_file)) { + return(osm_file) + } + + dir_writable <- file.access(dirname(osm_file), 2) == 0 + lib_paths <- normalizePath(.libPaths(), winslash = "/", mustWork = FALSE) + norm_path <- normalizePath(osm_file, winslash = "/", mustWork = FALSE) + in_lib <- any(vapply(lib_paths, function(lp) startsWith(norm_path, lp), logical(1))) + + if (!dir_writable || in_lib) { + temp_file <- file.path(tempdir(), basename(osm_file)) + if (!file.exists(temp_file) || file.info(osm_file)$mtime > file.info(temp_file)$mtime) { + file.copy(osm_file, temp_file, overwrite = TRUE) + } + return(temp_file) + } + + osm_file +} diff --git a/R/query_osm_bus_lanes.R b/R/query_osm_bus_lanes.R index e4090dde..f7550616 100644 --- a/R/query_osm_bus_lanes.R +++ b/R/query_osm_bus_lanes.R @@ -36,6 +36,7 @@ #' @export osm_bus_lanes <- function(bbox, osm_file = NULL) { if (!is.null(osm_file)) { + osm_file <- prepare_osm_file(osm_file) highways_base <- osmextract::oe_read(osm_file, boundary = bbox, quiet = TRUE) highways_cols <- osmextract::oe_get_keys(highways_base) cols_to_check <- c( diff --git a/R/query_osm_shapes_match_routes.R b/R/query_osm_shapes_match_routes.R index 15202ca3..0a17f0bb 100644 --- a/R/query_osm_shapes_match_routes.R +++ b/R/query_osm_shapes_match_routes.R @@ -124,6 +124,7 @@ osm_shapes_match_routes <- function( osm_route_type = "bus", metric_crs = 3857 ) { + osm_file <- prepare_osm_file(osm_file) metric_crs_is_default <- missing(metric_crs) initial_osm_file <- osm_file total_steps <- 4 diff --git a/R/query_osm_shapes_to_routes.R b/R/query_osm_shapes_to_routes.R index b269dbc4..2a5466a4 100644 --- a/R/query_osm_shapes_to_routes.R +++ b/R/query_osm_shapes_to_routes.R @@ -70,6 +70,7 @@ osm_shapes_to_routes <- function( osm_file = NULL, osm_route_type = "bus" ) { + osm_file <- prepare_osm_file(osm_file) total_steps <- 2 + ways if (!is.null(osm_file)) { total_steps <- total_steps - 1 diff --git a/inst/extdata/samples/osmextract_lisbon_highways_sample.gpkg b/inst/extdata/samples/osmextract_lisbon_highways_sample.gpkg deleted file mode 100644 index c9c15286..00000000 Binary files a/inst/extdata/samples/osmextract_lisbon_highways_sample.gpkg and /dev/null differ diff --git a/inst/extdata/samples/osmextract_tcb_network.gpkg b/inst/extdata/samples/osmextract_tcb_network.gpkg deleted file mode 100644 index e43dd40a..00000000 Binary files a/inst/extdata/samples/osmextract_tcb_network.gpkg and /dev/null differ diff --git a/man/get_trip_speed_profile.Rd b/man/get_trip_speed_profile.Rd index dcba7192..a734e8ab 100644 --- a/man/get_trip_speed_profile.Rd +++ b/man/get_trip_speed_profile.Rd @@ -7,7 +7,6 @@ get_trip_speed_profile( rt_speed, by = c("trip_id", "route_id", "day"), - dist_col = "distance_along_geometry", speed_col = "speed_kmh", time_col = "timestamp" ) @@ -15,16 +14,14 @@ get_trip_speed_profile( \arguments{ \item{rt_speed}{data.frame or sf data.frame. The result of \code{GTFShift::rt_average_speed()}. Must contain at least the columns -\code{distance_along_geometry} and \code{timestamp}.} +\code{distance_along_geometry}, \code{distance_along_geometry_reversed}, and +\code{timestamp}.} \item{by}{Character vector (Default \code{c("trip_id", "route_id", "day")}). Columns to aggregate by. If \code{"day"} is included in \code{by} but not present in \code{rt_speed}, it is automatically derived from the \code{timestamp} column. Set to \code{NULL} or \code{character(0)} to compute metrics across the entire dataset.} -\item{dist_col}{Character (Default \code{"distance_along_geometry"}). Column name -present in \code{rt_speed} representing cumulative distance along geometry (in meters).} - \item{speed_col}{Character (Default \code{"speed_kmh"}). Column name present in \code{rt_speed} representing estimated speed between consecutive updates (in km/h).} @@ -47,18 +44,26 @@ For each group defined by \code{by} (by default, each unique combination of \code{trip_id}, \code{route_id}, and \code{day}), observations are ordered chronologically by \code{timestamp}. -Let \eqn{\{(d_i, t_i)\}_{i=1}^n} denote the ordered sequence of updates, where -\eqn{d_i} is the distance along geometry (meters) and \eqn{t_i} is the timestamp (seconds). +Let \eqn{\{(d_i, d_i^{\mathrm{rev}}, t_i)\}_{i=1}^n} denote the ordered sequence of updates, where +\eqn{d_i} is the distance along geometry (meters), \eqn{d_i^{\mathrm{rev}}} is the reversed distance +along geometry (meters), and \eqn{t_i} is the timestamp (seconds). + +To accommodate circular geometries (where starting and terminal positions may map to the same +location on the shape), the distance traveled between two observations \eqn{i} and \eqn{j} (\eqn{j > i}) +is calculated by considering both the normal and reversed distances and taking the maximum: +\deqn{\Delta d_{i, j}^{\mathrm{fwd}} = \left| d_j - d_i \right|} +\deqn{\Delta d_{i, j}^{\mathrm{circ}} = \left| d_j - d_i^{\mathrm{rev}} \right|} +\deqn{\Delta d_{i, j} = \max\left(\Delta d_{i, j}^{\mathrm{fwd}}, \Delta d_{i, j}^{\mathrm{circ}}\right)} \strong{Commercial speed} is calculated as the total distance traveled between the first and last updates divided by the elapsed time: -\deqn{v_{\mathrm{commercial}} = \frac{|d_n - d_1|}{1000} \div \frac{t_n - t_1}{3600}} +\deqn{v_{\mathrm{commercial}} = \frac{\Delta d_{1, n}}{1000} \div \frac{t_n - t_1}{3600}} If \eqn{n < 2} or \eqn{t_n \le t_1}, \code{commercial_speed} is \code{NA}. \strong{Alternative commercial speed} (\code{commercial_speed_alt}) uses the 2nd and penultimate (\eqn{n-1}) observations to eliminate potential dwell times or layovers at the terminal stops: -\deqn{v_{\mathrm{commercial\_alt}} = \frac{|d_{n-1} - d_2|}{1000} \div \frac{t_{n-1} - t_2}{3600}} +\deqn{v_{\mathrm{commercial\_alt}} = \frac{\Delta d_{2, n-1}}{1000} \div \frac{t_{n-1} - t_2}{3600}} If \eqn{n < 4} or \eqn{t_{n-1} \le t_2}, \code{commercial_speed_alt} is \code{NA}. If \code{by} does not include \code{trip_id} (e.g., aggregating at route or day level), diff --git a/tests/testthat/test-get_trip_speed_profile.R b/tests/testthat/test-get_trip_speed_profile.R index 9ad84c91..8f0f9a06 100644 --- a/tests/testthat/test-get_trip_speed_profile.R +++ b/tests/testthat/test-get_trip_speed_profile.R @@ -12,6 +12,7 @@ test_that("get_trip_speed_profile calculates speed profile with default paramete route_id = "R1", timestamp = c(1000, 1060, 1120, 1180, 1240, 2000, 2050, 2100, 2150, 2200), distance_along_geometry = c(0, 300, 700, 1200, 1500, 0, 400, 800, 1300, 1800), + distance_along_geometry_reversed = c(1500, 1200, 800, 300, 0, 1800, 1400, 1000, 500, 0), speed_kmh = c(NA, 18, 24, 30, 18, NA, 28.8, 28.8, 36, 36) ) @@ -89,6 +90,7 @@ test_that("get_trip_speed_profile supports custom aggregation columns in 'by'", route_id = "R1", timestamp = c(1000, 1060, 1120, 1180, 1240, 2000, 2050, 2100, 2150, 2200), distance_along_geometry = c(0, 300, 700, 1200, 1500, 0, 400, 800, 1300, 1800), + distance_along_geometry_reversed = c(1500, 1200, 800, 300, 0, 1800, 1400, 1000, 500, 0), speed_kmh = c(NA, 18, 24, 30, 18, NA, 28.8, 28.8, 36, 36) ) @@ -121,7 +123,7 @@ test_that("get_trip_speed_profile supports custom aggregation columns in 'by'", expect_equal(res_char0$commercial_speed, 27.45) # 5. Default by when route_id is missing: keeps available columns (trip_id and derived day) - df_no_route <- df[, c("trip_id", "timestamp", "distance_along_geometry", "speed_kmh")] + df_no_route <- df[, c("trip_id", "timestamp", "distance_along_geometry", "distance_along_geometry_reversed", "speed_kmh")] res_default_partial <- GTFShift::get_trip_speed_profile(df_no_route) expect_equal(nrow(res_default_partial), 2) expect_contains(names(res_default_partial), c("trip_id", "day")) @@ -136,6 +138,7 @@ test_that("get_trip_speed_profile derives day column from different timestamp ty route_id = "R1", timestamp = t_posix, distance_along_geometry = c(0, 500, 1000, 1500), + distance_along_geometry_reversed = c(1500, 1000, 500, 0), speed_kmh = c(NA, 15, 15, 15) ) res_posix <- GTFShift::get_trip_speed_profile(df_posix) @@ -149,6 +152,7 @@ test_that("get_trip_speed_profile derives day column from different timestamp ty route_id = "R1", timestamp = as.Date(c("2024-06-01", "2024-06-02", "2024-06-03", "2024-06-04")), distance_along_geometry = c(0, 100000, 200000, 300000), + distance_along_geometry_reversed = c(300000, 200000, 100000, 0), speed_kmh = c(NA, 4.17, 4.17, 4.17) ) res_date <- GTFShift::get_trip_speed_profile(df_date) @@ -170,6 +174,7 @@ test_that("get_trip_speed_profile drops geometry from sf objects", { route_id = "R1", timestamp = c(1000, 1060, 1120, 1180, 1240), distance_along_geometry = c(0, 300, 700, 1200, 1500), + distance_along_geometry_reversed = c(1500, 1200, 800, 300, 0), speed_kmh = c(NA, 18, 24, 30, 18), geometry = sf::st_sfc(lapply(1:5, function(i) sf::st_point(c(i, i)))) ) @@ -186,6 +191,7 @@ test_that("get_trip_speed_profile handles update count edge cases (n < 2, 2 <= n trip_id = "T1", timestamp = 1000, distance_along_geometry = 0, + distance_along_geometry_reversed = 1500, speed_kmh = 25 ) res_n1 <- GTFShift::get_trip_speed_profile(df_n1, by = "trip_id") @@ -202,6 +208,7 @@ test_that("get_trip_speed_profile handles update count edge cases (n < 2, 2 <= n trip_id = "T1", timestamp = c(1000, 1060), distance_along_geometry = c(0, 500), + distance_along_geometry_reversed = c(500, 0), speed_kmh = c(NA, 30) ) res_n2 <- GTFShift::get_trip_speed_profile(df_n2, by = "trip_id") @@ -213,6 +220,7 @@ test_that("get_trip_speed_profile handles update count edge cases (n < 2, 2 <= n trip_id = "T1", timestamp = c(1000, 1060, 1120), distance_along_geometry = c(0, 500, 1000), + distance_along_geometry_reversed = c(1000, 500, 0), speed_kmh = c(NA, 30, 30) ) res_n3 <- GTFShift::get_trip_speed_profile(df_n3, by = "trip_id") @@ -224,6 +232,7 @@ test_that("get_trip_speed_profile handles update count edge cases (n < 2, 2 <= n trip_id = "T1", timestamp = c(1000, 1060, 1120, 1180), distance_along_geometry = c(0, 500, 1000, 1500), + distance_along_geometry_reversed = c(1500, 1000, 500, 0), speed_kmh = c(NA, 30, 30, 30) ) res_n4 <- GTFShift::get_trip_speed_profile(df_n4, by = "trip_id") @@ -236,6 +245,7 @@ test_that("get_trip_speed_profile handles update count edge cases (n < 2, 2 <= n trip_id = c("T1", "T2"), timestamp = c(1000, 2000), distance_along_geometry = c(0, 0), + distance_along_geometry_reversed = c(1000, 1000), speed_kmh = c(20, 30) ) res_multi_n1 <- GTFShift::get_trip_speed_profile(df_multi_n1, by = "route_id") @@ -248,6 +258,7 @@ test_that("get_trip_speed_profile handles update count edge cases (n < 2, 2 <= n trip_id = c("T1", "T1", "T2", "T2"), timestamp = c(1000, 1060, 2000, 2060), distance_along_geometry = c(0, 500, 0, 600), + distance_along_geometry_reversed = c(500, 0, 600, 0), speed_kmh = c(NA, 30, NA, 36) ) res_multi_n2 <- GTFShift::get_trip_speed_profile(df_multi_n2, by = "route_id") @@ -260,6 +271,7 @@ test_that("get_trip_speed_profile handles update count edge cases (n < 2, 2 <= n trip_id = c("T1", "T1", "T1", "T1", "T2"), timestamp = c(1000, 1060, 1120, 1180, 2000), distance_along_geometry = c(0, 500, 1000, 1500, 0), + distance_along_geometry_reversed = c(1500, 1000, 500, 0, 1000), speed_kmh = c(NA, 30, 30, 30, 20) ) res_multi_mixed <- GTFShift::get_trip_speed_profile(df_multi_mixed, by = "route_id") @@ -273,6 +285,7 @@ test_that("get_trip_speed_profile handles unordered updates, zero/negative elaps trip_id = "T1", timestamp = c(1000, 1060, 1120, 1180), distance_along_geometry = c(0, 500, 1000, 1500), + distance_along_geometry_reversed = c(1500, 1000, 500, 0), speed_kmh = c(NA, 30, 30, 30) ) df_scrambled <- df_sorted[c(3, 1, 4, 2), ] @@ -289,6 +302,7 @@ test_that("get_trip_speed_profile handles unordered updates, zero/negative elaps trip_id = "T1", timestamp = c(1000, 1000), distance_along_geometry = c(0, 500), + distance_along_geometry_reversed = c(500, 0), speed_kmh = c(NA, 30) ) res_zero <- GTFShift::get_trip_speed_profile(df_zero_time, by = "trip_id") @@ -299,6 +313,7 @@ test_that("get_trip_speed_profile handles unordered updates, zero/negative elaps trip_id = "T1", timestamp = c(NA, 1060), distance_along_geometry = c(0, 500), + distance_along_geometry_reversed = c(500, 0), speed_kmh = c(NA, 30) ) res_na_time <- GTFShift::get_trip_speed_profile(df_na_time, by = "trip_id") @@ -308,6 +323,7 @@ test_that("get_trip_speed_profile handles unordered updates, zero/negative elaps trip_id = "T1", timestamp = c(1000, 1060), distance_along_geometry = c(NA, 500), + distance_along_geometry_reversed = c(NA, 0), speed_kmh = c(NA, 30) ) res_na_dist <- GTFShift::get_trip_speed_profile(df_na_dist, by = "trip_id") @@ -318,6 +334,7 @@ test_that("get_trip_speed_profile handles unordered updates, zero/negative elaps trip_id = "T1", timestamp = c(1000, 1060, 1120, 1180), distance_along_geometry = c(1500, 1000, 500, 0), + distance_along_geometry_reversed = c(0, 500, 1000, 1500), speed_kmh = c(NA, 30, 30, 30) ) res_backward <- GTFShift::get_trip_speed_profile(df_backward, by = "trip_id") @@ -329,6 +346,7 @@ test_that("get_trip_speed_profile handles unordered updates, zero/negative elaps trip_id = "T1", timestamp = c("2024-05-01 10:00:00", "2024-05-01 10:02:00", "2024-05-01 10:04:00", "2024-05-01 10:06:00"), distance_along_geometry = c(0, 500, 1000, 1500), + distance_along_geometry_reversed = c(1500, 1000, 500, 0), speed_kmh = c(NA, 15, 15, 15) ) res_char <- GTFShift::get_trip_speed_profile(df_char, by = "trip_id") @@ -342,6 +360,7 @@ test_that("get_trip_speed_profile handles speed column variations and edge cases trip_id = "T1", timestamp = c(1000, 1060, 1120, 1180), distance_along_geometry = c(0, 500, 1000, 1500), + distance_along_geometry_reversed = c(1500, 1000, 500, 0), speed_kmh = c(20, Inf, -Inf, 40) ) res_inf <- GTFShift::get_trip_speed_profile(df_inf, by = "trip_id") @@ -355,6 +374,7 @@ test_that("get_trip_speed_profile handles speed column variations and edge cases trip_id = "T1", timestamp = c(1000, 1060), distance_along_geometry = c(0, 500), + distance_along_geometry_reversed = c(500, 0), speed_kmh = c(NA_real_, NA_real_) ) res_all_na <- GTFShift::get_trip_speed_profile(df_all_na, by = "trip_id") @@ -376,7 +396,8 @@ test_that("get_trip_speed_profile handles speed column variations and edge cases df_no_speed <- data.frame( trip_id = "T1", timestamp = c(1000, 1060), - distance_along_geometry = c(0, 500) + distance_along_geometry = c(0, 500), + distance_along_geometry_reversed = c(500, 0) ) res_no_speed <- GTFShift::get_trip_speed_profile(df_no_speed, by = "trip_id") expect_equal(res_no_speed$speed_count, 0) @@ -387,13 +408,13 @@ test_that("get_trip_speed_profile handles speed column variations and edge cases df_custom <- data.frame( my_trip = "T1", my_time = c(1000, 1060, 1120, 1180), - my_dist = c(0, 500, 1000, 1500), + distance_along_geometry = c(0, 500, 1000, 1500), + distance_along_geometry_reversed = c(1500, 1000, 500, 0), my_spd = c(NA, 30, 30, 30) ) res_custom <- GTFShift::get_trip_speed_profile( df_custom, by = "my_trip", - dist_col = "my_dist", speed_col = "my_spd", time_col = "my_time" ) @@ -421,22 +442,22 @@ test_that("get_trip_speed_profile stops with informative error on invalid inputs # 2. Missing required columns expect_error( GTFShift::get_trip_speed_profile(data.frame(x = 1)), - "rt_speed is missing required column\\(s\\): distance_along_geometry, timestamp" + "rt_speed is missing required column\\(s\\): distance_along_geometry, distance_along_geometry_reversed, timestamp" ) expect_error( GTFShift::get_trip_speed_profile( - data.frame(x = 1), - dist_col = "my_dist", + data.frame(distance_along_geometry = 1), time_col = "my_time" ), - "rt_speed is missing required column\\(s\\): my_dist, my_time" + "rt_speed is missing required column\\(s\\): distance_along_geometry_reversed, my_time" ) # 3. Specified grouping column in by is missing df_valid <- data.frame( trip_id = "T1", timestamp = 1000, - distance_along_geometry = 0 + distance_along_geometry = 0, + distance_along_geometry_reversed = 1000 ) expect_error( GTFShift::get_trip_speed_profile(df_valid, by = c("non_existent_col")), @@ -491,3 +512,29 @@ test_that("get_trip_speed_profile integrates seamlessly with rt_average_speed ou expect_equal(nrow(global_summary), 1) expect_equal(global_summary$n_updates, 8) }) + +test_that("get_trip_speed_profile accommodates circular geometries by taking max of normal and reversed distances", { + # On a circular loop of 1000 meters, suppose a vehicle completes a loop and the final update + # snaps to the beginning (distance_along_geometry = 10, distance_along_geometry_reversed = 990). + # Update 1: at meter 20 (distance_along_geometry = 20, distance_along_geometry_reversed = 980) + # Update 2: at meter 250 (distance_along_geometry = 250, distance_along_geometry_reversed = 750) + # Update 3: at meter 750 (distance_along_geometry = 750, distance_along_geometry_reversed = 250) + # Update 4: at meter 990, snapped to 10 (distance_along_geometry = 10, distance_along_geometry_reversed = 990) + # Forward distance: |10 - 20| = 10 m + # Reversed distance: |10 - 980| = 970 m + # Max distance: 970 m over (1120 - 1000) = 120 seconds -> 29.1 km/h + # Alt updates (updates 2 and 3): + # Forward: |750 - 250| = 500 m + # Reversed: |750 - 750| = 0 m + # Max distance: 500 m over (1080 - 1040) = 40 seconds -> 45.0 km/h + df_circ <- data.frame( + trip_id = "T_circ", + timestamp = c(1000, 1040, 1080, 1120), + distance_along_geometry = c(20, 250, 750, 10), + distance_along_geometry_reversed = c(980, 750, 250, 990), + speed_kmh = c(NA, 20.7, 45.0, 19.8) + ) + res_circ <- GTFShift::get_trip_speed_profile(df_circ, by = "trip_id") + expect_equal(res_circ$commercial_speed, 29.1) + expect_equal(res_circ$commercial_speed_alt, 45.0) +}) diff --git a/tests/testthat/test-osm_utils.R b/tests/testthat/test-osm_utils.R index c1f37e9d..1c27968d 100644 --- a/tests/testthat/test-osm_utils.R +++ b/tests/testthat/test-osm_utils.R @@ -198,6 +198,40 @@ test_that("get_osm_relations correctly forwards custom osm_route_type parameter" ) }) +test_that("prepare_osm_file handles NULL and non-existent files", { + expect_null(GTFShift:::prepare_osm_file(NULL)) + expect_equal(GTFShift:::prepare_osm_file("non_existent.pbf"), "non_existent.pbf") +}) + +test_that("prepare_osm_file returns original path when file is in writable directory outside library", { + temp_dir <- file.path(tempdir(), "test_writable_dir") + dir.create(temp_dir, showWarnings = FALSE) + test_file <- file.path(temp_dir, "test.pbf") + file.create(test_file) + on.exit(unlink(temp_dir, recursive = TRUE), add = TRUE) + + res <- GTFShift:::prepare_osm_file(test_file) + expect_equal(res, test_file) +}) + +test_that("prepare_osm_file copies file to tempdir when directory is read-only", { + temp_dir <- file.path(tempdir(), "test_ro_dir") + dir.create(temp_dir, showWarnings = FALSE) + test_file <- file.path(temp_dir, "test_ro.pbf") + cat("test content", file = test_file) + Sys.chmod(temp_dir, "0555") + on.exit({ + Sys.chmod(temp_dir, "0755") + unlink(temp_dir, recursive = TRUE) + unlink(file.path(tempdir(), "test_ro.pbf")) + }, add = TRUE) + + res <- GTFShift:::prepare_osm_file(test_file) + expect_true(startsWith(res, tempdir())) + expect_equal(basename(res), "test_ro.pbf") + expect_true(file.exists(res)) +}) +