The Black Hills 100 is a grueling ultra-marathon that takes runners through the rugged beauty of South Dakota’s Black Hills. One of the biggest challenges of the race is the drastic changes in elevation, with the 50 mile route having over 6,000 feet in elevation gain. To plan for this, we mapped out the route as a GPX file and used Python to better understand the terrain and adjust our pacing goals accordingly.

This article showcases how we did this, which included mapping out the entire trail route with Hiiker, transforming it from a GPX file to a tabular format with Python, and creating interactive elevation profiles with Plotly.
So, without further ado, let’s get into it, starting by mapping out the trail route in Hiiker.
Creating a GPX File
All of the route information is provided at the Black Hills 100 Website, which we put a link to in the description. The site lists out all of the info for the race, including the route plan, which can be found under the maps page.

There are a few static maps available with the basic route plotted against a topographical view and aerial view, but we want the actual route data with elevation included, which is where Hiiker comes in. Hiiker is a trail planning app for identifying and navigating hiking trails, and we’re going to use its route planning functionality to create a GPX file of the marathon route.
We’ll start by going to their website, hiiker.app and clicking on the Plan a Route tab. This brings up the route editor with an interactive map of a bunch of different trails, and we’re going to pull up the static map side by side with this to approximate which trails the route uses.

Note that we’ll want to be in the Snap to Trail mode of the editor. We’ll navigate to the starting point of the race and copy the route from the static map by adding points along the corresponding trails.
As you add points, the Snap to Trail functionality will recognize which trail you’re route is following, and it does a pretty good job of mapping out your route based on the trails. We’ll continue this along the whole marathon route until we have the whole thing planned out, and at any point during this process we can save our route by signing up for a free Hiiker account.
Once we’re done, we’ll use the share button on the right to get a shareable web link, and we’ll paste it into a new browser window to pull up the final route.

You’ll notice in this view that there’s an option to export the route as a GPX file, which we’ll use to download it to our desktop.

Formatting in Python
Now that we have the whole route mapped out in a GPX file, we can use Python to analyze it according to our needs.
GPX to dataframe
We’ll start by using the gpx-py package to load the GPX file into Python. We’ll then iterate through all of the individual points in the route with a nested for loop, get the latitude, longitude, and elevation for each, and append it all to one big 2D array. Finally, we’ll put it into a pandas dataframe so that it’s easy for us to work with.

Next, we want to measure the distance at each point on the trail, which we can calculate from our lat/long coordinates. We’ll start by getting the coordinates from the previous point on each row, and then we’ll define a little function to calculate the distance between the previous point and the current point. We’ll apply this function to our dataframe, and can get the total trail distance at each point with a cumulative sum of these distances.

Next, we’ll use the elevation column to measure changes in elevation throughout the route. The exported GPX file has elevation measured in meters, but we want it in feet, so we’ll define the conversion factor between meters and feet and use it to convert the elevation column to feet.

We ultimately want to measure changes in elevation, so we’ll find the previous elevation for each row and use it to find the change in elevation between the current and previous points. With our GPX file loaded in and the basic calculations complete, the next step is to break the route up into intervals based on aid stations.
Breaking out into intervals
Throughout the ultra-marathon route, there are several different aid stations set up where the runner’s crew can check in on them and provide them with food and water. The website lists out the mileage at which each of these aid stations are located, so we’ll use this mileage to connect them to our route data.

We want to break up the route into intervals between these aid stations to better manage our pacing and calorie goals. We’ll start by listing out the name and mileage of each aid station a csv file, and we’ll read it into Python as a Pandas dataframe.

Since we’re trying to get the interval between aid stations, we’ll use the shift method to get the next row for each row in the dataframe, and we’ll drop NAs to filter out the interval for after the finish line. We’ll also use the index to label each of the intervals chronologically and call it interval number.

In order to join this to our route data, we’ll define a function that takes a given mileage, determines what interval it falls within based on the start and end mileage of each interval, and returns the corresponding interval number. We’ll apply that function to the cumulative distance column of our route data, and we now have our route data fully formatted and ready to visualize.
Creating the Visualization
We’ll use the Python Plotly package to make some plots, which allows us to build interactive visualizations inside of our Jupyter notebook.
Overall profile
We’ll start with just an overall elevation profile for the whole route. We’re going to define a function for building each of the different plots so that we can re-use the code in different scenarios.
The first step in building a Plotly visual is creating the figure object, which is the Python object that represents our plot and stores all of it’s functionality. Each function will return this figure object, and at the end we’ll call this function and use the show method to display the plot.

Note that our plot is currently blank since we haven’t added anything to it, so we’ll add the elevation data to it with the add trace method and the scatter class.

We can also add annotations to our plot, and in this case we want to add some summary metrics about the route like total elevation gain, total elevation loss, and total distance. We’ll start by calculating each one of these from the data, and then we’ll put together one big text string that lists everything out. We’ll then add it to the plot as an annotation with this update layout method.

Another cool feature of the Plotly package is that there are a lot of different options that we can set to adjust the styling and appearance of our chart also through the update layout method. In this example, we’ll apply one of Plotly’s built-in styling templates, adjust our margins, and add an overall title and axis titles.

With our first plot now fully built and styled how we want it, we’re ready to start building our second that highlights the individual intervals inside of our overall elevation profile.
Highlight intervals
We’ll simply take the plot that we just created and add vertical lines to separate out the different intervals and a gray shaded region to highlight the specific interval that we’re focusing on.
In this function, we’ll start by calling the other function to create the overall elevation plot, and then we’ll get the min and max y values to know how far to draw the vertical lines. We’ll then iterate through all of the intervals to draw each of the lines.

Lastly, we’ll add an if statement to highlight the interval if it’s the one we’re focused on.

With that, we have our second plot built out, and we’re ready to move on to our third and final one that zooms in on a specific interval.
Individual interval profiles
We’ll create another function for it, and start by simply filtering the dataframe to the given interval based on the interval number. Then we can simply plug the filtered data into the first function again, and that’s all it takes for the third plot.

Conclusion
The Black Hills 100 is a tough course, but with the right tools, we gained valuable insights to plan ahead. By mapping out the route in Hiiker, analyzing the GPX data with Python, and visualizing elevation profiles with Plotly, we were better prepared to tackle the race’s challenging elevation changes.
We went through it all a bit fast, so feel free to download the full notebook of Python code to try it out yourself:
If you found this guide helpful, feel free to drop a comment below with any questions or feedback. Your input helps us improve future posts and inspires new ideas.