Java: Reading GPX data from Endomondo

I’ve started using Endomondo as my primary workout application for a couple of weeks now and although it is a great app I want to have more control over my data. Luckily for us endomondo users they offer a export function that exports the workouts in GPX format.

Today I’ve been playing around with this library: https://sourceforge.net/projects/gpxparser/ and get the GPX file from Endomondo to be parsed succesfully..

So first of all I created a new project in my Eclipse and imported the GPX parser from the website into another project. I downloaded a file from my endomondo account and wrote a little code:

GPXParser p = new GPXParser();
p.addExtensionParser(new HeartRateExtensionParser());
FileInputStream in = new FileInputStream(location);
GPX gpxFile = p.parseGPX(in);

As you can see. Nothing to fancy. So now what? If we take a sneak peek at the file http://www.jeroensomhorst.eu/wp-content/uploads/20140816_100357.gpx you will see there is no information whatsoever about distance, speed, average heartrate and so on. We have to add that ourselves. Luckily its GNU license so I can edit the source as I please (or atleast that’s what I make out of the license..).

Let start by refactor the GPXParser class. I want to generate a custom GPX class that implements my interface EndomondoGPX. First create the interface

public interface IEndomondoGPx {

	public double getAverageHearthRate();
	public double getTotalDistance();
	public double getMaxHearthRate();
	public double getMinHearthRate();
	public double getTotalDuration();
	public double getMaxSpeed();
}

Nice, isn’t? Next create a new class called EndomondoGPX

package org.alternativevision.gpx.beans;

import java.util.ArrayList;
import java.util.Iterator;
import org.alternativevision.gpx.beans.sort.WayPointComparator;
import org.alternativevision.gpx.extensions.HeartRateExtensionParser;

public class EndomondoGPX extends GPX implements IEndomondoGPx {

	double avgHearthRate = -1;
	double trackpoints = -1;
	double maxHearthRate = -1;
	double TotalDuration = -1;
	double maxSpeed = -1;
	double minHearthRate = -1;

	public EndomondoGPX() {
		super();
	}

	private Iterator<Waypoint> getAllTrackPoints() {
		ArrayList<Waypoint> tp = new ArrayList<Waypoint>();
		for (Object o : this.getTracks().toArray()) {
			Track t = (Track) o;
			tp.addAll(t.getTrackPoints());
		}
		tp.sort(new WayPointComparator());
		return tp.iterator();
	}

	public double getAverageHearthRate() {

		if (avgHearthRate == -1){
			double hearthRate = 0;
			int trackpointcount = 0;
			
			Iterator<Waypoint> trackpionts = getAllTrackPoints();
			while(trackpionts.hasNext()){
				Waypoint wp = trackpionts.next();
				Object hr = wp.getExtensionData(HeartRateExtensionParser.PARSER_ID);
				if(hr instanceof HearthRate){
					hearthRate += ((HearthRate) hr).getHearthRate();
					trackpointcount++;
				}
			}
			
			this.avgHearthRate = (hearthRate / trackpointcount);
		}
		return avgHearthRate;

	}

	@Override
	public double getTotalDistance() {
		double currentDistance = 0;
		Iterator<Waypoint> trackpoints = this.getAllTrackPoints();
		while(trackpoints.hasNext()){
			Waypoint p1 = trackpoints.next();
			Waypoint p2 = null;
			if(trackpoints.hasNext()){
				p2 = trackpoints.next();	
			}
			
			if(p2 != null){
				System.out.println(p1.getTime());
				System.out.println(p2.getTime());
					double distance = getDistance(p1,p2);
					if(distance == 0){
						distance = getDistance(p2,p1);
					}
					System.out.println(distance);
					currentDistance += distance;
			
			
			}
		}
		currentDistance = (currentDistance/100)*101;
		return currentDistance;
	}

