Rust for beginners: GPX Analyzer - how to find the best GPS tracks in apps like Strava or Nike Run Club

Rust for beginners: GPX Analyzer - how to find the best GPS tracks in apps like Strava or Nike Run Club
Photo by Hannes Egler / Unsplash

This article was written for beginners and not by a Rust professional, so do not judge it too harshly. All the code is available here. Running is fun and important for your health, so run and don't stop! Why not make your daily run more interesting with Rust, the popular modern programming language with many lovers and, of course, haters from the C/C++ armies.

Many people say that Rust is a complex language and difficult for beginners. As you'll see at the end of this article - it's not quite true, you can write large and complex software in Rust with a comfort unbeatable for C and C++ languages, and get fast or even faster applications.

Why Rust is still a good choice for beginners:

  • Modern language with modern design, fixing many design problems from 20+ years ago.
  • Memory safety: 90%+ of C/C++ code problems are memory related.
  • Speed: Rust is faster than enterprise queen Java and the hipster's choice - Python.
  • Borrow checker: at compile time, every piece of data has the same owner, it can be shared or mutated, but never at the same time.
  • Better error handling.
  • Improved memory management: Rust automatically releases memory and closes resources when they're no longer needed.

First steps

To analyze GPX - GPS Exchange Format
file, you must first extract it from your tracking application: Strava, Nike Run Club or another. It's nothing else than a GPX schema with metadata and 3 types of data:

  • waypoints (wptType): a collection of points from location A to location B
  • tracks (trkType): an ordered list of waypoints describing a path
  • routes (rteType) : an ordered list of waypoints leading to destination

We will use this one GPX file and will find the best tracks: longest distance and highest altitude. Why exactly this one? 'Cause I found it on my top5 Google results and it was big enough to test Rust's performance - 5235 lines.

Time to look under the hood. The metadata:

<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="FME(R) 2014 SP5">
	<metadata>
		<time>2016-04-27T15:17:02+01:00</time>
		<bounds minlat="48.57697573087804" minlon="-3.9874913352415757" maxlat="48.726304979176675" maxlon="-3.8264762610046783"/>
	</metadata>

The first two lines are the XML schema header, the creator tells us about the software which was used for this GPX recording. In bounds we can find the "borders" - minlat, maxlat and the similar longitude values. These values are quite useful for the initial map zooming position.

Scroll down and here we go - our tracks are here:

<trk>
		<name>1_Roscoff_Morlaix_A</name>
		<trkseg>
			<trkpt lat="48.726304979176675" lon="-3.9829935637739382">
				<ele>5.3000000000029104</ele>
			</trkpt>
			<trkpt lat="48.72623035828412" lon="-3.9829726446543385">
				<ele>4.6999999999970896</ele>
			</trkpt>
			<trkpt lat="48.726126671101639" lon="-3.9829546542797467">
				<ele>5.1999999999970896</ele>
...

We got the only one segment here - trkseg and many waypoints inside the trkpt tags. The waypoints come with coordinates and altitude, but GPX format allows many options like time, speed, comments and many more. From name my only guees about cities: we'll travel from Roscoff, France to Morlaix, France. The distance between these cities is nearly 25km. Let's start our travel, I think these GPS tracks are recorded by airplane, not by car, ship or bike. Why? You'll see it later, stay tuned 😉

Rust set up and libraries

To get started with Rust quickly, it will be nice to recommend Rustup. On *nix it can be done in one and only command:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

If you value your security and refuse to run random code from the Internet, let's download the script, verify it and then run it:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -L -o rustup.sh
chmod +x rustup.sh && ./rustup.sh

To check if all is OK, run rustc in your terminal.

Now add some Rust libraries for working with GPS: geoutils and gpx.

$ cargo-init
$ cargo add geoutils
$ cargo add gpx

These commands create three files: Cargo.toml, Cargo.lock and src/main.rs. The first two are cargo specs - the Rust package manager; in the latest main.rs we will write our code. We can also change package versions in Cargo.toml:

[dependencies]
geoutils = "0.5.1"
gpx = "0.8.6"

