| @ -0,0 +1,360 @@ | |||
| This post looks at how you can aggregate and visualize Fitbit GPS data since there is no built-in functionality to do this on the Fitbit website. | |||
| Before you read this post, check out my two other posts on using Fitbit data: | |||
| - [A Closer Look at Fitbit Data](https://jrtechs.net/data-science/a-closer-look-at-fitbit-data) | |||
| - [Graphing my Life with Matplotlib](https://jrtechs.net/data-science/graphing-my-life-with-matplotlib) | |||
| # Getting the Data | |||
| There are two options that we can use to fetch data from Fitbit: | |||
| - Fitbit's Data Export Tool | |||
| - Fitbit's API | |||
| ## Exporting from the Website | |||
| The easiest way to export data from Fitbit is to use the data export tool on the website. | |||
|  | |||
| The Fitbit data archive is very organized and has all information relating to your account. | |||
| The data is organized in separate JSON files labeled by date. Fitbit keeps around 1MB of data on you per day; most of this data is from the heart rate sensors. | |||
| Although 1MB of data may sound like a ton of data, it is probably a lot less if you store it in a more compact format than JSON. | |||
| The problem with the data export is that it can take a while to export and that it doesn't include the GPS data in [TCX](https://en.wikipedia.org/wiki/Training_Center_XML) format. | |||
| In the exercise.json files, we can view all activities with logged GPS data, but the data archives themselves don't include the TCX data-- only a link to it on the website. | |||
| ```json | |||
| },{ | |||
| "logId" : 384529004, | |||
| "activityName" : "Run", | |||
| "activityTypeId" : 90009, | |||
| "activityLevel" : [{ | |||
| "minutes" : 0, | |||
| "name" : "sedentary" | |||
| },{ | |||
| "minutes" : 0, | |||
| "name" : "lightly" | |||
| },{ | |||
| "minutes" : 0, | |||
| "name" : "fairly" | |||
| },{ | |||
| "minutes" : 17, | |||
| "name" : "very" | |||
| }], | |||
| "calories" : 184, | |||
| "distance" : 1.872894, | |||
| "distanceUnit" : "Mile", | |||
| "duration" : 1077000, | |||
| "activeDuration" : 1077000, | |||
| "steps" : 2605, | |||
| "source" : { | |||
| "type" : "app", | |||
| "name" : "Fitbit for Android", | |||
| "id" : "228VSR", | |||
| "url" : "https://www.fitbit.com/android", | |||
| "trackerFeatures" : ["GPS","STEPS","CALORIES","PACE","DISTANCE"] | |||
| }, | |||
| "logType" : "mobile_run", | |||
| "manualValuesSpecified" : { | |||
| "calories" : false, | |||
| "distance" : false, | |||
| "steps" : false | |||
| }, | |||
| "tcxLink" : "https://www.fitbit.com/activities/exercise/384529004?export=tcx", | |||
| "speed" : 6.26036991643454, | |||
| "pace" : 575.0458915453837, | |||
| "lastModified" : "03/22/21 21:22:03", | |||
| "startTime" : "03/22/21 21:04:02", | |||
| "originalStartTime" : "03/22/21 21:04:02", | |||
| "originalDuration" : 1077000, | |||
| "hasGps" : true, | |||
| "shouldFetchDetails" : true, | |||
| "hasActiveZoneMinutes" : false | |||
| },{ | |||
| ``` | |||
| ## API | |||
| Since the data export doesn't include the TCX data we desire, we need to use the Fitbit API. | |||
| First, you need to register a Fitbit [developer account](https://dev.fitbit.com/apps) and then register an app. | |||
| For the redirect URL, put "http://localhost:9000/auth/fitbit/callback". | |||
| To authenticate with the application, you will need to OAuth 2 client ID and the client secret. | |||
| Check out my first post on Fitbit to learn more about how to set up an [applicatoin with Fitbit](https://jrtechs.net/data-science/a-closer-look-at-fitbit-data), and check out the code for this project on my [Github](https://github.com/jrtechs/HomePage). | |||
| # Downloading All GPS Logs | |||
| Within the Fitbit API, there is no place to download all of your activities -- since that would return a lot of data. | |||
| Instead, Fitbit allows you to download a maximum of 100 events from a time, and you can specify a start and end range for your query. | |||
| ```javascript | |||
| function fetchActivities(result, startTime) | |||
| { | |||
| return new Promise((resolve, reject)=> | |||
| { | |||
| queryAPI(result, 'https://api.fitbit.com/1/user/-/activities/list.json?beforeDate=' + startTime + '&sort=desc&offset=0&limit=100').then((data)=> | |||
| { | |||
| if(data != false) | |||
| { | |||
| resolve(data.activities); | |||
| } | |||
| reject("Error with API, are you authenticated") | |||
| }); | |||
| }); | |||
| } | |||
| ``` | |||
| I wrote a helper function to fetch all the "mobile_runs" events recursively. | |||
| In my case, since I have the Fitbit Altra, so the only way for me to get GPS data is to tether my Fitbit to my phone and log the start/end. | |||
| With the Fitbits that have built-in GPS, other events have GPS TCX data associated with them. | |||
| ```javascript | |||
| function fetchAllRuns(result, startTime) | |||
| { | |||
| var runs = []; | |||
| console.log(startTime); | |||
| return new Promise((resolve, reject)=> | |||
| { | |||
| fetchActivities(result, startTime).then((events)=> | |||
| { | |||
| if(events.length < 10) | |||
| { | |||
| resolve(runs); | |||
| } | |||
| else | |||
| { | |||
| for(var i = 0; i < events.length; i++) | |||
| { | |||
| if(events[i].logType === "mobile_run") | |||
| { | |||
| console.log(events[i]); | |||
| runs.push(events[i]); | |||
| } | |||
| } | |||
| var newStart = events[events.length -1].startTime.slice(0, -10); | |||
| fetchAllRuns(result, newStart).then((run_rec)=> | |||
| { | |||
| resolve(runs.concat(run_rec)); | |||
| }).catch((err)=> | |||
| { | |||
| reject(err); | |||
| }); | |||
| } | |||
| }).catch((error)=> | |||
| { | |||
| reject(error); | |||
| }); | |||
| }); | |||
| } | |||
| ``` | |||
| After we have a list of our TCX data, we can simply write it to disk for later use. | |||
| ```javascript | |||
| function saveTCX(result, tcxID) | |||
| { | |||
| return new Promise((resolve, reject)=> | |||
| { | |||
| fetchTCX(result, tcxID).then((tcx)=> | |||
| { | |||
| utils.saveFile(tcxID + ".tcx", tcx); | |||
| resolve(); | |||
| }).catch((err)=> | |||
| { | |||
| reject(err); | |||
| }) | |||
| }); | |||
| } | |||
| ``` | |||
| I have this process invoked via an endpoint call to enable me to use Fitbit's OAuth2 authentication. | |||
| ```javascript | |||
| app.get('/save-all-tcx', (request, result)=> | |||
| { | |||
| var tcxID = request.params.id; | |||
| var startTime = '2030-06-01T00:00:00'; | |||
| fetchAllRuns(result, startTime).then((data)=> | |||
| { | |||
| var promises = []; | |||
| for(var i =0; i < data.length; i++) | |||
| { | |||
| promises.push(saveTCX(result, data[i].logId)); | |||
| } | |||
| Promise.all(promises).then(function(content) | |||
| { | |||
| result.write("All events saved"); | |||
| result.end(); | |||
| }).catch(function(err) | |||
| { | |||
| console.log(err); | |||
| throw err; | |||
| }); | |||
| }).catch((error)=> | |||
| { | |||
| console.log(error); | |||
| result.writeHead(500, {'Content-Type': 'text/json'}); | |||
| result.end(); | |||
| }); | |||
| }); | |||
| ``` | |||
| # Visualizing GPS Data as KML | |||
| Now that we have all of our TCX data saved, we can parse the data to aggregate it into a single file. | |||
| TCX data is simply XML data that is easy to parse with several different packages. | |||
| In my case, I am [Beautiful Soup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) in Python to parse the XML. | |||
| ```xml | |||
| <?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |||
| <TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"> | |||
| <Activities> | |||
| <Activity Sport="Running"> | |||
| <Id>2019-09-03T16:52:50.000-04:00</Id> | |||
| <Lap StartTime="2019-09-03T16:52:50.000-04:00"> | |||
| <TotalTimeSeconds>1313.0</TotalTimeSeconds> | |||
| <DistanceMeters>4618.291573209821</DistanceMeters> | |||
| <Calories>265</Calories> | |||
| <Intensity>Active</Intensity> | |||
| <TriggerMethod>Manual</TriggerMethod> | |||
| <Track> | |||
| <Trackpoint> | |||
| <Time>2019-09-03T16:52:50.000-04:00</Time> | |||
| <Position> | |||
| <LatitudeDegrees>43.0</LatitudeDegrees> | |||
| <LongitudeDegrees>-77.6</LongitudeDegrees> | |||
| </Position> | |||
| <AltitudeMeters>123.4</AltitudeMeters> | |||
| <DistanceMeters>0.0</DistanceMeters> | |||
| </Trackpoint> | |||
| <Trackpoint> | |||
| <Time>2019-09-03T16:52:51.000-04:00</Time> | |||
| <Position> | |||
| <LatitudeDegrees>43.0</LatitudeDegrees> | |||
| <LongitudeDegrees>-77.6</LongitudeDegrees> | |||
| </Position> | |||
| <AltitudeMeters>123.4</AltitudeMeters> | |||
| <DistanceMeters>0.28944606511402504</DistanceMeters> | |||
| </Trackpoint> | |||
| ``` | |||
| A quick note on the data that I am showing: it is all out of date, and I no longer live in these neighborhoods. | |||
| Additionally, for the TCX example, I truncated the longitude/latitude results, but, from Fitbit, the TSV data has fifteen decimal places. | |||
| Using Beautiful Soup, it is straightforward to open a TCX file and get all the longitude/latitude values. | |||
| ```Python | |||
| from bs4 import BeautifulSoup | |||
| import glob | |||
| import os.path | |||
| def parseTCX(filename): | |||
| file = open(filename) | |||
| xml_file = file.read() | |||
| soup = BeautifulSoup(xml_file, 'lxml') | |||
| id = soup.find("id").text # gets the UTC timestamp | |||
| lats = [] | |||
| longs = [] | |||
| for tag in soup.find_all("trackpoint"): | |||
| lats.append(tag.find("latitudedegrees").text) | |||
| longs.append(tag.find("longitudedegrees").text) | |||
| return id[:-10], lats, longs | |||
| ``` | |||
| I am converting all the TCX files into a single [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) file to make it possible for me to visualize with various GEO tools. | |||
| ```python | |||
| def single_run(id, lats, longs): | |||
| locString = "" | |||
| for i in range(0, len(lats)): | |||
| locString += longs[i] + "," + lats[i] + " " | |||
| value = """ | |||
| <Placemark> | |||
| <name>{0}</name> | |||
| <description>xx Miles</description> | |||
| <Style> | |||
| <LineStyle> | |||
| <color>ff0000e6</color> | |||
| <width>4</width> | |||
| </LineStyle> | |||
| </Style> | |||
| <LineString> | |||
| <tessellate>1</tessellate> | |||
| <altitudeMode>clampToGround</altitudeMode> | |||
| <coordinates>{1}</coordinates> | |||
| </LineString> | |||
| </Placemark> | |||
| """ | |||
| return value.format(id, locString) | |||
| def convertToKML(): | |||
| base_path = os.path.dirname(os.path.realpath(__file__)) | |||
| files = glob.glob(base_path + "/tcx/*.tcx") | |||
| header = """<?xml version="1.0" encoding="utf-8" standalone="yes"?> | |||
| <kml xmlns="http://www.opengis.net/kml/2.2"> | |||
| <Document> | |||
| <name><![CDATA[38415617200]]></name> | |||
| <visibility>1</visibility> | |||
| <open>1</open> | |||
| <Folder id="Runs"> | |||
| <name>Tracks</name> | |||
| <visibility>1</visibility> | |||
| <open>0</open> | |||
| """ | |||
| footer = """ | |||
| </Folder> | |||
| </Document> | |||
| </kml> | |||
| """ | |||
| o_file = open("outputKML.kml", "w") | |||
| o_file.write(header) | |||
| for file in files: | |||
| id, lats, longs = parseTCX(file) | |||
| o_file.write(single_run(id, lats, longs)) | |||
| print(files) | |||
| o_file.write(footer) | |||
| o_file.close() | |||
| ``` | |||
| Now that we have a single KML file with all of our running data coordinates, we can visualize it with google maps -- or any number of other KML tools. | |||
| This visualization is nifty since it lets you see every place that you have run. | |||
|  | |||
| As accurate as GPS may appear on our phones, we can start to see inaccuracies in our data when looking at the aggregate plot. | |||
| For example, occasionally, the GPS will drop and result in a plot that has "random teleportation." | |||
|  | |||
| Additionally, GPS on our phones is not pinpoint accurate. | |||
| By interpolating the data as we go, each track individually appears smooth. However, the overall route is not all that accurate. | |||
| In the following image, we see that although I stay strictly to the sidewalk and make a sharp turn, it puts me all over the road and in the yard. | |||
|  | |||
| # Future Work for Project | |||
| In the future, I want to transfer more metadata from the TCV files and include it in the KML files like total distance, average speed, mile markers. | |||
| Additionally, visualizing this on a website that auto-pulls new TCX data and updates an embedded map would be interesting. | |||
| The end goal is to do clustering on my TCX data to identify unique running routes that I have taken so that I can either plan new running routes or select a course that is the desired length. | |||