Introduction

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 11​1⁄4°.

Figure 1. The compass rose

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?

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.

1. Visually coding wind direction versus two-point calculation

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.

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)

1.1 A function to calculate straight distance between two points

# 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))

1.2 A function to get wind direction between two points

# 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.

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…

1.3 The most common wind direction in the region

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.

Figure 4.

1.4 Matrix visually coded wind direction versus two-point calculation

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 5.



Figure 6. Some outlier examples.

Figure 6. Some outlier examples.

1.5 Straight line distance versus actual distance

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.

Figure 7.

2. Wind direction based on two points versus multiple points calculations

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)

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 2b. Hellendoornse Berg (source: Climbfinder)



Map 2c. Vaalserberg (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)

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)

Figure 8. (Source gpx: Climbfinder)



2.1 Matrix two-point versus multipoint calculation

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 9.

2.2 Straight line distance versus actual distance

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.

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.

Figure 11.

3. The most dominant section of a segment



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.

3.1 Matrix two-point versus dominant section breakdown


From the first it can be seen that there is only 70% similarity with the two-point estimator.

Figure 12.

Figure 12.



3.2 Straight line distance versus dominant section distance


The second shows that the dominant section averages no more than 44% of the total segment length.
Figure 13.

Figure 13.



Summary



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.





Conclusion

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.



Appendix



Bibliography

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


  1. Geographer, data-analist and cyclist↩︎

  2. 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↩︎

  3. 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↩︎

  4. 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↩︎

  5. The area includes the provinces of Groningen, Fryslân and Drenthe↩︎

  6. Acknowledgements to VeloViewer’s Ben Lowe for granting the request↩︎

  7. Permission has been granted to use Climbfinder data for publishing purposes.↩︎