This article focuses on the best wind direction to cycle a Strava segment.2 Finding the best tailwind. Clearly, on an exposed, arrow-straight segment, the most favorable weather would be a hurricane tailwind. Because segment start and end points have recently become available in CSV format, one might wonder: how accurate is the wind direction calculated using a straight line between the two points?3
Suppose your weather app predicts strong winds, and you want to cycle as fast as possible on certain segments with the wind at your back, then you can use the Strava segment explore feature to find the right segments and add them to the route planner (https://www.strava.com). But if you like using your own data, you can go one step further. VeloViewer offers the option to download your own segment data and then enrich it yourself with the ideal wind direction. If strong winds are forecast, you can put together your route by selecting segments from your own data file (https://www.veloviewer.com).4
In this writing we use a 32-point compass rose, constructed by bisecting the angles of the principal winds to come up with intermediate compass points, at angles of difference of 111⁄4°.
Figure 1. The compass rose
In 2014 I started downloading Strava segment information. The data
was enriched with the best wind direction to cycle the segment as fast
as possible. This was done by viewing each segment on the laptop screen
and determining the best wind direction using a compass rose (Figure 1).
The advantage of visual coding is that you can take into account local
conditions, such as vegetation, buildings, waterways, and the terrain.
These all affect the wind’s speed and direction.
With the weather
forecast in mind, I mapped out a route with the right segments using https://www.plotaroute.com/routeplanner. I sometimes
felt like a storm chaser. Not in order to investigate any severe weather
phenomenon, but as a KOM hunter.
After about five years I stopped,
having coded approximately 4,800 segments in the northern part of the
Netherlands in this way.5 The map shows the area in
question.
Map 1. Where is Groningen?
in February 2024, VeloViewer started providing latitude and longitude of both ‘start’ and ‘end’ segment coordinates.6 A few things crossed my mind: How accurate did I code the ideal wind direction of those segments back then? Is wind direction calculated using two points a good alternative? Or should we use multiple points?
To answer these questions, two analyses
were done.
1. A comparison of visually coded wind direction and the
calculated variant based on start and end points of segments.
2.
Comparing calculates wind direction based on two points versus multiple
points.
Two data sets were used.
The
first is a set of 4,728 Strava segments, downloaded from
VeloViewer, to which in the past I visually estimated and added the wind
direction for each segment. The data could recently be supplemented with
the two-point wind direction.
The second dataset consists
of all climbs in the Netherlands that were searched for on the
Climbfinder site, a total of 1,256 (https://www.climbfinder.com). The GPX file was
downloaded for each climb, as well as properties such as altimeters and
gradient.7 The GPX data makes it possible to determine
the desired wind direction using start and end points on the one hand
and multiple points on the other.
The manual or ‘visually’ method used the compass rose shown above. Determination while keeping the rose close to the laptop screen displaying the segment on a map. The image below - Figure 2 - shows the resulting number of segments for each wind direction. Actually, it’s a frequency distribution in the shape of a compass rose. Apparently the four cardinal wind directions were favored for coding, followed by two diagonals (SW-NE and NW-SE).
Figure 2.
Next, we add the Strava segment start and end coordinates, provided by VeloViewer, in order to calculate wind direction and straight-line distance with some necessary trigonometry.
Below is the R code to read the VeloViewer segment data.
version[['version.string']]
## [1] "R version 4.3.2 (2023-10-31 ucrt)"
library(openxlsx)
library(tidyverse)
vv_files <- "data/vv_download/" # Folder for VeloViewer files.
# Read segments.csv and replace all white spaces in the column name with underscore
# to make R referral to column names easier.
# Select your favorite items.
# Format DateTime in segments export: e.g. "Sat Apr 25 2020 16:20:37 UTC"
# the function mdy_hms converts these strings to R datetime object.
# Duplicated columns in VeloViewer source file: "Segment Id" at locations 52 and 58
# name repair will ensure column names are "unique"
segments <- read_csv(file = paste0(vv_files, "segments.csv"), show_col_types = FALSE, name_repair = "unique") %>%
rename_all(function(x) gsub(" ", "_", x)) %>%
filter(Type == 'Ride') %>%
select(last_col(), Name, City, State, Dist_km, "Grade_%", Elv_change_m, Total, Pos, Pos_Score, Elapsed_Time, When,
KOM_Time, Behind_Leader, Tries, "Speed_km/h", Start_latitude, Start_longitude, End_latitude, End_longitude) %>%
rename(Segment_Id = (head(names(.), 1)),
Speed_kmh = "Speed_km/h",
Seconds_behind_leader = Behind_Leader,
Grade = "Grade_%",
n_Riders = "Total") %>%
mutate(Dist_km = round(Dist_km/1000,3),
Speed_kmh=Speed_kmh * 3.6,
Speed_kmh_check = round((Dist_km / Elapsed_Time)*3600,1),
Speed_kmh_KOM = round((Dist_km / KOM_Time)*3600,1),
Grade = round(Grade,1),
Speed_kmh = round(Speed_kmh,1),
Elv_change_m = round(Elv_change_m,0),
Status = ifelse(Pos == 0, "flagged/hidden", "active"),
Pos = replace(Pos, Pos == 0, NA),
Pos_Score = replace(Pos_Score, Pos_Score == 0, NA),
When = mdy_hms(When),
KOM = case_when(Pos == 1 & n_Riders > 1 ~ TRUE, !(Pos == 1 & n_Riders > 1) ~ FALSE),
Segment_url = paste0("https://www.strava.com/segments/", Segment_Id)) %>%
filter(Status == 'active'&
KOM_Time != 0 &
Dist_km >= 0.250 &
!KOM &
Speed_kmh_KOM < 99 &
!(Speed_kmh_KOM > 70 & Grade > -0.15)) %>%
select (-Status)
# Function to calculate distance between start and end points.
distance_between_two_points <- function(lat1, lon1, lat2, lon2) {
theta <- lon2 - lon1
d1 <- (sin((atan(1)/45) * lat2) * sin((atan(1)/45) * lat1)) + (cos((atan(1)/45) * lat2) * cos((atan(1)/45) * lat1) * cos((atan(1)/45) * theta))
d2 <- 2*atan(1) - asin(d1)
d2[is.na(d2)] <- 0
d3 = (45/atan(1))*(d2)
distmile = d3 * 60 * 1.1515
distkm = distmile * 1.609344
distance_meter = distkm * 1000
distance_km <- round(distance_meter / 1000,3)
return(data.frame(distance_km))
}
# Adding straight distance in km to working file.
segments <- cbind(segments,
distance_between_two_points (segments$Start_latitude, segments$Start_longitude,segments$End_latitude,segments$End_longitude )) %>%
rename(Dist_km_straight_line = distance_km) %>%
mutate (Ratio_straight_line_real_distance = round(Dist_km_straight_line / Dist_km,2))
# Function to get wind direction between two points in 32 headings
# arctan result in radians.
# 1 radial = 57.2957795 degrees.
# quadrant wind
# --------------------------
# 4 1 SE SW
# 3 2 NE NW
# quadrant 1 = top right side
# 2 = bottom right
# 3 = bottom left
# 4 = top left
# Cut off degrees to achieve 32 categories.
x1 <- 0.000000
x2 <- 5.625000
x3 <- 16.875000
x4 <- 28.125000
x5 <- 39.375000
x6 <- 50.625000
x7 <- 61.875000
x8 <- 73.125000
x9 <- 84.375000
y1 <- 5.624999
y2 <- 16.874999
y3 <- 28.124999
y4 <- 39.374999
y5 <- 50.624999
y6 <- 61.874999
y7 <- 73.124999
y8 <- 84.374999
y9 <- 90.000000
winddirection_between_two_points <- function(lat1, lon1, lat2, lon2) {
londis <- (lon2 - lon1) * 68
latdis <- (lat2 - lat1) * 111
tan_angle <- latdis/londis
angle_degrees <- atan(tan_angle) * 57.2957795
quadrant = ifelse((lon2 < lon1) & (lat2 < lat1),3,
ifelse((lon2 < lon1) & (lat2 >= lat1),4,
ifelse((lon2 >= lon1) & (lat2 < lat1),2,
ifelse((lon2 >= lon1) & (lat2 >= lat1),1,NA))))
wind_cardinal = ifelse(quadrant == 1, 'SW',
ifelse(quadrant == 2, 'NW',
ifelse(quadrant == 3, 'NE',
ifelse(quadrant == 4, 'SE',NA))))
Winddirection = ifelse(quadrant == 1 & angle_degrees >= x1 & angle_degrees < y1, 'W ',
ifelse(quadrant == 1 & angle_degrees >= x2 & angle_degrees < y2, 'W,WSW ',
ifelse(quadrant == 1 & angle_degrees >= x3 & angle_degrees < y3, 'WSW ',
ifelse(quadrant == 1 & angle_degrees >= x4 & angle_degrees < y4, 'SW,WSW',
ifelse(quadrant == 1 & angle_degrees >= x5 & angle_degrees < y5, 'SW ',
ifelse(quadrant == 1 & angle_degrees >= x6 & angle_degrees < y6, 'SW,SSW',
ifelse(quadrant == 1 & angle_degrees >= x7 & angle_degrees < y7, 'SSW ',
ifelse(quadrant == 1 & angle_degrees >= x8 & angle_degrees < y8, 'S,SSW ',
ifelse(quadrant == 1 & angle_degrees >= x9 & angle_degrees <= y9, 'S ',
ifelse(quadrant == 3 & angle_degrees >= x1 & angle_degrees < y1, 'E ',
ifelse(quadrant == 3 & angle_degrees >= x2 & angle_degrees < y2, 'E,ENE ',
ifelse(quadrant == 3 & angle_degrees >= x3 & angle_degrees < y3, 'ENE ',
ifelse(quadrant == 3 & angle_degrees >= x4 & angle_degrees < y4, 'NE,ENE',
ifelse(quadrant == 3 & angle_degrees >= x5 & angle_degrees < y5, 'NE ',
ifelse(quadrant == 3 & angle_degrees >= x6 & angle_degrees < y6, 'NE,NNE',
ifelse(quadrant == 3 & angle_degrees >= x7 & angle_degrees < y7, 'NNE ',
ifelse(quadrant == 3 & angle_degrees >= x8 & angle_degrees < y8, 'N,NNE ',
ifelse(quadrant == 3 & angle_degrees >= x9 & angle_degrees <= y9, 'N ',
ifelse(quadrant == 2 & abs(angle_degrees) >= x1 & abs(angle_degrees) < y1, 'W ',
ifelse(quadrant == 2 & abs(angle_degrees) >= x2 & abs(angle_degrees) < y2, 'W,WNW ',
ifelse(quadrant == 2 & abs(angle_degrees) >= x3 & abs(angle_degrees) < y3, 'WNW ',
ifelse(quadrant == 2 & abs(angle_degrees) >= x4 & abs(angle_degrees) < y4, 'NW,WNW',
ifelse(quadrant == 2 & abs(angle_degrees) >= x5 & abs(angle_degrees) < y5, 'NW ',
ifelse(quadrant == 2 & abs(angle_degrees) >= x6 & abs(angle_degrees) < y6, 'NW,NNW',
ifelse(quadrant == 2 & abs(angle_degrees) >= x7 & abs(angle_degrees) < y7, 'NNW ',
ifelse(quadrant == 2 & abs(angle_degrees) >= x8 & abs(angle_degrees) < y8, 'N,NNW ',
ifelse(quadrant == 2 & abs(angle_degrees) >= x9 & abs(angle_degrees) <= y9, 'N ',
ifelse(quadrant == 4 & abs(angle_degrees) >= x1 & abs(angle_degrees) < y1, 'E ',
ifelse(quadrant == 4 & abs(angle_degrees) >= x2 & abs(angle_degrees) < y2, 'E,ESE ',
ifelse(quadrant == 4 & abs(angle_degrees) >= x3 & abs(angle_degrees) < y3, 'ESE ',
ifelse(quadrant == 4 & abs(angle_degrees) >= x4 & abs(angle_degrees) < y4, 'SE,ESE',
ifelse(quadrant == 4 & abs(angle_degrees) >= x5 & abs(angle_degrees) < y5, 'SE ',
ifelse(quadrant == 4 & abs(angle_degrees) >= x6 & abs(angle_degrees) < y6, 'SE,SSE',
ifelse(quadrant == 4 & abs(angle_degrees) >= x7 & abs(angle_degrees) < y7, 'SSE ',
ifelse(quadrant == 4 & abs(angle_degrees) >= x8 & abs(angle_degrees) < y8, 'S,SSE ',
ifelse(quadrant == 4 & abs(angle_degrees) >= x9 & abs(angle_degrees) <= y9, 'S ',NA
))))))))))))))))))))))))))))))))))))
return(data.frame(tan_angle, angle_degrees, quadrant, Winddirection))
}
# wind as an R factor: when sorting by wind we want N at the top.
wind_factor <- tibble::tribble(
~Winddirection, ~Compass_rose_index,
'N ', 1,
'N,NNE ', 2,
'NNE ', 3,
'NE,NNE', 4,
'NE ', 5,
'NE,ENE', 6,
'ENE ', 7,
'E,ENE ', 8,
'E ', 9,
'E,ESE ', 10,
'ESE ', 11,
'SE,ESE', 12,
'SE ', 13,
'SE,SSE', 14,
'SSE ', 15,
'S,SSE ', 16,
'S ', 17,
'S,SSW ', 18,
'SSW ', 19,
'SW,SSW', 20,
'SW ', 21,
'SW,WSW', 22,
'WSW ', 23,
'W,WSW ', 24,
'W ', 25,
'W,WNW ', 26,
'WNW ', 27,
'NW,WNW', 28,
'NW ', 29,
'NW,NNW', 30,
'NNW ', 31,
'N,NNW ', 32) %>%
arrange(Compass_rose_index) %>%
mutate(Winddirection = trimws(Winddirection)) %>%
mutate(Winddirection = factor(as.integer(Compass_rose_index), levels = Compass_rose_index, labels = Winddirection))
# Adding calculated wind direction to working file.
segments <- cbind(segments,
winddirection_between_two_points (segments$Start_latitude, segments$Start_longitude,segments$End_latitude,segments$End_longitude )) %>%
mutate(Winddirection = trimws(Winddirection)) %>%
mutate(Winddirection = factor(Winddirection, levels = wind_factor$Winddirection))
# Remove constants x and y series.
rm(list=ls(pattern="^[xy]*[1-9]$"))
# Select items to save in Excel.
segments <- segments %>%
select(c(Segment_Id, Segment_url, Name, City, State, Grade, Dist_km, n_Riders, Pos,
Seconds_behind_leader, Speed_kmh_KOM, Winddirection, Dist_km_straight_line, Ratio_straight_line_real_distance))
options(width = 1500)
head(segments,15)[,c("Segment_Id", "Grade", "Seconds_behind_leader", "Speed_kmh_KOM", "Winddirection", "Dist_km", "Dist_km_straight_line", "Ratio_straight_line_real_distance")]
## Segment_Id Grade Seconds_behind_leader Speed_kmh_KOM Winddirection Dist_km Dist_km_straight_line Ratio_straight_line_real_distance
## 1 7275619 1.3 3 45.0 W,WSW 1.361 1.355 1.00
## 2 15271422 0.0 1 53.5 SW,WSW 0.565 0.561 0.99
## 3 1577351 -0.1 2 50.8 SW,WSW 1.807 1.781 0.99
## 4 13225754 -1.2 6 47.9 SE,ESE 3.538 3.059 0.86
## 5 24361472 0.2 1 64.0 W,WNW 0.836 0.832 1.00
## 6 10467378 0.0 1 49.2 SW,WSW 2.038 1.981 0.97
## 7 22623916 1.1 24 40.8 SW,WSW 4.033 3.932 0.97
## 8 10025365 -0.5 2 59.4 W,WSW 0.429 0.424 0.99
## 9 6921731 1.0 24 40.0 SW,WSW 4.104 4.080 0.99
## 10 18909466 1.0 23 40.4 SW,WSW 4.070 4.007 0.98
## 11 9908742 0.2 2 59.5 WSW 1.091 1.090 1.00
## 12 2186691 -0.1 3 62.8 W,WNW 1.117 1.115 1.00
## 13 9478096 -1.8 10 49.1 NNW 4.610 4.159 0.90
## 14 10775238 -0.1 2 59.9 WNW 0.566 0.566 1.00
## 15 14850680 -0.5 4 51.1 N,NNW 0.681 0.675 0.99
# Export to Excel (and use its filter option).
write.xlsx(segments,"data/my_Strava_segments.xlsx", colNames = TRUE, rowNames = FALSE, append = FALSE)
The figure below shows the frequency distribution of the
calculated wind direction.
Figure 3.
If we compare both previous pictures, the first one is somewhat more pronounced, whilst the second is a bit more squat, a little bit smoother. The second is also turned slightly counterclockwise, which could indicate a programming error, but I didn’t find such an error. When coding manually, it seems that when in doubt the brain chooses the cardinal wind directions north, south, east, west, or their combinations (NW, NE, SE, SW), see Figure 2. Furthermore, in Figure 3 the lower left quadrant appears to contain the most segments. Does this perhaps have something to do with the dominant wind direction in my region? So now for a short break…
In Figure 4, generated by the R script below, you will find the
predominant wind directions in the northern part of the Netherlands,
broken down into wind force. The dominance of the south-west quadrant is
clear. A hypothesis could be that for this reason there is an
over-representation of segments with a wind direction from this angle
and its opposite (cf. Figure 3 and 4).
# SOURCE: ROYAL NETHERLANDS METEOROLOGICAL INSTITUTE (KNMI)
#
# Station LON(east) LAT(north) ALT(m) NAME
# 280 6.585 53.125 5.20 Eelde
#
# FHX : Maximum hourly mean wind speed (in 0.1 m/s)
# TX : Maximum temperature (in 0.1 degrees Celsius)
# PX : Maximum hourly sea level pressure (in 0.1 hPa)
# DR : Precipitation duration (in 0.1 hour)
# UX : Maximum relative atmospheric humidity (in percents)
# DDVEC : Vector mean wind direction in degrees (360=north; 90=east; 180=south; 270=west)
#download.file("https://www.daggegevens.knmi.nl/klimatologie/daggegevens", "data/KNMI daggegevens Eelde 2014-2018.txt", "curl",
# extra = "-d stns=280&vars=FHX:TX:PX:DR:UX:DDVEC&start=20140101&end=20181231&fmt=csv")
knmi_winddata <- read_csv("data/KNMI daggegevens Eelde 2014-2018.txt", col_names = FALSE, comment = "#") %>%
mutate(datum = ymd(X2), X3 = X3 / 10, X4 = X4 / 10, X6 = (X6 * 6)/60, X5 = X5 / 10) %>%
rename(max_wind_Bft = X3 , max_temp = X4, prec_dur = X6, max_pressure_hPa = X5, max_rel_hum = X7, mean_wind_direction = X8) %>%
select( - c(X1, X2)) %>%
mutate(max_wind_Bft = cut(max_wind_Bft, breaks = c(0.3, 1.6, 3.4, 5.5, 7.9, 10.8, 13.9, 17.2, 20.8, 24.5, 28.5, 32.6),
labels = c("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"))) %>%
mutate(Wind_32 = as.numeric(cut_interval(mean_wind_direction,32))) %>%
mutate(Wind = factor(Wind_32, levels = c(1:32),
labels = c('N,NNE','NNE','NE,NNE','NE','NE,ENE','ENE','E,ENE','E','E,ESE','ESE','SE,ESE','SE','SE,SSE',
'SSE','S,SSE','S','S,SSW','SSW','SW,SSW','SW','SW,WSW','WSW','W,WSW',
'W','W,WNW','WNW','NW,WNW','NW','NW,NNW','NNW','N,NNW', 'N'))) %>%
select(max_wind_Bft, Wind) %>%
mutate(max_wind_Bft = as.character(max_wind_Bft)) %>%
group_by(Wind, max_wind_Bft) %>%
summarise(number_of_days_yearly = n()/5) %>%
mutate(max_wind_Bft = factor(max_wind_Bft, levels = c(7,6,5,4,3,2,1)))
check_mean_number_of_day_in_2014_2018 <- sum(knmi_winddata$number_of_days_yearly)
check_mean_number_of_day_in_2014_2018 # 2016 was a leap year
## [1] 365.2
ggplot(knmi_winddata, aes(x = Wind, y = number_of_days_yearly, fill = max_wind_Bft)) +
geom_col(width = 1, show.legend = TRUE) +
scale_fill_manual(values = c("#FF3300", "#FFCC00", "#FFFF33", "#00FF00", "#66FF66", "#99FF99", "#CCFFCC")) +
coord_polar(start = pi/32) + # change start value if you want a different orientation
theme_light() +
theme(axis.title.x = element_blank(),
panel.ontop = TRUE, # change to FALSE for grid lines below the wind rose
panel.background = element_blank()) +
ylab("average number of days, yearly") +
theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5)) +
ggtitle("Average wind direction in the northern part of the Netherlands, 2014-2018") +
labs(subtitle="and maximum hourly mean wind force (Bft)") +
labs(caption = paste0("Source: The Royal Netherlands Meteorological Institute (KNMI), Eelde station"))
Figure 4.
We continue by analyzing the relationship visually coding versus
two-point calculation in more detail. A cross-tabulation of both
classifications (Figures 2 and 4) shows the exact differences. The table
is presented in Figure 5. The summary table inside shows that about 50
percent of the segments have the same wind direction, 45 percent deviate
slightly. The remainder, only 5 percent, contains some notable
differences. For instance, four times east instead of west. This is
clearly a matter of incorrect visual coding. In addition, there are
segments that are difficult to code because they have an erratic
pattern. In rare cases even time trial laps, or When the segment is a
loop. Some outliers are shown in Figure 6.
Finding 95 percent
similarity, the conclusion so far is an encouragement to use calculated
two-point wind direction and to never code manually again.
Figure 5.
Figure 6. Some outlier examples.
In addition to wind direction, another item can be derived from the start and end points: the straight line distance. The segment length is an existing item in the VeloViewer export file, and we can compare this one to the straight line distance. We do this by calculating the ratio between the two.
The ratio should not be higher than 1.
The graph of Figure 7
below shows both distances compared to each other. The colored points
represent the results of the wind direction comparison mentioned
before.
The mean ratio is 0.93 (As an aside: the ratio of the group
with deviating wind directions is significantly lower, 0.86, see the
summary table inside Figure 7). On average for all segments examined,
the straight-line distance is 93 percent of the actual segment distance.
That is a very high percentage. Most segments in the present region are
apparently little more than straight lines.
Figure 7.
In this section we want to compare the two-point segment distance
with the distance calculated using multiple points. Since VeloViewer
only provides start and end points, we need to use a different source:
Climbfinder (https://www.climbfinder.com). All climbs on Climbfinder
are arranged by countries and regions. For each climb, they provide key
facts such as its length in km, average gradient (%), and the total
ascent in meters. In addition, every ascent has a difficulty indicator
allowing climbs to be easily compared with each other. The indicator is
based on the Belgian Encyclopedia Cotacol. A very interesting site for
anyone who loves climbing.
But of particular importance for our
analysis is the fact that Climbfinder offers the option to download a
GPX file of each climb for your bike navigation. Such a GPX file
contains many points, allowing the comparison between two-point segment
distance and multiple point distance.
At the time of collecting the
GPX files, Climbfinder offered 1,256 climbs in the Netherlands. Perhaps
unnecessarily to mention, it won’t surprise anyone that the word “climb”
has a little different meaning in this mainly flat country than
elsewhere. Climbing in the Netherlands - the name of this country
literally means low country - is actually a ‘contradictio in terminis’,
a contradiction in terms. Nevertheless, there are a number of
irregularities in the landscape, apart from dikes and viaducts. The
southernmost part of the Netherlands, South Limburg, has been lifted by
tectonics, creating a plateau landscape in which a large number of
valleys have been carved out by water erosion. Furthermore, the
penultimate ice age - the Saale glacial stage - has left its mark on the
landscape, especially in the middle of the country. On top of the
Vaalserberg you will find the highest point in the Netherlands, at 323
meters above sea level.
Here’s an R script to read all downloaded GPX’s.
# Stack all 1,256 climb GPX's and keep latitude and longitude.
library(tidyverse)
gpx_files = list.files(path = 'data/klimcoördinaten/', pattern = "gpx$", full.names = TRUE)
gpx_files_stacked <- map_dfr(gpx_files, read_csv, id = "file_name", col_names = FALSE, trim_ws = TRUE) %>%
filter(substring(X1,1,10) == "<trkpt lat") %>%
mutate(record_nr = row_number()) %>%
mutate(duplicate = ifelse(X1 == lag(X1), TRUE, FALSE)) %>%
filter(!duplicate | record_nr == 1) %>%
mutate(climb_name = str_sub(file_name, start=22, end=-5)) %>%
mutate_all(~gsub('<trkpt lat="|" lon="|">', " ", .)) %>%
mutate(X1 = str_trim(X1)) %>%
mutate(Array = (gregexpr(' ', X1))) %>%
mutate(lat = substring(X1, 1, as.numeric(Array)-1)) %>%
mutate(lon = substring(X1, as.numeric(Array)+1)) %>%
group_by(file_name) %>%
mutate(point_number = row_number()) %>%
mutate(lat=as.numeric(lat)) %>%
mutate(lon=as.numeric(lon)) %>%
ungroup() %>%
mutate(climb_name = str_replace_all(climb_name, "_", " ")) %>%
select(climb_name, point_number, lat, lon) %>%
arrange(climb_name, point_number)
gpx_files_stacked %>%
mutate(across((c("lat", "lon")), ~ num(., digits = 6))) %>%
filter(climb_name == "Cauberg") %>% # Cauberg as an example
tibble::as_tibble() %>% print(n = Inf)
## # A tibble: 27 × 4
## climb_name point_number lat lon
## <chr> <int> <num:.6!> <num:.6!>
## 1 Cauberg 1 50.862533 5.829650
## 2 Cauberg 2 50.862528 5.829649
## 3 Cauberg 3 50.862484 5.829468
## 4 Cauberg 4 50.862313 5.829087
## 5 Cauberg 5 50.862152 5.828562
## 6 Cauberg 6 50.861871 5.827224
## 7 Cauberg 7 50.861772 5.826864
## 8 Cauberg 8 50.861308 5.825571
## 9 Cauberg 9 50.861252 5.825216
## 10 Cauberg 10 50.861248 5.824434
## 11 Cauberg 11 50.861212 5.824157
## 12 Cauberg 12 50.861163 5.823961
## 13 Cauberg 13 50.861046 5.823690
## 14 Cauberg 14 50.860867 5.823441
## 15 Cauberg 15 50.860718 5.823326
## 16 Cauberg 16 50.858674 5.822107
## 17 Cauberg 17 50.858174 5.821835
## 18 Cauberg 18 50.858104 5.821712
## 19 Cauberg 19 50.857281 5.821309
## 20 Cauberg 20 50.857115 5.821192
## 21 Cauberg 21 50.857004 5.821085
## 22 Cauberg 22 50.856855 5.820885
## 23 Cauberg 23 50.856719 5.820586
## 24 Cauberg 24 50.856615 5.820259
## 25 Cauberg 25 50.856559 5.819770
## 26 Cauberg 26 50.856571 5.819401
## 27 Cauberg 27 50.856772 5.817886
As mentioned earlier, we use the GPX files from Climbfinder to determine the wind direction based on multiple points. This is done as follows. We calculate the distance and wind direction between two consecutive points for all points in the climbing route. We then aggregate the distances per wind direction found. Distance as a proportion of the length of the climbing route is the weight factor of the wind direction. See the table below for a few examples.
The table above shows the three dominant wind directions of the
Cauberg, which is the famous final climb of the UCI World Tour classic
Amstel Gold Race. These are NNE (weight 0.29), ENE (0.19) and NE,ENE
(0.16). Together they explain 64% of the length of the route (see map
below).
Map 2a. Cauberg (source: Climbfinder)
The Hellendoornse Berg, on the other hand, has only one predominant
wind direction: ENE (100%).
The Vaalserberg is a bit more
winding.
Map 2b. Hellendoornse Berg (source: Climbfinder)
Map 2c. Vaalserberg (source: Climbfinder)
As a final example, the Fromberg. The three main wind directions are
each in a different quadrant, SW, NE, SE respectively. Your preferred
wind direction would be the two-point estimator SSE.
Map 2d. Fromberg (source: climbfinder)
Here’s the full breakdown table of the four examples.
## # A tibble: 53 × 7
## Climb Wind_direction Windrose_seq_number Meters Prop_explained Cum_distance Cum_proportion
## <chr> <fct> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Cauberg NNE 2 340 0.285 340 0.285
## 2 Cauberg ENE 6 223 0.187 563 0.473
## 3 Cauberg NE,ENE 5 186 0.156 749 0.629
## 4 Cauberg E,ESE 9 109 0.092 858 0.72
## 5 Cauberg N,NNE 1 97 0.081 955 0.802
## 6 Cauberg E 8 81 0.068 1036 0.87
## 7 Cauberg E,ENE 7 81 0.068 1117 0.938
## 8 Cauberg NE 4 60 0.05 1177 0.988
## 9 Cauberg NE,NNE 3 14 0.012 1191 1
## 10 Fromberg SW,WSW 21 375 0.227 375 0.227
## 11 Fromberg E,ENE 7 175 0.106 550 0.333
## 12 Fromberg SE,SSE 13 164 0.099 714 0.432
## 13 Fromberg ESE 10 150 0.091 864 0.523
## 14 Fromberg E 8 125 0.076 989 0.599
## 15 Fromberg S,SSE 15 100 0.061 1089 0.659
## 16 Fromberg W,WSW 23 75 0.045 1164 0.705
## 17 Fromberg WSW 22 75 0.045 1239 0.75
## 18 Fromberg S 16 75 0.045 1314 0.795
## 19 Fromberg SE 12 75 0.045 1389 0.841
## 20 Fromberg SSE 14 75 0.045 1464 0.886
## 21 Fromberg E,ESE 9 50 0.03 1514 0.916
## 22 Fromberg ENE 6 50 0.03 1564 0.947
## 23 Fromberg SE,ESE 11 50 0.03 1614 0.977
## 24 Fromberg SW 20 19 0.012 1633 0.989
## 25 Fromberg SSW 18 19 0.012 1652 1
## 26 Hellendoornse Berg vanuit Hellendoorn ENE 6 748 1 748 1
## 27 Vaalserberg N,NNW 31 563 0.177 563 0.177
## 28 Vaalserberg WNW 26 368 0.116 931 0.293
## 29 Vaalserberg W,WNW 25 236 0.074 1167 0.368
## 30 Vaalserberg W,WSW 23 200 0.063 1367 0.431
## 31 Vaalserberg N 32 195 0.061 1562 0.492
## 32 Vaalserberg W 24 181 0.057 1743 0.549
## 33 Vaalserberg NNW 30 172 0.054 1915 0.603
## 34 Vaalserberg N,NNE 1 154 0.049 2069 0.652
## 35 Vaalserberg WSW 22 145 0.046 2214 0.698
## 36 Vaalserberg NNE 2 144 0.045 2358 0.743
## 37 Vaalserberg SSW 18 115 0.036 2473 0.779
## 38 Vaalserberg NW 28 100 0.032 2573 0.811
## 39 Vaalserberg NW,WNW 27 91 0.029 2664 0.839
## 40 Vaalserberg SW,SSW 19 88 0.028 2752 0.867
## 41 Vaalserberg NW,NNW 29 81 0.026 2833 0.893
## 42 Vaalserberg NE,NNE 3 65 0.02 2898 0.913
## 43 Vaalserberg NE,ENE 5 47 0.015 2945 0.928
## 44 Vaalserberg SW,WSW 21 38 0.012 2983 0.94
## 45 Vaalserberg E,ENE 7 37 0.012 3020 0.951
## 46 Vaalserberg E 8 27 0.009 3047 0.96
## 47 Vaalserberg ESE 10 25 0.008 3072 0.968
## 48 Vaalserberg S,SSE 15 25 0.008 3097 0.976
## 49 Vaalserberg S,SSW 17 25 0.008 3122 0.984
## 50 Vaalserberg SW 20 23 0.007 3145 0.991
## 51 Vaalserberg ENE 6 13 0.004 3158 0.995
## 52 Vaalserberg E,ESE 9 11 0.003 3169 0.998
## 53 Vaalserberg NE 4 5 0.002 3174 1
This data can be displayed graphically; VeloViewer does something similar for each segment.
Figure 8. (Source gpx: Climbfinder)
To summarize all directions found into one wind path for each climb,
we weigh all directions and calculate the average. The corresponding
sequence numbers 1 to 32 were used for weighing purposes. Figure 9 shows
the matrix of the calculated wind direction based on start and end
points (x-axis) and GPX points (y-axis). 83 percent of the segments have
the same wind direction, 13 percent deviate slightly, 4 percent have
notable differences. Finding 96 percent similarity, the conclusion is
that one can rely very well on the two-point wind direction. It would be
better to say: one can race very fast together with this wind as your
buddy!
Figure 9.
Figure 10 gives straight-point distance versus the actual length of a climb. The colored points represent the results of the wind direction comparison in the previous figure. The mean ratio is 0.89, in other words: on average across all climbs, the straight-line distance is 89 percent of the actual distance. That is a rather high percentage. Most ‘ascents’ in the Netherlands are apparently little more than straight lines. The ratio of the group with deviating wind directions is significantly lower (0.79), which is very explainable: a lower ratio indicates a more erratic segment that is more difficult to capture in an unambiguous wind direction.
Figure 10.
Below the frequency distribution of the calculated two-point wind direction for climbs. As expected, the distribution is rather uniform. In contrast, the distribution of the sample segments in the northern Netherlands (Figure 3) was more skewed.
Figure 11.
we evaluated eye-coded, two-point and multi-point wind direction. For
completeness, one could also look at the most dominant section of a
segment, i.e. the longest part with the same wind direction. Table 1 and
the R table in Chapter 2 show that the dominant part of the Fromberg -
see Map 2d - is 375 meters long (accounting for 23% of the total climb
length), with wind direction SW, WSW. If we were to use this wind
direction as an estimator, what would the now well-known graphs look
like? Well, as in Figures 12 and 13 below.
From the first it can be seen that there is only 70% similarity
with the two-point estimator.
Figure 12.
Figure 13.
Suppose your weather app predicts strong winds, and you want to cycle
as fast as possible on certain segments with the wind at your back, then
you can use the Strava segment explore feature to find the right
segments and add them to the route planner. But if you like using your
own data, you can go one step further. VeloViewer offers the option to
download your own segment data and then enrich it yourself with the
ideal wind direction. If strong winds are forecast, you can put together
your route by selecting segments from your own data file. You can
calculate the ideal wind direction for cycling a segment using the start
and end coordinates (two-point). It turns out that the two-point
estimator of the ideal wind direction corresponds 95 percent to the
self-estimated wind, and shows 96 percent similarity to the wind
direction calculated based on multiple points from GPX files. The
dominant section wind direction method is an alternative form of
calculation, but the similarity with the start-end point results shows
only 70 percent compatibility.
This conclusion is based on two
data sets. The first is a set of 4,728 Strava
segments, downloaded from VeloViewer, to which in the past I visually
estimated and added the wind direction for each segment. The data could
recently be supplemented with the two-point wind direction because
VeloViewer started providing the starting and ending coordinates.
The second dataset consists of all climbs in
the Netherlands that were searched for on the Climbfinder site, a total
of 1,256. The GPX file was downloaded for each climb, as well as
properties such as altimeters and gradient. The GPX data makes it
possible to determine the desired wind direction using start and end
points on the one hand and multiple points on the other.
The ratio
between the distance as the crow flies and the actual distance is an
excellent indicator of the accuracy of the calculated wind direction.
The lower the ratio, the higher the deviation from the calculated wind
direction. The ratio in the flat segments sample is 0.93, in the
climbing group a lower value was logically found (0.89). After all,
climbs are on average a bit more winding.
The median number of
elevation meters in the flat sample is 2 meters and 28 meters in the
climbing group. The median of the percentage increase in both groups is
0 percent and 3.2 percent respectively. These figures are illustrative
of the flatness of the Netherlands. Table 2 summarizes the data in this
writing.
If you feel addressed as a data geek, an avid KOM-hunter and someone
who has time to spare, then I would suggest subscribing to both Strava
and VeloViewer and regularly using the segment export file of the latter
to create an Excel file including the following items: Segment Id and /
or Segment url, city, distance of segment, number of seconds behind
leader, the speed necessary to get the KOM, calculated two-point
tailwind direction, and finally the ratio straight line to actual
distance which serves as a kind of ‘goodness-of-fit’ indicator for the
acquired wind direction.
Strava
https://www.strava.com
VeloViewer
https://www.veloviewer.com
Climbfinder
https://www.climbfinder.com
Route planning for outdoor pursuits
https://www.plotaroute.com
Gavin Francis, “The best wind for a KOM on Strava”,
Science4Performance, February 24, 2017
https://science4performance.com/2017/02/24/the-best-wind-for-a-kom-on-strava/
Gavin Francis, “The best weather conditions for a KOM on Strava”,
Science4Performance March 1, 2017
https://science4performance.com/2017/03/01/the-best-weather-conditions-for-a-kom-on-strava/
“Ride like the wind” with Headwind, a desktop app that connects with
Strava to visualize your next ride based on real time local weather
information
https://headwindapp.com
Horatiu Lazu, “Bringing machine learning analytics, data
visualization and weather data to cycling activities and segments”,
GitHub, January 10, 2018
https://github.com/MathBunny/strava-wind-analysis
Footnotes
Geographer, data-analist and cyclist↩︎
Another key aspects of the weather that influences the time to complete a Strava segment is the air density. This article considers only the direction of the wind↩︎
An example. A southwest wind is a wind that blows from the southwest. At to international convention we indicate this with an arrow pointing to the top right↩︎
Obviously, this article may not only be of some interest for those who like to cycle fast with the wind, but could very well be interesting too for cyclists who like a headwind, such as participants of the Dutch Headwind Cycling Championships. Competitors must ride a time trial course against the wind on the Oosterscheldekering storm barrier, which faces the North Sea↩︎
The area includes the provinces of Groningen, Fryslân and Drenthe↩︎
Acknowledgements to VeloViewer’s Ben Lowe for granting the request↩︎
Permission has been granted to use Climbfinder data for publishing purposes.↩︎