	@Override
	public double getMaxHearthRate() {

		if (this.maxHearthRate < 0) {
			double hearthRate = -1;
			Iterator<Waypoint> trackpoints = this.getAllTrackPoints();
			while(trackpoints.hasNext()){
				Object hr = trackpoints.next().getExtensionData(HeartRateExtensionParser.PARSER_ID);
				
				if (hr != null && hr instanceof HearthRate) {
					if (((HearthRate) hr).getHearthRate() > hearthRate) {
						hearthRate = ((HearthRate) hr).getHearthRate();
					}
				}
			}
			this.maxHearthRate = hearthRate;
		}
		return this.maxHearthRate;
	}

	@Override
	public double getMinHearthRate() {
		if(this.minHearthRate<0){
			double hearthRate = -1;
			Iterator<Waypoint> trackpoints = this.getAllTrackPoints();
			while(trackpoints.hasNext()){
				Object hr = trackpoints.next().getExtensionData(HeartRateExtensionParser.PARSER_ID);
				if (hr != null && hr instanceof HearthRate) {
					double entryRate = ((HearthRate)hr).getHearthRate();
					if(hearthRate == -1){
						hearthRate = entryRate;
					}
					else if(entryRate< hearthRate){
						hearthRate = entryRate;
					}
				
					
				}
			}
			this.minHearthRate = hearthRate;
		}
		return this.minHearthRate;
	}

	@Override
	public double getTotalDuration() {
		Iterator<Waypoint> trackpoints = this.getAllTrackPoints();
		while(trackpoints.hasNext()){
			Waypoint p = trackpoints.next();
			p.getTime();
		}
		return 0;
	}

	@Override
	public double getMaxSpeed() {
		// TODO Auto-generated method stub
		return 0;
	}
	
	private static double getDistance(Waypoint p1, Waypoint p2){
		double EARTH_RADIUS = 6371;
		double dLat = toRad(p2.getLatitude() - p1.getLatitude());
		double dLon = toRad(p2.getLongitude() - p1.getLongitude());
		double dLat1 = toRad(p1.getLatitude());
		double dlat2 = toRad(p2.getLatitude());
		double a = Math.sin(dLat/2)*Math.sin(dLat/2)+Math.cos(dLat1)*Math.cos(dlat2)*Math.sin(dLon/2)*Math.sin(dLon);
		double c = 2 * Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
		double d = EARTH_RADIUS * c; // distance Kilometer
		return d;
		
	}
	private static final double toRad(double f){
		return f * (Math.PI/180);
	}
}

The code is pretty simple. There are some util functions there for retrieving all the trackpoints. This method also sorts them by time.

To make this happen I had to make  change to class that is base for all classes in the default GPX parser. The extension class. I had to make it abstract, implement a interface (comparable !) and make a abstract method public int compareTo(Object o1, Object o2).

All other classes such as waypoint should implement the compareTo. For this patch I only need to add the compareTo to the Waypoint class.

After some testing I found out there is a little bug in the GPX parser. When there is more then 1 trkseg node it will overide that node all the time. So you will end up with only the last trkseg node. In this case (see file) it will only contain one trkseg with one trkpt node. Not what we want!

The fix for this is the following method in Track.java

public void setTrackPoints(ArrayList<Waypoint> trackPoints) {
		
		if(this.trackPoints == null){
			this.trackPoints = trackPoints;
		}else{
			this.trackPoints.addAll(trackPoints);
		}
	}

It won’t overwrite all trackpoints for the current track it will add them. For now that is correct but I wont recommend it for all GPX files out there.

So, when that is done. We can start changing our Parser class.

Open up the GPXParser class and add the following method:

	@SuppressWarnings("unchecked")
	public <T extends GPX> T parseGPX(InputStream in, Class c) throws ParserConfigurationException, SAXException, IOException{
		try {
			return (T) parseGPX(in,(GPX) c.newInstance());
		} catch (InstantiationException e) {
			logger.error("Could not Instantiate custom GPX class");
		} catch (IllegalAccessException e) {
			logger.error("Could not instantiate custom GPX class illegal access");
		}
		return null;
	}

and refactor the method signature of parseGPX to

private GPX parseGPX(InputStream in, GPX gpx) throws ParserConfigurationException, SAXException, IOException {
..
}

Now add the following method for backwards compatability:

	public GPX parseGPX(InputStream in) throws ParserConfigurationException, SAXException, IOException{
		return this.parseGPX(in,new GPX());
	}

