Skip to content

Commit

Permalink
#1 #10 Work in progress:
Browse files Browse the repository at this point in the history
-Add support for Garmin FIT files
-Documentation for Garmin FIT files support
-Add FIT test files

[ci skip]
  • Loading branch information
FABallemand committed Nov 17, 2023
1 parent adfecb4 commit 23d420e
Show file tree
Hide file tree
Showing 15 changed files with 433 additions and 212 deletions.
15 changes: 14 additions & 1 deletion docs/tutorials/parsing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,17 @@ In order to parse a KMZ file, simply create a new :py:class:`~ezgpx.gpx.GPX` obj
from ezGPX import GPX

# Parse KML file
gpx = GPX("file.kmz")
gpx = GPX("file.kmz")


FIT Files
^^^^^^^^^

In order to parse a FIT file, simply create a new :py:class:`~ezgpx.gpx.GPX` object with the path to the file.

::

from ezGPX import GPX

# Parse FIT file
gpx = GPX("file.fit")
3 changes: 2 additions & 1 deletion ezgpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
from .gpx_writer import *
from .kml_parser import *
from .kml_writer import *
from .parser import *
from .fit_parser import *
from .xml_parser import *
from .writer import *
1 change: 1 addition & 0 deletions ezgpx/fit_parser/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .fit_parser import *
135 changes: 135 additions & 0 deletions ezgpx/fit_parser/fit_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import os
from typing import Optional, Union
import logging
import pandas as pd
from datetime import datetime
from math import pi

from fitparse import FitFile

from ..parser import Parser, DEFAULT_PRECISION
from ..gpx_elements import Bounds, Copyright, Email, Extensions, Gpx, Link, Metadata, Person, Point, PointSegment, Route, TrackSegment, Track, WayPoint

class FitParser(Parser):
"""
Fit file parser.
"""

def __init__(self, file_path: Optional[str] = None) -> None:
"""
Initialize FitParser instance.
Args:
file_path (str, optional): Path to the file to parse. Defaults to None.
"""
if not (file_path.endswith(".fit") or file_path.endswith(".FIT")):
return
super().__init__(file_path)

if self.file_path is not None and os.path.exists(self.file_path):
self.parse()
else:
logging.warning("File path does not exist")

def set_time_format(self, time):
"""
Set the time format used in FIT file.
"""
if time is None:
logging.warning("No time element in FIT file.")
return

try:
d = datetime.strptime(time, "%Y-%m-%dT%H:%M:%S.%fZ")
self.time_format = "%Y-%m-%dT%H:%M:%S.%fZ"
except:
self.time_format = "%Y-%m-%dT%H:%M:%SZ" # default time format

def semicircles_to_deg(self, list):
"""
Convert semicircle data from FIT file to dms data.
Args:
list (list): List of semicircle values.
Returns:
list: List of dms values.
"""
const = 180 / 2**31
list = map(lambda x: const * x, list)
return list

def _parse(self):
"""
Parse FIT file and store data in a Gpx element.
"""
lat_data = []
lon_data = []
alt_data = []
time_data = []

units = {"alt" : "", "lat": "", "lon": ""}

fit_file = FitFile(self.file_path)

for record in fit_file.get_messages("record"):
for record_data in record:
if record_data.name == "position_lat":
lat_data.append(record_data.value)
if units["lat"] == "":
units["lat"] = record_data.units
self.precisions["lat_lon"] = self.find_precision(record_data.value) # Temporary (may change if units["lat"] == "semicircles")
if record_data.name == "position_long":
lon_data.append(record_data.value)
if units["lon"] == "":
units["lon"] = record_data.units
if record_data.name == "altitude":
alt_data.append(record_data.value)
if units["alt"] == "":
units["alt"] = record_data.units
self.precisions["elevation"] = self.find_precision(record_data.value)
if record_data.name == "timestamp":
time_data.append(record_data.value)
self.set_time_format(record_data.value)

# Convert semicircles data to radians ??
if units["lat"] == "semicircles":
self.precisions["lat_lon"] = DEFAULT_PRECISION
lat_data = self.semicircles_to_deg(lat_data)
if units["lon"] == "semicircles":
lon_data = self.semicircles_to_deg(lon_data)

# Store FIT data in Gpx element
trkpt = []
for lat, lon, alt, time in list(zip(lat_data, lon_data, alt_data, time_data)):
trkpt.append(WayPoint("trkpt", lat, lon, alt, time))
trkseg = TrackSegment(trkpt=trkpt)
trk = Track(trkseg=[trkseg])
self.gpx.tracks = [trk]

def add_properties(self):
self.gpx.creator = "ezGPX"
self.gpx.xmlns = "http://www.topografix.com/GPX/1/1"
self.gpx.version = "1.1"
self.gpx.xmlns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
self.gpx.xsi_schema_location = ["http://www.topografix.com/GPX/1/1", "http://www.topografix.com/GPX/1/1/gpx.xsd"]

def parse(self) -> Gpx:
"""
Parse Fit file.
Returns:
Gpx: Gpx instance.
"""
# Parse FIT file
try:
self._parse()
except Exception as err:
logging.exception(f"Unexpected {err}, {type(err)}.\nUnable to parse FIT file.")
raise

# Add properties
self.add_properties()

logging.debug("Parsing complete!!")
return self.gpx
32 changes: 26 additions & 6 deletions ezgpx/gpx/gpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import pandas as pd
from math import degrees

from fitparse import FitFile