The cargo run command will compile all code and run the application. All it feels like comfortable tooling, don't you? This is Rust.

The journey begins

The task number one for now is parsing XML - our GPX file. This can be done by loading the file into buffer and extracting all tags using regular expression, but we will use the library noted above - gpx. Let's include it in main.rs:

use geoutils::Location;
use gpx::read;
use gpx::{Gpx, Track, TrackSegment};
use std::fs::File;
use std::io::BufReader;

fn main() {
}

use declaration allows you to include different parts of library - there's no need to get all in one. The paths can be more complex like x::y::z:{a,b};. We also need to include the standard libraries to work with files and buffering - buffer is safe and write-only space in memory that can be used without initialization.

fn main(){} is, not hard to guess, our main function - the function will be called first when the program will start.

Moving forward: reading the file and collecting metadata

OK, the libraries are included, time to read the our GPX file.

let file = File::open("src/tour.gpx").unwrap();
let filesize = file.metadata().unwrap().len();
println!("Loading GPX trek with size {} bytes...", filesize);
let reader = BufReader::new(file);
let gpx: Gpx = read(reader).unwrap();

In the first line we use the open method from Rust file API, which we must to provide into path to GPX file and extract the result with .unwrap() method. Why do we need unwrap here? Well, if the path is wrong you might get the error, right? So Rust is predicting this and returns data structure in format called enum format Result<String,std::io::Error>. So there are two potential results - it can be Ok or Err, together:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

The unwrap actually extracts the value from Ok.

Then we use .metadata() and .len() methods to calculate file size, it's useful to see how big is the data we should deal with. Also the empty file highlight will save our nervous 😃

println!("Loading GPX trek with size {} bytes...", filesize);

This line looks super simple, just like print in Python with variable filesize replaced with {}, but contains a simple trick with the template.

println!("{}{}{}", "Loading GPX trek with size ", filesize, " bytes...");

This code will work too, but far for being readable and editable like the above version.

Next, we're sending the file to buffer and now be able to do any manipulation with the data.

let gpx: Gpx = read(reader).unwrap(); - this line does the GPX format parsing and returns data in format Result<Gpx, Error>. Gpx contains all tracks, waypoints with coordinates and all available metadata.

Deal with GPS data

let track: &Track = &gpx.tracks[0];
let track_name = &track.name;
println!("The track name is: {}", track_name.as_ref().unwrap());

Here we're exract the track data. There's only one <trk> tag, so we got a single track in our GPX file. There can be many tracks in single file, all depends from the GPX recording software. The second and third line can be joined into one, but I wrote them both to demonstrate what reference & is: we can't unwrap &track.name, the reference to gpx.tracks directly, so we should use as_ref() and .unwrap() together to get the number of tracks.

let segment: &TrackSegment = &track.segments[0];
println!(
    "Number of segments in loaded track: {:?}",
    track.segments.len()
    );

The code above will print the number of segments in the track. Now it's time to dig deeper and print the number of waypoints:

  for sgmt in 0..track.segments.len() {
    //for (index, segment) in &track.segments {
        println!(
            "Waypoints in segment {sgmt} : {}",
            track.segments[sgmt].points.len()
            //track.segments[index].points.len()
        );
    }

There's a commented version of how to do it with two variables iteration.

Iteration and looking for the best tracks

Let's print the simple code to demonstrate how iteration works with for loop for absolute beginners:

let mut max = 0;
for index in [0,1,2,3,4,5]{
    if index > max {
    max = index;
    }
}
println!("the max number is {}", max)

Here we're iterating over an array with five numbers and comparing each number with our max variable. max is defined above the for scope `cause we need it to catch it after the loop iteration has finished.

We will use the same approach for our track, segment, waypoint data structure. Now we need to catch mouch more data than in the example above: the maximum elevation with coordinates and maximum distance with coordinates and elevation. Why with coordinates? 'Cause we need to know where it happened and what is the elevation on that location - the best data possible.

let mut max_elevation = 0.0;
let mut max_elevation_lat = 0.0;
let mut max_elevation_long = 0.0;
let mut max_distance = 0.0;
let mut max_distance_lat = 0.0;
let mut max_distance_long = 0.0;
let mut max_distance_elevation = 0.0;
let mut old_latitude = segment.points[0].point().x();
let mut old_longitude = segment.points[0].point().y();

