FIT - Flexible and Interoperable Transfer - is a protocol designed for storing and sharing data from fitness and health devices.
Since getting a Coros running watch in July 2022, I've been exporting the FIT file data to Dropbox after every run.
Having all of this data laying around seemed like a good excuse for a toy project.1 I haven't done much web programming in the last five years, so I'm building a little web app with django.
FIT data
The data I'm most interested in are the Session, Lap, and Record types from each file.
Sessions
capture aggregated data about your run - things like total distance, average heart rate, average speed, etc.
Laps
capture aggregated data about a particular lap of your run. By default, my watch creates one lap every mile. The fields here are similar to sessions - average heart rate, average speed, etc.
Records
are the raw data about the run. My watch creates a new "record" every second of the run. It captures my latitude and longitude, as well as things like my heart rate, speed, cadence, estimated power output (watts), step length, etc.
To relate this data back to its source file, I've created one additional type called Activity
. This contains the source filename and date, and also acts as a foreign key on the Session
, Lap
, and Record
tables.
Mapping each of these to a django model looks like this (I've omitted many fields for the sake of conciseness):
from django.db import models
class Activity(models.Model):
source_filename = models.FilePathField(unique=True)
began_at = models.DateTimeField(null=True)
class Session(models.Model):
activity = models.ForeignKey(Activity, on_delete=models.CASCADE)
start_time = models.DateTimeField()
total_elapsed_time = models.FloatField()
avg_heart_rate = models.PositiveSmallIntegerField()
# ... many more fields
class Lap(models.Model):
activity = models.ForeignKey(Activity, on_delete=models.CASCADE)
total_elapsed_time = models.FloatField()
avg_heart_rate = models.PositiveSmallIntegerField()
# ... more fields omitted
class Record(models.Model):
activity = models.ForeignKey(Activity, on_delete=models.CASCADE)
timestamp = models.DateTimeField()
position_lat = models.CharField(max_length=255, null=True)
position_long = models.CharField(max_length=255, null=True)
heart_rate = models.PositiveSmallIntegerField(null=True)
# ... more fields omitted
Ingest command
Django allows you to register custom commands with your application that can be run via manage.py
. This is useful for standalone scripts or ones that you'll want to regularly run.
First, some helper functions to use within the command:
def convert_frame_to_dict(frame) -> Dict[str, Any]:
return {field.name: field.value
for field in frame.fields}
Data rows from the FIT file are message objects with a property containing the fields, and each field containing a name and value. convert_frame_to_dict
converts these to dictionaries so they're easier to work with.
def extract_datetime_from_filename(filename: str) -> str:
regex = r"([0-9]+).fit"
match = re.search(regex, filename)
if not match:
return
try:
dt = datetime.strptime(match.group(1), "%Y%m%d%H%M%S")
except ValueError:
return
return dt
File names from Coros contain a timestamp marking when the run began (e.g. Run20230520091606.fit
). extract_datetime_from_filename
parses out that timestamp so it can be stored in the database.
def determine_files_for_ingest(filepaths: List[Path]) -> List[Path]:
"""
Given a list of filepaths, compare with DB to determine which ones should be ingested.
Returns a list of filepaths in need of ingest.
"""
# get a list of all the files in the DB
ingested_files = Activity.objects.values_list("source_filename", flat=True)
needs_ingest = []
for fp in filepaths:
if fp.name not in ingested_files:
needs_ingest.append(fp)
return needs_ingest
Since I will call this command after every run, determine_files_for_ingest
compares the FIT file directory with what's already been loaded into the database.
With fitdecode doing the heavy lifting, my custom command looks like below:
class Command(BaseCommand):
help = "Loads FIT file(s) into the database"
def add_arguments(self, parser):
parser.add_argument("fitfile_dir", type=str)
def handle(self, *args, **options):
fitfile_dir = Path(options["fitfile_dir"])
filepaths = fitfile_dir.glob("*Run*.fit")
# filter out any files that are already in the DB
needs_ingest = determine_files_for_ingest(filepaths)
self.stdout.write(f"Found {len(needs_ingest)} files to ingest")
if not needs_ingest:
return
# extract date from filename so we can process in chronological order
filepaths = {extract_datetime_from_filename(fp.name): fp
for fp in needs_ingest}
for _, fp in sorted(filepaths.items()):
self.stdout.write(f"Loading {fp.name} to database")
try:
self.process_fitfile(fp)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Failed to load {fp.name} to database", e)
)
self.stdout.write(
self.style.SUCCESS(f"Successfully loaded {fp.name} to database")
)
def process_fitfile(self, filepath: Path):
with fitdecode.FitReader(filepath) as fit:
activity = Activity.objects.create(
source_filename=filepath.name,
began_at=extract_datetime_from_filename(filepath.name)
)
for frame in fit:
if frame.frame_type != fitdecode.FIT_FRAME_DATA:
continue
data = convert_frame_to_dict(frame)
if frame.name == 'session':
self.create_session(data, activity)
if frame.name == 'lap':
self.create_lap(data, activity)
if frame.name == 'record':
self.create_record(data, activity)
I've ommitted the code for create_session
, create_lap
, and create_record
. Each just instantiates the appropriate model and calls its save
method.
Running the command with manage.py
:
$ python manage.py ingest_fitfiles ../Apps/coros
The payoff? Now I can easily write SQL queries against my running data!
$ sqlite3 db.sqlite3 < sql/weekly_totals.sql -table
+---------+---------------+-------+--------------+----------+
| week | hours_running | miles | feet_climbed | calories |
+---------+---------------+-------+--------------+----------+
| 2023-14 | 3.5 | 21.8 | 883.0 | 2408.0 |
| 2023-15 | 3.6 | 22.2 | 1316.0 | 2375.0 |
| 2023-16 | 4.3 | 28.3 | 1486.0 | 3102.0 |
| 2023-17 | 4.8 | 30.6 | 2139.0 | 3328.0 |
| 2023-18 | 2.2 | 14.5 | 912.0 | 1703.0 |
| 2023-19 | 4.8 | 31.0 | 1686.0 | 3626.0 |
| 2023-20 | 5.2 | 33.6 | 1765.0 | 3871.0 |
+---------+---------------+-------+--------------+----------+
Sure, Strava already does a lot of this, but where's the fun in that?
- My history with side projects is one of abandonment.