from matplotlib.axes import Axes
from matplotlib.figure import Figure
import matplotlib.colors
Expand All @@ -22,6 +24,7 @@
from ..gpx_elements import Gpx, WayPoint
from ..gpx_parser import GPXParser
from ..kml_parser import KMLParser
from ..fit_parser import FitParser
from ..gpx_writer import GPXWriter
from ..kml_writer import KMLWriter
from ..utils import EARTH_RADIUS
Expand All @@ -48,32 +51,49 @@ def __init__(self, file_path: Optional[str] = None, check_schemas: bool = True,
"""
if file_path is not None and os.path.exists(file_path):
self.file_path: str = file_path
self.gpx: Gpx = None
self.gpx_parser: GPXParser = None
self.kml_parser: KMLParser = None
self.fit_parser: FitParser = None
self.precisions: Dict = None
self.time_format: str = None

# GPX
if file_path.endswith(".gpx"):
self.gpx_parser: GPXParser = GPXParser(file_path, check_schemas, extensions_schemas)
self.gpx: Gpx = self.gpx_parser.gpx
self.gpx_parser = GPXParser(file_path, check_schemas, extensions_schemas)
self.gpx = self.gpx_parser.gpx
self.precisions = self.gpx_parser.precisions
self.time_format = self.gpx_parser.time_format

# KML
elif file_path.endswith(".kml"):
self.kml_parser: KMLParser = KMLParser(file_path, check_schemas, extensions_schemas)
self.gpx: Gpx = self.kml_parser.gpx
self.kml_parser = KMLParser(file_path, check_schemas, extensions_schemas)
self.gpx = self.kml_parser.gpx
self.precisions = self.kml_parser.precisions
self.time_format = self.kml_parser.time_format

# KMZ
elif file_path.endswith(".kmz"):
kmz = ZipFile(file_path, 'r')
kmls = [info.filename for info in kmz.infolist() if info.filename.endswith(".kml")]
if "doc.kml" not in kmls:
logging.warning("Unable to parse this file: Expected to find doc.kml inside KMZ file.")
kml = kmz.open("doc.kml", 'r').read()
self.write_tmp_kml("tmp.kml", kml)
self.kml_parser: KMLParser = KMLParser("tmp.kml", check_schemas, extensions_schemas)
self.gpx: Gpx = self.kml_parser.gpx
self.kml_parser = KMLParser("tmp.kml", check_schemas, extensions_schemas)
self.gpx = self.kml_parser.gpx
self.precisions = self.kml_parser.precisions
self.time_format = self.kml_parser.time_format
os.remove("tmp.kml")

# FIT
elif file_path.endswith(".fit"):
self.fit_parser = FitParser(file_path)
self.gpx = self.fit_parser.gpx
self.precisions = self.fit_parser.precisions
self.time_format = self.fit_parser.time_format

# NOT SUPPORTED
else:
logging.error("Unable to parse this type of file...\nYou may consider renaming your file with the proper file extension.")
self.gpx_writer: GPXWriter = GPXWriter(
Expand Down
19 changes: 12 additions & 7 deletions ezgpx/gpx_parser/gpx_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from datetime import datetime
import xml.etree.ElementTree as ET

from ..parser import Parser
from ..xml_parser import XMLParser
from ..gpx_elements import Bounds, Copyright, Email, Extensions, Gpx, Link, Metadata, Person, Point, PointSegment, Route, TrackSegment, Track, WayPoint

class GPXParser(Parser):
class GPXParser(XMLParser):
"""
GPX file parser.
"""
Expand All @@ -21,12 +21,17 @@ def __init__(self, file_path: Optional[str] = None, check_schemas: bool = True,
check_schemas (bool, optional): Toggle schema verification during parsing. Defaults to True.
extensions_schemas (bool, optional): Toggle extensions schema verificaton durign parsing. Requires internet connection and is not guaranted to work. Defaults to False.
"""
if file_path.endswith(".kml"):
if not file_path.endswith(".gpx"):
return
super().__init__(file_path, check_schemas, extensions_schemas)

self.name_space: dict = {"topo": "http://www.topografix.com/GPX/1/1"}

super().__init__(file_path,
{"topo": "http://www.topografix.com/GPX/1/1"},
check_schemas,
extensions_schemas)

print(f"GPX FILE PATH = {self.file_path}")
print(f"GPX NAME SPACE = {self.name_space}")
print(f"GPX TIME FORMAT = {self.time_format}")

if self.file_path is not None and os.path.exists(self.file_path):
self.parse()
else:
Expand Down
16 changes: 7 additions & 9 deletions ezgpx/kml_parser/kml_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from datetime import datetime
import xml.etree.ElementTree as ET

from ..parser import Parser
from ..xml_parser import XMLParser
from ..gpx_elements import Bounds, Copyright, Email, Extensions, Gpx, Link, Metadata, Person, Point, PointSegment, Route, TrackSegment, Track, WayPoint

class KMLParser(Parser):
class KMLParser(XMLParser):
"""
KML file parser.
"""
Expand All @@ -21,14 +21,12 @@ def __init__(self, file_path: Optional[str] = None, check_schemas: bool = True,
check_schemas (bool, optional): Toggle schema verification during parsing. Defaults to True.
extensions_schemas (bool, optional): Toggle extensions schema verificaton durign parsing. Requires internet connection and is not guaranted to work. Defaults to False.
"""
if file_path.endswith(".gpx"):
if not file_path.endswith(".kml"):
return
super().__init__(file_path, check_schemas, extensions_schemas)

self.xml_tree: ET.ElementTree = None
self.kml_tree: ET.Element = None

self.name_space: dict = {"opengis": "http://www.opengis.net/kml/2.2"}
super().__init__(file_path,
{"opengis": "http://www.opengis.net/kml/2.2"},
check_schemas,
extensions_schemas)

if self.file_path is not None and os.path.exists(self.file_path):
self.parse()
Expand Down
Loading

0 comments on commit 23d420e

Please sign in to comment.