Here we go: define a maximum elevation with coordinates, maximum distance and [old_latitude, old_longitude] - the coordinates of first waypoint, the start of our journey.
The coordinates were extracted by using .point(), .x() and .y(). To see whole data structure and all available methods, run printeln!({:?}, segment.points[0]), where :? is a pretty print hook.

The loop will cartch all the hot distances, coordinates and elevations we ever dreamed off:

for wp in 0..track.segments[0].points.len() {
        let latitude = segment.points[wp].point().x();
        let longitude = segment.points[wp].point().y();
        let elevation = segment.points[wp].elevation.unwrap();

        // calculate distance in meters
        let previous_location = Location::new(old_latitude, old_longitude);
        let current_location = Location::new(latitude, longitude);
        let distance = current_location
            .distance_to(&previous_location)
            .unwrap()
            .meters();

        if distance > max_distance {
            max_distance = distance;
            max_distance_lat = latitude;
            max_distance_long = longitude;
            max_distance_elevation = elevation;
            println!(
                "{}{:?}{}{:?}{}{:?}",
                "New max distance: ",
                distance,
                " on elevation: ",
                elevation,
                " at ",
                [max_distance_lat, max_distance_long]
            );
        }
        old_latitude = latitude;
        old_longitude = longitude;

        if elevation > max_elevation {
            max_elevation = elevation;
            max_elevation_lat = latitude;
            max_elevation_long = longitude;
            println!(
                "{}{:?}{}{:?}{}{:?}",
                "New max elevation: ",
                elevation,
                " on distance: ",
                distance,
                " at ",
                [max_elevation_lat, max_elevation_long]
            );
        }
    }

How it works:

  • get the coordinates of each waypoint
  • calculate the distance to previous waypoint using geoutils library
  • if the distance is bigger than the local maximum - write the value to max_distance and save it's coordinates
  • do the same with elevation: pick the highest and save it's coordinates

The println! calls directly in for loop is great for debugging and demonstrating the calculation progress and sometimes helpful to catch something interesting.

Congratulation, ladys and gentemans, our results is here, we only need to print them in human readable format and do it well.

println!("-----\nThe best tracks:");
    println!(
        "{}{:?}{}{:?}",
        "Highest elevation: ",
        max_elevation,
        " at ",
        [max_elevation_lat, max_elevation_long]
    );
    println!(
        "{}{:?}{}{:?}{}{:?}",
        "Longest distance: ",
        max_distance,
        " at ",
        [max_distance_lat, max_distance_long],
        " on elevation ",
        max_distance_elevation
    )

Now we can run the final version and get the results:

Loading GPX trek with size 162656 bytes...
The track name is: 1_Roscoff_Morlaix_A
Number of segments in loaded track: 1
Waypoints in segment 0: 1741
New max elevation: 5.30000000000291 on distance: 0.0 at [-3.982993563773938, 48.726304979176675]
New max distance: 8.604 on elevation: 4.69999999999709 at [-3.9829726446543385, 48.72623035828412]
New max distance: 11.685 on elevation: 5.19999999999709 at [-3.9829546542797467, 48.72612667110164]
...
New max elevation: 54.19999999999709 on distance: 1.026 at [-3.985234045713236, 48.68868314401551]
New max elevation: 2034.165599999993 on distance: 2.136 at [-3.9857944259800657, 48.6865175990549]
New max elevation: 9085.780799999993 on distance: 7.581 at [-3.985764911927947, 48.68645598615743]
New max elevation: 9999.0 on distance: 0.982 at [-3.9857610894015805, 48.68644800612525]
New max distance: 102.967 on elevation: 9999.0 at [-3.9866905734090805, 48.68639251037621]
New max distance: 123.885 on elevation: 0.0 at [-3.9719576338546476, 48.67163185463712]
New max distance: 155.452 on elevation: 0.0 at [-3.9577466263357945, 48.64466328878896]
---------------------------------------
The best tracks:
Highest elevation: 9999.0 at [-3.9857610894015805, 48.68644800612525]
Longest distance: 155.452 at [-3.9577466263357945, 48.64466328878896] on elevation 0.0

