17 Stage Route Analysis
This chapter introduces several ideas relating to stage route analysis and the generation of “stage route profiles” that describe various features of the the route, such as its twistiness.
To a certain extent, stage route analysis could be considered as a static analysis complement to the pace notes created for a stage:
library("vembedr")
# Hayden Paddon describes pace notes
embed_url("https://www.youtube.com/watch?v=9q7EYVJL9XU")
# See also this Team O'Neil tutorial:
# "Rally Stage Notes and Pace Notes Explained" https://www.youtube.com/watch?v=earjqfXGYzw
Stage route analysis also includes a consideration of elevation along the route. Analysis and review of route elevation is explored in more detail in a separate chapter.
17.1 Load in Base Data
As ever, let’s load in our stage data and the elevation raster and create a demo map:
library(sf)
library(raster)
library(rayshader)
= 'montecarlo_2021.geojson'
geojson_filename = sf::st_read(geojson_filename) geojson_sf
## Reading layer `montecarlo_2021' from data source `/Users/tonyhirst/Documents/GitHub/visualising-rally-stages/montecarlo_2021.geojson' using driver `GeoJSON'
## Simple feature collection with 9 features and 2 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 5.243488 ymin: 43.87633 xmax: 6.951953 ymax: 44.81973
## geographic CRS: WGS 84
= geojsonio::geojson_json(geojson_sf[1,]$geometry)
stage_route_gj
# Previously downloaded TIF digital elevation model (DEM) file
= "stage_elevation.tif"
stage_tif
# Load in the previously saved image raster
= raster(stage_tif)
elev_img
# Note we can pass in a file name or a raster object
= raster_to_matrix(stage_tif) elmat
## [1] "Dimensions of matrix are: 382x565."
= elmat %>%
demo_map sphere_shade(texture = "desert",
progbar = FALSE)
Let’s also get a UTM projection of the stage routes:
# Detect the UTM zone as an EPSG code
= function(lonlat) {
lonlat2UTMzone = (floor((lonlat[1] + 180) / 6) %% 60) + 1
utm if(lonlat[2] > 0) {
+ 32600
utm else{
} + 32700
utm
}
}
= function(routes){
get_utm_projection # Keep track of the original proj4 string
= st_crs(geojson_sf[1,])$proj4string
old_crs
= st_coordinates(st_centroid(routes[1,]))[1]
sample_location_x = st_coordinates(st_centroid(routes[1,]))[2]
sample_location_y
# Generate a new projection in the appropriate UTM zone
= lonlat2UTMzone(c(sample_location_x,
crs_zone
sample_location_y))
= st_crs(crs_zone)$proj4string
new_proj4_string
# Transform the route to the UTM projection
= st_transform(geojson_sf, crs=new_proj4_string)
utm_routes
utm_routes# Or should we returned a named list
# e.g. including the original projection?
#list(utm_routes = utm_routes, orig_crs=old_crs)
}
= get_utm_projection(geojson_sf) utm_routes
In some cases, we may want to discretise a route to smaller segment lengths. We can use the sf::st_segmentize()
function cut a route described by a linestring into shorter connected segments. For example, we can discretise the route into segments lengths of just 1m long:
= st_segmentize(utm_routes[1,], units::set_units(1, m))
segmented_route_utm = st_coordinates(segmented_route_utm) segmented_route_utm_coords
Note that if we plot a discretised path, we may end up cutting corners that are described in the original route. Another side effect of discretisation is that the length of the discretised route may change.
17.2 Generating Stage Route Profiles
Although there are no R
packages (yet!) that are specifically focused on representing and analysing rally stage data, there are several packages designed to support the representation and analysis of animal tracks and route trajectories. These packages are capable of loading and analysing route co-ordinate data specified in a variety of ways and have no difficulty in working with our stage route data.
As to the question of “why bother?”, several possibilities come to mind.
In the first case, for the rally fan or rally journalist, learning a bit more about the make up of a stage may add colour when watching or reporting on a stage, or help make sense of why time appears capable of being made up, or lost, on particular stages or particular parts of a stage.
In the second case, an understanding of the detail of a particular stage may help understand strategy, or tyre selection.
In the third case, understanding stage routes may help in setting strategy, or even in planning a stage or hill climb route. With the potential for electric vehicles being used in stage rallies and for hill climbs, energy management and budgeting becomes a key issue. Ensuring that an electric vehicle has enough stored energy to allow it to complete even a single stage, let alone multiple stages with road sections in between, requires an understanding of the likely energy consumption requirements. Knowing the distances involved is one component, but elevation changes may also feed into to energy question, particularly on a a hill climb where the only way is up, and opportunities for regenerative breaking are reduced. Knowing how tortuous a stage is, and the relative mix of fast straights flat corners and slower corners may also help produce simple kinetic energy requirement models for a stage.
17.2.1 An Aside - Twists and Turns of Rally Stages
Whilst an understanding of the state of the road surface and the current weather conditions may be the key factors in determining tyre choice, how “twisty and turny” a stage route may also influence tyre selection and driving style.
The tread pattern of rally tyres are designed to support acceleration even when turning, so if tyres are wearing thin and the last stage of day has far more aggressive right corners than turns to a left, knowing the stage profile may help save a second or two.
embed_url("https://www.youtube.com/watch?v=-I74uopkcro")
17.3 Finding Stage Route Lengths
To begin with, let’s start with a very simple metric, stage route length.
We can find the length of each stage route in meters using the sf::st_length()
function:
st_length(geojson_sf)
## Units: [m]
## [1] 21069.62 21343.85 20378.19 21983.22 22858.86 18649.37 21220.66 13185.96
## [9] 14850.85
Or for a single stage:
st_length(geojson_sf[1,])
## 21069.62 [m]
# 21069.62
The rgeos::gLength()
function will generate the length of a Spatial object using its natural units. Which is to say, we’d need to project into something like UTM co-ordinates first if we wanted to use it to calculate the distance in meters.
We can also find the route length directly from a geojson string that encodes latitude and longitude locations using the turf.js like geoops::geo_line_distance()
function:
= geojsonio::geojson_json(geojson_sf[1,]$geometry)
route_as_geojson
::geo_line_distance(route_as_geojson) geoops
## [1] 21.05409
# 21.05409
The geosphere::distGeo()
function provides a way of calculating distances for each step of the route represented using the lonlat projection:
# Get the coordinates for each step
= st_coordinates(geojson_sf[1,])[,1:2]
lonlat_coords
# Omit the final NA value
= head(geosphere::distGeo(lonlat_coords), -1)
route_step_lengths
# Calculate the accumulated distance at each step
# and return the final (maximum) value:
max(cumsum(route_step_lengths))
## [1] 21069.62
# 21069.62
One thing we notice is that some of these distances are: a) different to each other; b) different to the officially stated stage distance of 20.58 km.
The projection and parameters used in a projection to calculate the distance all contribute to the actual calculated value. In addition, our distance calculations are base on summing the lengths of the multiple straight line segments described by the route data rather than the tighter (shorter) racing line. It’s also not clear from the route data where the extent of the route is the extent of the competitive portion of the stage, or whether the start and end points refer to the time control locations rather than competitive timing lines.
On the other hand, the actual route is likely to be longer than what we might term the “ground distance” as you can tell from an elevation chart that shows elevation of the route against distance into the route. If we elevation and distance axes on the same scale, the elevation line is unlikely to be a straight line: the elevation may go up or down. If we straighten out the elevation line to a flat line and place on the x-axis, it will be longer than the original “on the flat” route distance.
17.4 Using Ecological Movement Analysis Tools to Analyse Stage Routes
Ecologists researching animal movements have produced a wide range of tools for categorising and classifying the trajectories of routes followed by one or more animals. Trajectories are typically recorded as sequences of locations recorded at particular times.
The adehabitatLT
R package usefully categorises animal trajectories in the following ways:
- type I: location data is available but a sample timestamp is not precisely known or not available for each step;
- type II: as well as location data, a timestamp for each step is also available; this type is then further subdivided into:
- regular trajectories, where there is a constant delay or period between each step sample; – irregular trajectories, where there is a variable delay between each step sample.
As far as rally data goes, stage route data may be trivially classed as either type 1. However, if we use distance into route as a time proxy, we can also create type II irregular traces; telemetry data should ideally be type II regular.
One intuition behind using animal movement analysis tools to categorise rally stage routes is that we can analyse the route as if it were a type 1 style animal track. Another intuition is that we can use distance into route as a dummy time variable (e.g. use a dummy equivalence mapping of 1m into route as 1s, although we could also attempt to use proxy times, such as setting time = distance_into_stage x nominal_speed, or even use a speed model that may depend on route properties at each step of the stage).
17.5 Using trajr
for Stage Route Profiling
The trajr
R package was developed to help characterise and analyse the trajectories or paths followed by a moving animal given various location coordinates along its route.
Trajectories are created using Cartesian co-ordinates, so it makes sense to use data that has been cast into UTM coordinate system as the basis for the trajectory. A two dimensional spatial trajr
trajectory can be created by default from a two column (x/y coordinates) or three column (x/t/time) dataframe using the trajr::TrajFromCoords()
function, r from an arbitrary dataframe by specifying column names using the xCol, yCol and timeCol parameters. Spatial and temporal units can also be specified using the spatialUnits and timeUnits parameters.
library(trajr)
<- TrajFromCoords(as.data.frame(st_coordinates(utm_routes[1,]))) trj
To cast all the routes to trajectories, we could do something like the following:
= function(route){
get_trj TrajFromCoords(as.data.frame(st_coordinates(route$geometry)))
}
= apply(utm_routes, 1, get_trj) trjs
We can retrieve the stage length using the trajr::TrajLength
function:
TrajLength(trjs[[1]])
## [1] 21074.92
We can create also create a simple plot of the trajectory, and overplot it with the location points used to construct the route:
# Create a simple plot the route
plot(trj)
# Overlay points onto the plot
points(trj, draw.start.pt = FALSE,
#pch = 16, # symbol
col = "black",
cex = 0.4 # scale
)
For each fragment of the line we can calculate the distance from its displacement, or directly using trajr::TrajStepLengths
function, and then cumulatively sum them to get the overall route distance so far:
# displacement is a complex number, so we can get the actual distance:
$distance = Mod(trj$displacement)
trj
# Find the distance of the just completed step
$distance2 = c(0, TrajStepLengths(trj))
trj
# Find the distance of the upcoming step
$predist = c(TrajStepLengths(trj), 0)
trj
# Find the accumulated distance at each step
$cum_dist = cumsum(trj$distance)
trj
head(trj[,c('distance', 'distance2', 'predist', 'cum_dist')])
## distance distance2 predist cum_dist
## 1 0.00000 0.00000 22.57610 0.00000
## 2 22.57610 22.57610 27.93969 22.57610
## 3 27.93969 27.93969 21.00615 50.51579
## 4 21.00615 21.00615 20.38569 71.52194
## 5 20.38569 20.38569 21.77625 91.90762
## 6 21.77625 21.77625 39.22507 113.68388
17.5.1 Smoothing a Trajectory
Sometimes a stage route may appear rather “ragged” as an artefact of using multiple straight line segments to construct the route.
We can smooth a route with a Savitzky-Golay smoothing filter called from signal::sgolayfilt
using the trajr::TrajSmoothSGorder(order, filter_length_odd)
function.
Using a third order filter applied to a window size of 5 steps retains quite a smooth fitting line whilst removing some of the sharp edges caused by concatenating straight line segments
<- TrajSmoothSG(trj, p=3, n=5)
smoothed #p polynomial order
#n filter length (window size); must be an odd integer
plot(trj)
plot(smoothed, col = "red", add = TRUE)
The smoothr
R package also provides several useful smoothing utilities.
So that we can more clearly see what’s going on, let’s grab just a fragment of our stage route:
= function(i, steps){
get_route_segment = as.data.frame(st_coordinates(geojson_sf[i,]))[steps,]
cc
st_linestring(cbind(x=cc[,1], y=cc[,2]))
}
= get_route_segment(1, 1:10) route_segment
For example, the smoothr::smooth
function allows us to apply various smoothing approaches. Let’s see how well its ksmooth kernel smoothing approach works, that applies a Gaussian kernel regression to the x and y coordinates independently:
= smoothr::smooth(route_segment,
smoothr_route_segment method = "ksmooth")
plot(st_geometry(route_segment), col = 'grey', lwd = 3)
plot(st_geometry(smoothr_route_segment),
col = "red", add = TRUE)
Alternatively, we could use the smoothr
spline method, which calculates a spline interpolation over the x and y coordinates independently:
plot(st_geometry(geojson_sf[1,]), col = 'grey', lwd = 3)
plot(st_geometry(smoothr::smooth(geojson_sf[1,], method = "spline")),
col = "red", add = TRUE)
= smoothr::smooth(route_segment, method = "spline")
smoothr_route_segment2
plot(st_geometry(route_segment), col = 'grey', lwd = 3)
plot(st_geometry(smoothr_route_segment),
col = "red", add = TRUE)
For a more detailed look at smoothing trajectories and finding racing lines, albeit in circuit racing, see Heilmeier, A., Wischnewski, A., Hermansdorfer, L., Betz, J., Lienkamp, M. and Lohmann, B., 2019. Minimum curvature trajectory planning and control for an autonomous race car. Vehicle System Dynamics. DOI: 10.1080/00423114.2019.1631455 and the related Python laptime simulation package, TUMFTM/laptime-simulation
.
17.5.2 Resampling Locations
We can use the trajr::TrajRediscretize
function to resample locations from the line that are a fixed distance apart as the crow flies. s well as finding sample points, a new trajectory is also calculated that connects those points.
For example, if we discretise the route to points 500m apart as the crow flies, we note that the newly discretised route now has straight line segments between each of the sample points. This is perhaps not so good for stage route analysis, but it does provide one way of obtaining sample point locations from telemetry data. Ideally, though, sample points would be a fixed distance apart along the route:
# Resample to step length 500m
<- TrajRediscretize(trj, 500)
resampled plot(trj)
# Plot rediscretized trajectory in red
lines(resampled, col = "#FF0000A0", lwd = 2)
points(resampled, type = 'p', col = "#FF0000A0", pch = 16)
legend("topright", c("Original", "Rediscretized"),
col = c("black", "red"),
lwd = 2, inset = c(0.01, 0.02))
Can we see how many splits there are?
nrow(resampled)
## [1] 30
17.5.3 Resampling Against Distance
The trajr
package doesn’t seem to support resampling by distance along the line but it does allow resampling by time. So if we set the time to the accumulated basis, we can use time as a proxy for distance…
# Backup the original (dummy) timestamps
$displacementTime_ = trj$time
trj
# Use the accumulated distance as a time proxy
$time = trj$cum_dist trj
Let’s see what happens if we resample based on this use of time as a proxy for distance sampling the route every 2km:
# The time units for the route are in seconds: TrajGetTimeUnits(trj)
= TrajResampleTime(trj, 2000 )
resampled_t plot(trj)
#lines(resampled_t, col = "#FF0000A0", lwd = 2)
points(resampled_t, type = 'p', col = "#FF0000A0", pch = 16)
legend("topright", c("Original", "Resampled"),
col = c("black", "red"),
lwd = 2, inset = c(0.01, 0.02))
17.5.4 Splitting a Trajectory
We can split a trajectory using the trajr::TrajSplit()
function. This takes a trajectory and an index of split points along the path and returns a list of trajectories containing the segment starting from each of the index values to the next supplied value.
If we try to use this function to split the route by split points applied generated by discretising or sampling the route, the resolution of the original line is lost: we are not cutting the original() line at the sample points, we are cutting the new* line created from the sample points:
plot(TrajSplit(resampled_t, c(1, 2, 4))[[3]])
However, we can cut the original lines using the stplanr::route_split()
function:
#https://www.rdocumentation.org/packages/stplanr/versions/0.6.2/topics/route_split
library(stplanr)
# Get the second resampled point
= st_sfc(st_point(c(resampled_t[2,'x'],resampled_t[2,'y'])),
pt crs=st_crs(utm_routes[1,])$proj4string)
# Get the third resampled point
= st_sfc(st_point(c(resampled_t[3,'x'],resampled_t[3,'y'])),
pt2 crs=st_crs(utm_routes[1,])$proj4string)
# Plot the original route in grep
plot(utm_routes[1,]$geometry, lwd = 9, col = "grey")
# Split the route about the second point
= route_split(utm_routes[1,], pt)
split_route
# And plot the two parts
plot(split_route$geometry, col = c("red", "blue"), add = TRUE)
We can view each line segment separately:
# Use two columns to display plots side by side
par(mfrow=c(1,2))
plot(split_route[1,]$geometry)
plot(split_route[2,]$geometry)
We can automate the extraction of segmented lines using the following approach, which takes a route, splits it by the first point, splits the second half of the segment by the second point, and so on. Note that this requires us to order the points we want to split the line along in the order they appear along the route:
= function(route_, pts_) {
split_line = c()
segments for(i in 1:length(pts_)) {
= route_split(route_, pts_[i])
split_ = c(segments, split_[1])
segments = split_[2]
route_
}
route_
}
# Points we wish to segment on
= c(pt, pt2)
pts
= split_line(utm_routes[1,], pts)
split_route2
split_route2
## Simple feature collection with 3 features and 0 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 728265.7 ymin: 4957658 xmax: 732502.2 ymax: 4967115
## CRS: +proj=utm +zone=31 +datum=WGS84 +units=m +no_defs
## geometry
## 1 LINESTRING (729177.5 495765...
## 2 LINESTRING (728356.7 495918...
## 3 LINESTRING (729158.2 496066...
Now we can plot out three segment line (two split points):
plot(utm_routes[1,]$geometry, lwd = 9, col = "grey")
plot(split_route2$geometry, col = c("red", "blue", "green"), add = TRUE)
17.5.5 Rotating the Stage Trajectory
To visualise routes in a similar profile, we can use the trajr::trajr-rotate()
function to align the start and finish points. An angle parameter gives the angle in radians between the first and last points.
For example, for a horizontal, left to right right stage view:
plot(TrajRotate(trj, angle = 0, relative = TRUE))
Or for a view where we read the stage up from the bottom:
plot(TrajRotate(trj, angle = pi/2, relative = TRUE))
#Arguments
#trj The trajectory to be rotated.
#angle The angle in radians between the first and last points in th
It might be handy to rebase the coordinates of the start to (0,0).
17.5.6 Straightness and Sinuosity
Several simple measures exist that try to provide a single number measure describing something about the straightness or sinuosity of a trajectory.
For example, the trajr::TrajStraightness()
function determines straightness from the start to the end of a route as the ratio D/L, where D is the distance from the start to the end of the trajectory, and L is the length of the trajectory:
TrajStraightness(trj)
## [1] 0.4734154
We can get the straightness for each stage as follows:
'straightness'] = unlist(lapply(trjs, TrajStraightness))
utm_routes[c('Name', 'straightness')] utm_routes[,
## Simple feature collection with 9 features and 2 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 678318.2 ymin: 4865061 xmax: 817310.3 ymax: 4967115
## CRS: +proj=utm +zone=31 +datum=WGS84 +units=m +no_defs
## Name straightness geometry
## 1 SS 1 0.4734154 LINESTRING (729177.5 495765...
## 2 SS 2 0.4963787 LINESTRING (744846.5 496584...
## 3 SS 3/6 0.4385873 LINESTRING (716515.9 492960...
## 4 SS 4/7 0.3944959 LINESTRING (687221.3 493014...
## 5 SS 5 0.3839475 LINESTRING (700929.5 490648...
## 6 SS 9/11 0.4275332 LINESTRING (762930.5 492637...
## 7 SS 10 0.4886654 LINESTRING (783708.2 495029...
## 8 SS 12/14 0.5080453 LINESTRING (812970.4 487379...
## 9 SS 13/15 0.5746124 LINESTRING (803161.5 486506...
This suggests that SS 5 is perhaps the most least straight, and SS13/15 is most straight.
If reading data from tables is not your thing, here’s a chart…
library(ggplot2)
= ggplot(utm_routes) +
g1 geom_col(aes(x=as.numeric(row.names(utm_routes)),
y=straightness)) +
scale_x_discrete(limits=utm_routes$Name) + xlab('Stage')
g1
#ggpubr::ggarrange(c(g1, g2))
Let’s render them both, with a simple indicator to show where the start of each stage is.
= function(i) {
start_location as.data.frame(st_coordinates(utm_routes[i, ]$geometry))[1,]
}
= function(i, label){
plot_stage ggplot(data=utm_routes[i,]) +
geom_sf() +
geom_point(aes(x=X, y=Y), size=2,
col='red', alpha = 0.5,
data=start_location(i)) +
ggtitle(label) + coord_sf()
}
= plot_stage(5, 'SS5 - least straight' )
g_SS5
= plot_stage(9, 'SS13/15 - most straight')
g_SS13
::ggarrange(g_SS5, g_SS13) ggpubr
Note that where we have multiple split point tines and locations for a stage available, we could slice the stage into split segments and analyse the straightness of each one separately.
Another single value measure is the sinuosity, “a function of the mean cosine of the turning angle”, calculated in a corrected form that does not require constant step size by the traj::TrajSinuosity2()
function (based on equation 8 of Benhamou, S. (2004). How to reliably estimate the tortuosity of an animal’s pat, Journal of Theoretical Biology, 229(2), 209-220. doi:10.1016/j.jtbi.2004.03.016):
Let’s see how the sinuosity compares with straightness on each stage:
'sinuosity'] = unlist(lapply(trjs, TrajSinuosity2))
utm_routes[c('Name', 'straightness', 'sinuosity')] utm_routes[,
## Simple feature collection with 9 features and 3 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 678318.2 ymin: 4865061 xmax: 817310.3 ymax: 4967115
## CRS: +proj=utm +zone=31 +datum=WGS84 +units=m +no_defs
## Name straightness sinuosity geometry
## 1 SS 1 0.4734154 0.06892671 LINESTRING (729177.5 495765...
## 2 SS 2 0.4963787 0.05811054 LINESTRING (744846.5 496584...
## 3 SS 3/6 0.4385873 0.06491216 LINESTRING (716515.9 492960...
## 4 SS 4/7 0.3944959 0.06735206 LINESTRING (687221.3 493014...
## 5 SS 5 0.3839475 0.08090800 LINESTRING (700929.5 490648...
## 6 SS 9/11 0.4275332 0.06355774 LINESTRING (762930.5 492637...
## 7 SS 10 0.4886654 0.06932409 LINESTRING (783708.2 495029...
## 8 SS 12/14 0.5080453 0.07686025 LINESTRING (812970.4 487379...
## 9 SS 13/15 0.5746124 0.08885200 LINESTRING (803161.5 486506...
Again, if reading data from tables is not your thing, we can visualise how straightness and sinuosity relate on each stage using a scatterplot:
= ggplot(utm_routes, aes(x=straightness,
g2 y=sinuosity,
label = Name)) +
geom_point(size=1) +
# hjust: left justification (0), right justification (1)
geom_text(size=3, hjust = 1, nudge_y = -0.001)
g2
Under this measure we see that the most sinuous state is actually SS 13/15 and SS5 also has high sinuosity, whereas SS 2 is the least sinuous stage:
= plot_stage(2, 'SS2 - least sinuous')
g_SS2
= g_SS13 + ggtitle('SS13 - most sinuous')
g_SS13b
::ggarrange(g_SS2, g_SS13b) ggpubr
17.5.7 Directional Change
Another possible measure of how twisty a route is the directional change. As this measure is defined for each pair of steps, the directional change measure applied to a route is given as the mean (DC) and standard deviation (SDDC) calculated over all directional changes. Directional change itself is defined as the angular change (in degrees) between any two points in the trajectory, divided by the time difference between the two points. According to the trajr
vignette, DC may be used as an index of nonlinearity, and SDDC as a measure of irregularity.
The time basis of this measure means we have to use some sort of poetic license when using this measure for route analysis: do steps go up one time unit per step, or do we use the accumulated distance for time stamps?
= function(route){
calc_dc mean(TrajDirectionalChange(route))
}
= function(route){
calc_sd sd(TrajDirectionalChange(route))
}
'DC'] = unlist(lapply(trjs, calc_dc))
utm_routes['SDDC'] = unlist(lapply(trjs, calc_sd))
utm_routes[c('Name', 'DC', 'SDDC')] utm_routes[,
## Simple feature collection with 9 features and 3 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 678318.2 ymin: 4865061 xmax: 817310.3 ymax: 4967115
## CRS: +proj=utm +zone=31 +datum=WGS84 +units=m +no_defs
## Name DC SDDC geometry
## 1 SS 1 610.7077 447.9824 LINESTRING (729177.5 495765...
## 2 SS 2 462.6914 419.8097 LINESTRING (744846.5 496584...
## 3 SS 3/6 532.5554 375.9676 LINESTRING (716515.9 492960...
## 4 SS 4/7 507.2251 396.5569 LINESTRING (687221.3 493014...
## 5 SS 5 624.6447 651.9530 LINESTRING (700929.5 490648...
## 6 SS 9/11 491.8433 465.1185 LINESTRING (762930.5 492637...
## 7 SS 10 577.5148 479.2375 LINESTRING (783708.2 495029...
## 8 SS 12/14 587.3964 572.4125 LINESTRING (812970.4 487379...
## 9 SS 13/15 654.2415 689.2286 LINESTRING (803161.5 486506...
Exploring these measures with different time bases is left for another day…
17.5.8 Tracking Turning Angles
One final single value, albeit, a complex value, metric we can get is the mean vector of the turning angle, given via the trajr::TrajMeanVectorOfTurningAngle()
function.
This function takes a compass.direction (default NULL
) which indicates whether the angle should be calculated relative to the angle (in radians) or, if NULL
, relative to the previous step.
'meanTurn'] = unlist(lapply(trjs,
utm_routes[
TrajMeanVectorOfTurningAngles))
c('Name', 'meanTurn')] utm_routes[,
## Simple feature collection with 9 features and 2 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 678318.2 ymin: 4865061 xmax: 817310.3 ymax: 4967115
## CRS: +proj=utm +zone=31 +datum=WGS84 +units=m +no_defs
## Name meanTurn geometry
## 1 SS 1 0.8731026+0.0075108i LINESTRING (729177.5 495765...
## 2 SS 2 0.9155575-0.0099249i LINESTRING (744846.5 496584...
## 3 SS 3/6 0.9020143+0.0010998i LINESTRING (716515.9 492960...
## 4 SS 4/7 0.9059527+0.0056232i LINESTRING (687221.3 493014...
## 5 SS 5 0.8541612-0.0058486i LINESTRING (700929.5 490648...
## 6 SS 9/11 0.9074551+0.0013095i LINESTRING (762930.5 492637...
## 7 SS 10 0.8766840-0.0029896i LINESTRING (783708.2 495029...
## 8 SS 12/14 0.8719758+0.0011581i LINESTRING (812970.4 487379...
## 9 SS 13/15 0.8343665+0.0067231i LINESTRING (803161.5 486506...
For example, SS2 and SS13 appear to rotate in different directions?
::ggarrange(g_SS2, g_SS13) ggpubr
Hmm… maybe!
We can also take the mean vector and turn it into polar coordinates. The angle is then the mean angle of turn (Arg(meanTurn)
), although the magnitude (Mod(meanTurn)
) for the relative angle summary is perhaps less useful.
Parsing the angle in terms of the general direction of travel over the course of the route provides a potential signal regarding the general compass direction of travel. This may be relevant when trying to identify whether a driver might be driving into the sun, for example (recall that we can look up sun angle and elevation by date and time easily enough using the suncalc
R package.
The simplest calculation is probably just to calculate the bearing between the start and the end point of the route, for example using the geosphere::bearing()
function.
Alternatively we might consider a summary statistic of the amount of turn at each step along the route.
For example, we can calculate the angle of each step on a route relative to a specific compass angle, which I think(?!) has the compass angle of 0 radians for due North:
= function(route, angle=0) {
compass_relative_turn TrajMeanVectorOfTurningAngles(route, angle)
}
'meanCompass'] = unlist(lapply(trjs,
utm_routes[
compass_relative_turn))
c('Name', 'meanCompass')] utm_routes[,
## Simple feature collection with 9 features and 2 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 678318.2 ymin: 4865061 xmax: 817310.3 ymax: 4967115
## CRS: +proj=utm +zone=31 +datum=WGS84 +units=m +no_defs
## Name meanCompass geometry
## 1 SS 1 0.1242776+0.3915590i LINESTRING (729177.5 495765...
## 2 SS 2 -0.0206872-0.4660270i LINESTRING (744846.5 496584...
## 3 SS 3/6 -0.3787080+0.0886329i LINESTRING (716515.9 492960...
## 4 SS 4/7 -0.4013541+0.0010163i LINESTRING (687221.3 493014...
## 5 SS 5 0.3798902+0.1763371i LINESTRING (700929.5 490648...
## 6 SS 9/11 -0.0243449-0.3751122i LINESTRING (762930.5 492637...
## 7 SS 10 -0.0341099+0.4008367i LINESTRING (783708.2 495029...
## 8 SS 12/14 0.2787954-0.4566956i LINESTRING (812970.4 487379...
## 9 SS 13/15 0.0876097+0.5542382i LINESTRING (803161.5 486506...
In this case the angle will give the average compass direction of travel and the modulus may give us an indication of the straightness of travel in that direction?
So… do SS2 and SS6 appear to go in different directions?
= plot_stage(2, 'SS2 - one way..')
g_SS2
= plot_stage(5, 'SS5 - and another?')
g_SS5
::ggarrange(g_SS2, g_SS5) ggpubr
And do SS9 and SS11 appear to go in roughly the same direction?
= plot_stage(6, 'SS9 - and the same?')
g_SS9
::ggarrange(g_SS2, g_SS9) ggpubr
17.5.9 Visualising Trajectory Direction Changes
As well as providing single number metrics, we can also get information back at the level of each step of the trajectory. We have already seen how the complex displacement is given for each step, from which we can calculate the distance of each step.
We can also get access to the angle of each step in a route using the trajr::TrajAngles()
function. This function also takes a compass.direction, again indicating whether the angle should be calculated relative to the angle (in radians) or, if NULL
, relative to the previous step.
# Step angle in radians relative to previous
$stepangle = c(0, TrajAngles(trj, compass.direction = NULL) * 180 / pi, NA)
trj
$cumstepangle = cumsum(c(0, TrajAngles(trj, compass.direction = NULL) * 180 / pi, NA))
trj$stepheading = c(TrajAngles(trj, compass.direction = 0)* 180 / pi, NA)
trj
head(trj[,c('stepangle', 'cumstepangle', 'stepheading')], 5)
## stepangle cumstepangle stepheading
## 1 0.0000000 0.0000000 64.44197
## 2 0.6145542 0.6145542 65.05653
## 3 21.1370427 21.7515969 86.19357
## 4 36.9688081 58.7204050 123.16238
## 5 43.4834868 102.2038918 166.64587
We can plot the route using colour to give the general heading, by quadrant:
# Cut styling via https://stackoverflow.com/a/29966785/454773
ggplot(data=tidyr::drop_na(trj, stepheading ),
aes(x=x, y=y, color=col)) +
geom_point(aes(colour = cut(stepheading,
c(-180, -90, 0, 90, 180))),
size = 1) +
coord_sf() +
geom_point(aes(x=X, y=Y), size=1, col='black',
data=start_location(1))
Can we identify tight turns? The following chart plots each step location, using a distinct marker to highlight turns with a large stepangle:
ggplot(data=trj,
aes(x=x, y=y), size=0.5) + geom_path(color='grey')+
geom_point( pch=0, color='blue',
data=trj[abs(trj$stepangle)>45,]) +
geom_point(data=trj[abs(trj$stepangle)<=45,],
pch=1, color='red', size=0.1) +
coord_sf() +
geom_point(aes(x=X, y=Y), size=1, col='black',
data=start_location(1))
The instantaneous angle is okay for trying to identify particularly tight turns, but to identify whether a corner increase the angle in the same step direction, which is to say, whether the corner tightens, for example, we need process the data a bit further.
One way of doing this is to determine the gradient of the accumulated angle using the pracma::gradient
function.
$step_gradient = pracma::gradient(trj$cumstepangle, trj$cum_dist) trj
The sign of the gradient then identifies the turn direction but the magnitude of the gradient is harder to parse because the step sizes are not constant.
A plot using colour to depict gradient sign may help identify whether a route involves turn predominantly to the left or to the right. We could use a related approach to display a table of locations labeled as tight right or left corners, for example:
ggplot(data=trj,
aes(x=x, y=y)) + geom_path(color='grey')+
geom_point( size=0.2, color='blue',
data=trj[trj$step_gradient>0.2,]) +
geom_point(size=0.2,
data=trj[trj$step_gradient<=-0.2,],
color='red') +
coord_sf() +
geom_point(aes(x=X, y=Y), size=1, col='black',
data=start_location(1))
If nothing else, however, this approach also gives us an immediate way of detecting stationary points in the gradient. As a consequence, we can identify directional changes from left to right, or vice versa.
ggplot(trj,aes(x=cum_dist)) +
geom_hline(yintercept=0, color='grey' ) +
geom_hline(yintercept=-500, color='grey' ) +
geom_line(aes( y=cumstepangle)) +
geom_line(aes( y=-500+50 * step_gradient),
color='red')
By creating simple rules, we might be able to identify features like tightens (for example, if we have three angles in the same direction with increasing magnitude and the final angle exceeding some threshold limit). We might similarly be able to create rules around elevation data, for example to identify crests (for example, a maximum in elevation, found by finding stationary points of elevation / first derivative).
Finally, it’s worth briefly mentioning a couple more trajr
functions that may be useful when it comes to analysing telemetry data: trajr::TrajVelocity
and trajr:TrajAcceleration
, which estimate velocity and acceleration as vectors at each point along a trajectory. But that’s for another book…
17.6 Using amt
for Stage Route Profiling
The amt
(animal movement tools) R package was designed to support the management and analysis of animal movement data. Routes are represented as tracks in the form of tibbles with at least an x_ and y_ column and, if required, a t_
(time) column. Consecutive rows, which might be assumed to be collected with fixed period, identify straightline steps. A track_xy
track type only has coordinates, and a
track_xyt
track type has a timestamp associated to each coordinate pair.
An additional burst column, burst_
, provides us with a potential grouping parameter that lets us perform analyses on sections of the track. This could be used to identify segments of the route between split points, for example, if we can apply appropriate burst labels to each part of the track.
For our purposes here, it is clear to see how we might imagine a mapping the X and Y co-ordinates from a route linestring in a UTM/meters based projection directly into a a track tibble. The amt::make_track()
function provides a straightforward way of doing this:
library(amt)
= make_track(st_coordinates(utm_routes[1,]), X, Y) amt_track
## .t missing, creating `track_xy`.
amt_track
## # A tibble: 419 x 2
## x_ y_
## * <dbl> <dbl>
## 1 729177. 4957658.
## 2 729187. 4957678.
## 3 729199. 4957703.
## 4 729200. 4957724.
## 5 729189. 4957741.
## 6 729168. 4957746.
## 7 729129. 4957744.
## 8 729102. 4957753.
## 9 728952. 4957845.
## 10 728928. 4957870.
## # … with 409 more rows
17.6.1 Creating Tracks
The make_track
function has a further optional argument (crs
), which allows the user to set a coordinate reference system (CRS) of the track. The CRS needs to be provided as valid proj4string, see the documentation of sp::CRS for further details; see also proj4 projections.
We can segment the track into a series of straightline steps from one point to the next, also returning the step distance (sl
) and angle (direction_p
radians):
# Create a "steps" object from each sample
= steps(amt_track)
amt_steps head(amt_steps)
## # A tibble: 6 x 7
## x1_ x2_ y1_ y2_ sl_ direction_p ta_
## * <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 729177. 729187. 4957658. 4957678. 22.6 NA NA
## 2 729187. 729199. 4957678. 4957703. 27.9 1.12 0.0107
## 3 729199. 729200. 4957703. 4957724. 21.0 1.14 0.369
## 4 729200. 729189. 4957724. 4957741. 20.4 1.50 0.645
## 5 729189. 729168. 4957741. 4957746. 21.8 2.15 0.759
## 6 729168. 729129. 4957746. 4957744. 39.2 2.91 0.288
17.6.2 Converting to Other Track Formats
The amt
package provides a range of tools that allow us to coerce amt
tracks to other movement analysis packages.
For example, we can cast a track_xy
object to a Spatial object:
as_sp(amt_track)
## class : SpatialPoints
## features : 419
## extent : 728265.7, 732502.2, 4957658, 4967115 (xmin, xmax, ymin, ymax)
## crs : NA
Conversions to other movement data structures is possible from track_xyt
objects.
17.7 Using amt
for Stage Route Analysis
We can also obtain step lengths directly from the track via the amt::step_lengths()
function:
# Obtain the step lengths and omit / drop the final NA value
= head(step_lengths(amt_track), -1) amt_step_lengths
Directions can be determined absolutely, e.g. with reference to a North, South, East or West basis, using amt::direction_abs()
or relatively to the previous step using `amt::direction_rel()
:
= direction_abs(amt_track)
absolute_step_directions = direction_rel(amt_track) relative_step_directions
In terms of route complexity measures, the amt
package provides functions for calculating single number metrics of sinuosity (amt::sinuosity()
),
straightness (amt::straightness()
), accumulated track distance (amt::cum_dist()
) and total straightline distance (amt::tot_dist()
). The intensity use metric is calculated by dividing the total movement distance (tot_dist
) by the square of the area of movement:
::sinuosity(amt_track) amt
## [1] 0.001697952
::straightness(amt_track) amt
## [1] 0.4734154
::cum_dist(amt_track) amt
## [1] 21074.92
::tot_dist(amt_track) amt
## [1] 9977.189
::intensity_use(amt_track) amt
## [1] 4.710567
Let’s generate a list of tracks, one for each stage:
= function(route){
to_amt_track make_track(st_coordinates(route$geometry), X, Y)
}
= apply(utm_routes, 1, to_amt_track) amt_tracks
Now we can generate reports for all the stages:
'amt_sin'] = unlist(lapply(amt_tracks, amt::sinuosity))
utm_routes['amt_str'] = unlist(lapply(amt_tracks, amt::straightness))
utm_routes['amt_cumd'] = unlist(lapply(amt_tracks, amt::cum_dist))
utm_routes['amt_totd'] = unlist(lapply(amt_tracks, amt::tot_dist))
utm_routes['amt_int'] = unlist(lapply(amt_tracks, amt::intensity_use))
utm_routes[
c('amt_sin','amt_str','amt_cumd','amt_totd','amt_int')] utm_routes[,
## Simple feature collection with 9 features and 5 fields
## geometry type: LINESTRING
## dimension: XY
## bbox: xmin: 678318.2 ymin: 4865061 xmax: 817310.3 ymax: 4967115
## CRS: +proj=utm +zone=31 +datum=WGS84 +units=m +no_defs
## amt_sin amt_str amt_cumd amt_totd amt_int
## 1 0.001697952 0.4734154 21074.92 9977.189 4.710567
## 2 0.003279775 0.4963787 21350.64 10598.001 3.449856
## 3 0.001161548 0.4385873 20381.31 8938.984 4.135712
## 4 0.005593607 0.3944959 21983.48 8672.392 4.697379
## 5 0.001731603 0.3839475 22861.47 8777.607 7.097515
## 6 0.002622528 0.4275332 18657.68 7976.777 4.770509
## 7 0.003586272 0.4886654 21233.21 10375.937 4.468135
## 8 0.002778324 0.5080453 13196.82 6704.582 4.362178
## 9 0.003312796 0.5746124 14861.89 8539.827 5.504767
## geometry
## 1 LINESTRING (729177.5 495765...
## 2 LINESTRING (744846.5 496584...
## 3 LINESTRING (716515.9 492960...
## 4 LINESTRING (687221.3 493014...
## 5 LINESTRING (700929.5 490648...
## 6 LINESTRING (762930.5 492637...
## 7 LINESTRING (783708.2 495029...
## 8 LINESTRING (812970.4 487379...
## 9 LINESTRING (803161.5 486506...
We can resample locations from the track using amt::track_resample()
. Once again, if we use distance as a proxy for time, this allows us to sample by distance along the line.
17.8 Using rLFT
for Stage Route Profiling
The rLFT
(Linear Feature Tools) R package was developed in order to support the processing of simple linear features.
The boundary convexity tool function, rLFT::bct()
generates a convexity measure for a sliding window of points along a route; the route must be provided in a projected (not geographic) coordinate system. The sign of the convexity measure identifies whether the curve is concave (negative value, left turn) or convex (positive value, right turn), the increasing values identifying tightness of the curve. A normalised convexity index and a sinuosity measure are also created.
The bct()
function takes two important arguments:
- the step, which describes the distance between measurements along the route (i.e. the distance between ample points), and
- the window, which describes the size of the window (the distance along the route) used to measure the convexity value.
library(rLFT)
<- bct(utm_routes[1,],
route_convexity # distance between measurements
step = 10,
window = 20, ridName = "Name")
## user system elapsed
## 0.169 0.032 0.201
## [1] "Features skipped due to size: "
## logical(0)
head(route_convexity)
## FID RID MidMeas WindowSize RawConvexity ConvexityIndex Sinuosity Midpoint_X
## 1 1 SS 1 10 20 0.000 0.000 0.500 729181.8
## 2 1 SS 1 20 20 -0.040 -0.004 0.500 729186.1
## 3 1 SS 1 30 20 -0.014 -0.001 0.500 729190.4
## 4 1 SS 1 40 20 0.000 0.000 0.500 729194.6
## 5 1 SS 1 50 20 -1.739 -0.174 0.509 729198.8
## 6 1 SS 1 60 20 -0.093 -0.009 0.501 729199.6
## Midpoint_Y
## 1 4957667
## 2 4957676
## 3 4957685
## 4 4957694
## 5 4957703
## 6 4957713
We can get the output table as a simple features object where the midpoint co-ordinates are cast to a point geometry type using the sf:st_as_sf()
function:
<- st_as_sf(route_convexity,
outSF coords = c("Midpoint_X", "Midpoint_Y"),
stringsAsFactors = FALSE)
We note that by sampling the actual route at 10m intervals when calculating curvature and then recreating a route from those 10m sample points, we may actual crop the end of tight corners as represented by the original route path. Reducing the step size would provide a more faithful route (recall, the window size is actual used in performing the convexity calculation). Arguably, it might make sense to use generate a smoothed route from the original route and then use a small step size over the smoothed route as the basis for all route based metrics.
The convexity measures are defined in Measuring boundary convexity at multiple spatial scales using a linear ‘moving window’ analysis: an application to coastal river otter habitat selection, Albeke, S.E. et al., Landscape Ecology 25 (2010): 1575-1587.
17.8.1 Convexity Profile of a Stage Route
We can profile the convexity of a stage route in the terms of the convexity against the distance along the route in terms of a simple line chart. Recall that the sign of the convexity value denotes the direction of a corner (negative left, positive right).
We can add a grey bar with low alpha transparency to slightly mask the less sever corners:
ggplot(route_convexity) +
geom_bar(aes(x=MidMeas, y=ConvexityIndex, col=(ConvexityIndex>0)),
stat='identity', show.legend = FALSE) +
geom_rect(ymin = -0.1, ymax = 0.1,
xmin = -Inf, xmax = Inf,
fill = 'lightgrey', alpha=0.05)
17.8.2 Previewing Significant Corners
By filtering the table to rows with with a large absolute ConvexityIndex we can identify and label tight corners. The ggrepel::geom_text_repel()
function attempts to minimise the extent to which labels overlap, using arrows to identify points referenced by the label where the label location is dodged away from the point:
= route_convexity[abs(route_convexity$ConvexityIndex)>0.45,]
tight_corners
ggplot() +
geom_sf(data=utm_routes[1,]) +
::geom_text_repel(data=tight_corners,
ggrepelaes(label = ConvexityIndex,
x=Midpoint_X, y=Midpoint_Y,
color= (ConvexityIndex>0) ),
size=2,
nudge_x = 2000, nudge_y = 500) +
geom_point(data=tight_corners,
aes(x=Midpoint_X, y=Midpoint_Y,
color= (ConvexityIndex>0) ), size=1) +
theme_classic()
17.8.3 Convexity as a 3D Plot
To try to get a feel for how the convexity changes along a route, we can render a 3D plot of the of the convexity using the rayhader::ggplot()
function.
As a first step, we need to create a base ggplot2 plot:
= ggplot() + geom_sf(data=utm_routes[1,]) +
convexity_gg geom_point(data=route_convexity, show.legend = FALSE,
size=1,
aes(x=Midpoint_X, y=Midpoint_Y,
color=abs(ConvexityIndex)))
convexity_gg
Then we can render it in three dimensions:
library(rayshader)
library(rgl)
::clear3d()
rglplot_gg(convexity_gg, scale=1000, raytrace = FALSE)
::rglwidget() rgl
Looking at these metrics over a complete route can be a little hard to decipher. However, if we segment the route into smaller sections, as we shall see in the next chapter, we will see how can start to use these metrics to describe the route in some useful detail.
17.9 Finding Curvature of a Route
The pracma::circlefit()
function will attempt to calculate the center and radius of a curvature from an arc defined by a path segment described by three or more consecutive locations provided as x and y coordinate lists. We can use the radius of curvature to determine corner severity numbers that are typically used in rally pace notes.
For example, we can build on the the rLFT
segmented route to add the radius of curvature of turns starting at each midpoint:
# The curvature function takes an arc defined over
# x and y coordinate lists
= function(x,y){
curvature #729181.8, 729186.1, 729190.4
#4957667 , 4957676, 4957685
tryCatch({
# circlefit gives an error if we pass a straight line
# Also hide the print statement in circlefit
# circlefit() returns the x and y coords of the circle center
# as well as the radius of curvature
# We could then also calculate the angle and arc length
::circlefit(x,y)[3]
pracma
},error = function(err) {
# For a straight, return the first co-ord and Inf diameter
# Alternatively, pass zero diameter?
c(x[1], y[1], Inf)[3]})
}
= function(x1, x2, x3, y1, y2, y3){
curvature2 curvature(c(x1, x2, x3), c(y1, y2, y3))
}
# The base::Vectorize function provides a lazy way of
# vectorising a non-vectorised function
= Vectorize(curvature2)
curvatures
# Tests
#curvature(c(729181.8,729186.1,729195.4),c(4957667,4957676,4957685))
#curvature(c(729194.6,729198.8,729199.6),c(4957694,4957703,4957713))
Let’s find the radius of curvature for each 10m sampled point we obtained when finding convexity values along the route:
library(dplyr)
library(pracma)
$radius = curvatures(lag(route_convexity$Midpoint_X),
route_convexity$Midpoint_X,
route_convexitylead(route_convexity$Midpoint_X),
lag(route_convexity$Midpoint_Y),
$Midpoint_Y,
route_convexitylead(route_convexity$Midpoint_Y)
)
Rally pace note systems tend to use a simpler numbering system to represent curvature which may be modeled by mapping the radius of curvature values to a discrete set of binned corner numbers. The base::cut()
R function allows us to perform this mapping using custom bin widths and ordered bin labels.
In the following example, we use the 10 point turn severity scale and associated radius of curvature values suggested in To see the invisible ( (Volume 1); low values represent smaller radius of curvature values and hence tighter corners. Note that we further are labeling straights as category 11 curves.
= c(0, 10, 15, 20, 27.5, 35,
invisible_bins 45, 60, 77.5, 100, 175, Inf)
$invisible_ts = cut(route_convexity$radius,
route_convexitybreaks = invisible_bins,
labels = 1:(length(invisible_bins)-1),
ordered_result=TRUE)
Let’s preview the values:
head(route_convexity[,c('ConvexityIndex',
'radius', 'invisible_ts')])
## ConvexityIndex radius invisible_ts
## 1 0.000 Inf 11
## 2 -0.004 Inf 11
## 3 -0.001 Inf 11
## 4 0.000 Inf 11
## 5 -0.174 28.64928 5
## 6 -0.009 534.95134 11
We can visualise the turn severity values at each 10m sample point along the route:
= route_convexity$radius>0 & route_convexity$radius<100
radius_filter
ggplot() + geom_sf(data=utm_routes[1,], color='grey') +
geom_point(data=route_convexity[radius_filter,],
aes(x=Midpoint_X, y=Midpoint_Y,
color=invisible_ts), size=0.5)
17.9.1 Percentage in Turn
A really crude metric of how severe the amount of turning is over the duration of a stage might be indicated by the root mean square of the curvature:
= function(x) sqrt(mean(x^2))
rms
rms(route_convexity$ConvexityIndex)
## [1] 0.09470864
Another possible measure would be the percentage of time in turn, relative to a minimum amount of curvature (or maximum radius of curvature).
For example, we can count the number of segments for which the convexity index is above a particular threshold:
= function(route, min_convexity=0.1) {
pc_in_turn_convexity sum(route$ConvexityIndex > min_convexity) / nrow(route)
}
pc_in_turn_convexity(route_convexity)
## [1] 0.07451353
Alternatively, we might calculate the percentage of segments for which the radius of curvature is less than some maximum value:
= function(route, max_curvature=80) {
pc_in_turn_curvature sum(route$radius < max_curvature) / nrow(route)
}
pc_in_turn_curvature(route_convexity)
## [1] 0.177029
Another more segmented profile could give the percentage time per turn severity:
= route_convexity %>%
turn_severity group_by(invisible_ts) %>%
summarise(n = n())
$n = turn_severity$n / nrow(route_convexity) turn_severity
For the non-straight sections, we can plot their distribution:
ggplot(turn_severity[turn_severity$invisible_ts < 11,]) +
geom_bar(aes(x=invisible_ts, y=n),
stat='identity')
We can also compare the percentage of segments spent on the straight against the percentage involved in some sort of turn:
= route_convexity %>%
pc_in_turn # Create a logical group
group_by(in_turn = invisible_ts<11) %>%
summarise(n = n())
$n = pc_in_turn$n / nrow(route_convexity)
pc_in_turn
ggplot(pc_in_turn) +
geom_bar(aes(x=in_turn, y=n),
stat='identity')
17.10 Estimating Speeds
As well as providing an estimate of turn severity and identifying straights, we can also use the radius of curvature, or the turn severity index, as a crude estimate of maximum speed along that segment. Once again volume 1 of the To see the invisible pacenotes tutorial provides some example speeds for each turn intensity value, noting that speeds are likely different on sealed tarmac surfaces or unsealed gravel surfaces:
# Speeds in km/h
= c(10, 40, 50, 60, 70, 80,
invisible_speeds 95, 1100, 120, 130, 145)
We can use these speeds to model (very crudely!) expected travel times along different parts of the route, or at least provide a crude baseline estimate, as well as a crude estimate over the overall average route speed, which is often regulated under rally competition regulations.
$invisible_sp = cut(route_convexity$radius,
route_convexitybreaks = invisible_bins,
labels = invisible_speeds,
ordered_result=TRUE)
# Cast speed as factor, via character, to integer
$invisible_sp = as.integer(as.character(route_convexity$invisible_sp))
route_convexity
head(route_convexity[,c('ConvexityIndex', 'radius',
'invisible_ts', 'invisible_sp')])
## ConvexityIndex radius invisible_ts invisible_sp
## 1 0.000 Inf 11 145
## 2 -0.004 Inf 11 145
## 3 -0.001 Inf 11 145
## 4 0.000 Inf 11 145
## 5 -0.174 28.64928 5 70
## 6 -0.009 534.95134 11 145
From the speed estimates, can we then perform a crude time estimate for the stage time, assuming infinite acceleration and deceleration? (This may turn out to be not very realistic at all, of course!)
For each segment, identify the segment transit time as the length of the segment divide by the speed:
$invisible_time = 10/ (route_convexity$invisible_sp * 1000 /3600)
route_convexity
= sum(route_convexity$invisible_time)
duration paste0(duration %/% 60, 'm', round(mod(duration, 60),1), 's' )
## [1] "11m5s"
#11m5s
# Actual stage time: 12:05.7
# via https://www.ewrc-results.com/results/41079-rallye-automobile-de-monte-carlo-2021/?s=293051
# Difference: (11*60+5) / (12 * 60 + 5) = 0.917 so ~9% error?
A better approach would be to try to model acceleration and evolve the speed across each segment.
A crude acceleration model might look to accelerate up into a faster segment, and decelerate down into a slower one:
= route_convexity$invisible_sp
sp 1] = 30 # nominal starting speed
sp[
# Crude acceleration / brake weights
= 1
acc = 1
dec for (i in 2:(length(sp)-1)) {
# Accelerate up
if (sp[i-1]<=sp[i]) sp[i] = (sp[i-1] + acc * sp[i]) / (1+acc)
# Decelerate down
if (sp[i]>sp[i+1]) sp[i] = (dec * sp[i] + sp[i+1]) / (1+dec)
}
$acc_sp = sp
route_convexity$acc_sp[length(sp)] = route_convexity$invisible_sp[length(sp)]
route_convexity
head(route_convexity[,c('invisible_sp','acc_sp')])
## invisible_sp acc_sp
## 1 145 30.0000
## 2 145 87.5000
## 3 145 116.2500
## 4 145 100.3125
## 5 70 70.0000
## 6 145 78.7500
And the new time model?
$invisible_time2 = 10/ (route_convexity$acc_sp * 1000 /3600)
route_convexity
= sum(route_convexity$invisible_time2)
duration2 paste0(duration2 %/% 60, 'm', round(mod(duration2, 60),1), 's' )
## [1] "12m9.1s"
# 12m9.1s
# Difference: (12*60+9) / (12 * 60 + 5) = 1.006 so minimal % error?