What we have done is pretty simple. First we created a new method to parse given GPX file (using inputstream) to a object of our own. We tell the GPXParser to parse the xml to our own instance of the GPX class. (in our case EndomondoGPX ).

Next, because we added that method we had to change the default method. We made it private, so that nobody can use it behind our back and we added a new parameter an instance of the GPX class. Inside this method we had to remove the  the GPX gpx = new GPX() because we now send it in as a parameter;

Last of but least we had to add a new method for backwards compatability. All this does is creating a new default GPX object and pass it into the changed method. We can now change our first code snippet to :

GPXParser p = new GPXParser();
p.addExtensionParser(new HeartRateExtensionParser());
FileInputStream in = new FileInputStream(location);
IEndomondoGPx gpxFile = (IEndomondoGPx) p.parseGPX(in, EndomondoGPX.class);
gpxFile.getAverageHearthRate();
gpxFile.getTotalDistance();
gpxFile.getMaxSpeed();
gpxFile.getMaxHearthRate();
gpxFile.getMinHearthRate();
gpxFile.getTotalDuration();

As you can see we have now full control what type of GPX object we get.

To get this all working by the way you also need to create a new HeartRateExtensionParser class. This class is used to parse extensions that comes with Endomondo.

<gpxtpx:TrackPointExtension>
            <gpxtpx:hr>146</gpxtpx:hr>
          </gpxtpx:TrackPointExtension>
package org.alternativevision.gpx.extensions;

import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import org.alternativevision.gpx.beans.GPX;
import org.alternativevision.gpx.beans.HearthRate;
import org.alternativevision.gpx.beans.Route;
import org.alternativevision.gpx.beans.Track;
import org.alternativevision.gpx.beans.Waypoint;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

public class HeartRateExtensionParser implements IExtensionParser {

	public static final String PARSER_ID = "HeartRateExtensionParser";
	private XPathFactory xPathFactory = XPathFactory.newInstance();
	private XPath xpath = xPathFactory.newXPath();
	private XPathExpression exp = null;
	
	
	
	public HeartRateExtensionParser() {
		try {
			exp  = xpath.compile("d");
			
		} catch (XPathExpressionException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	
	
	@Override
	public String getId() {
		return PARSER_ID;
	}

	@Override
	public Object parseWaypointExtension(Node node) {
		NodeList childNodes = node.getChildNodes();
		for(int i = 0; i < childNodes.getLength();i++){
			Node n = childNodes.item(i);
			if("gpxtpx:TrackPointExtension".equals(n.getNodeName())){
				NodeList tpNodes = n.getChildNodes();
				for(int j = 0;j<tpNodes.getLength();j++){
					Node m = tpNodes.item(j);
					if("gpxtpx:hr".equals(m.getNodeName())){
						String t = m.getTextContent();
						if(t!= null && !"".equals(t)){
							double d = Double.parseDouble(t);
							return new HearthRate(d);
						}
						
						
						
					}
				}
			}
		}
		
		System.out.println("No Hearthrate node found..");
		return null;
	}

	@Override
	public Object parseTrackExtension(Node node) {
		System.out.println("Parse Track Extension");
		return null;
	}

	@Override
	public Object parseGPXExtension(Node node) {
		System.out.println("Parse gpx Extension");
		return null;
	}

	@Override
	public Object parseRouteExtension(Node node) {
		System.out.println("Parse Route Extension");
		return null;
	}

	@Override
	public void writeGPXExtensionData(Node node, GPX wpt, Document doc) {
		System.out.println("Write ExtensionData");
	}

	@Override
	public void writeWaypointExtensionData(Node node, Waypoint wpt, Document doc) {
		System.out.println("Write WaypiontExtensionData");

	}

	@Override
	public void writeTrackExtensionData(Node node, Track wpt, Document doc) {
		System.out.println("Track extension");

	}

	@Override
	public void writeRouteExtensionData(Node node, Route wpt, Document doc) {
		System.out.println("Write Route Extension");
	}

}

If you want to code.. let me know. It is still little buggy (the distance calculations seems to be a bit odd compared to endomondo ) but it works.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.