As you can see, we got an interesting result. Remember I said before that this is not a bike, car, ship GPS log - it's an airplane? Now you can see why. GPS logs can be interesting and can hide a lot of secrets.

The final version

So here it is, the finish line. The whole application is ready, you can also check this code on Github. Nothing supernatural in the Rust code, but a lot of cool stuff and I had a lot of fun writing it.

Write code for fun and profit, create something interesing and powerful, and love Rust - this is the way!

use geoutils::Location;
use gpx::read;
use gpx::{Gpx, Track, TrackSegment};
use std::fs::File;
use std::io::BufReader;

fn main() {
    let file = File::open("src/tour.gpx").unwrap();
    let filesize = file.metadata().unwrap().len();
    println!("Loading GPX trek with size {} bytes...", filesize);
    let reader = BufReader::new(file);

    // read takes any io::Read and gives a Result<Gpx, Error>.
    let gpx: Gpx = read(reader).unwrap();

    // Each GPX file has multiple "tracks", this takes the first one.
    let track: &Track = &gpx.tracks[0];
    let track_name = &track.name;

    println!("The track name is: {}", track_name.as_ref().unwrap());

    // Each track will have different segments full of waypoints, where a
    // waypoint contains info like latitude, longitude, and elevation.
    let segment: &TrackSegment = &track.segments[0];

    println!(
        "Number of segments in loaded track: {:?}",
        track.segments.len()
    );

    // print number of waypoints in each segment
      for sgmt in 0..track.segments.len() {
    //for (index, segment) in &track.segments {
        println!(
            "Waypoints in segment {sgmt} : {}",
            track.segments[sgmt].points.len()
            //track.segments[index].points.len()
        );
    }

    // Print elevation, latitude and longtitude for first 10 waypoins
    let mut max_elevation = 0.0;
    let mut max_elevation_lat = 0.0;
    let mut max_elevation_long = 0.0;

    let mut max_distance = 0.0;
    let mut max_distance_lat = 0.0;
    let mut max_distance_long = 0.0;
    let mut max_distance_elevation = 0.0;

    let mut old_latitude = segment.points[0].point().x();
    let mut old_longitude = segment.points[0].point().y();

    for wp in 0..track.segments[0].points.len() {
        let latitude = segment.points[wp].point().x();
        let longitude = segment.points[wp].point().y();
        let elevation = segment.points[wp].elevation.unwrap();

        // calculate distance in meters
        let previous_location = Location::new(old_latitude, old_longitude);
        let current_location = Location::new(latitude, longitude);
        let distance = current_location
            .distance_to(&previous_location)
            .unwrap()
            .meters();

        if distance > max_distance {
            max_distance = distance;
            max_distance_lat = latitude;
            max_distance_long = longitude;
            max_distance_elevation = elevation;
            println!(
                "{}{:?}{}{:?}{}{:?}",
                "New max distance: ",
                distance,
                " on elevation: ",
                elevation,
                " at ",
                [max_distance_lat, max_distance_long]
            );
        }
        old_latitude = latitude;
        old_longitude = longitude;

        if elevation > max_elevation {
            max_elevation = elevation;
            max_elevation_lat = latitude;
            max_elevation_long = longitude;
            println!(
                "{}{:?}{}{:?}{}{:?}",
                "New max elevation: ",
                elevation,
                " on distance: ",
                distance,
                " at ",
                [max_elevation_lat, max_elevation_long]
            );
        }
    }

    println!("---------------------------------------\nThe best tracks:");
    println!(
        "{}{:?}{}{:?}",
        "Highest elevation: ",
        max_elevation,
        " at ",
        [max_elevation_lat, max_elevation_long]
    );
    println!(
        "{}{:?}{}{:?}{}{:?}",
        "Longest distance: ",
        max_distance,
        " at ",
        [max_distance_lat, max_distance_long],
        " on elevation ",
        max_distance_elevation
    )
}

Read more