Source code for upoints.gpx

#
# coding=utf-8
"""gpx - Imports GPS eXchange format data files."""
# Copyright © 2008-2017  James Rowe <jnrowe@gmail.com>
#
# This file is part of upoints.
#
# upoints is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# upoints is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# upoints.  If not, see <http://www.gnu.org/licenses/>.

import time

from operator import attrgetter

from lxml import etree

from . import (point, utils)
from ._version import web as ua_string


GPX_NS = 'http://www.topografix.com/GPX/1/1'
etree.register_namespace('gpx', GPX_NS)

create_elem = utils.element_creator(GPX_NS)

GPX_ELEM_ATTRIB = {
    'creator': ua_string,
    'version': '1.1',
    '{http://www.w3.org/2001/XMLSchema-instance}schemaLocation':
        '%s http://www.topografix.com/GPX/1/1/gpx.xsd' % GPX_NS,
}


class _GpxElem(point.TimedPoint):
    """Abstract class for representing an element from GPX data files.

    .. versionadded:: 0.11.0
    """

    __slots__ = ('name', 'description', 'elevation', 'time', )

    _elem_name = None

    def __init__(self, latitude, longitude, name=None, description=None,
                 elevation=None, time=None):
        """Initialise a new ``_GpxElem`` object.

        Args:
            latitude (float): Element's latitude
            longitude (float): Element's longitude
            name (str): Name for Element
            description (str): Element's description
            elevation (float): Element's elevation
            time (utils.Timestamp): Time the data was generated
        """
        super(_GpxElem, self).__init__(latitude, longitude, time=time)
        self.name = name
        self.description = description
        self.elevation = elevation

    def __str__(self):
        """Pretty printed location string.

        Returns:
            str: Human readable string representation of :class:`_GpxElem`
                object
        """
        location = super(_GpxElem, self).__format__('dms')
        if self.elevation:
            location += ' @ %sm' % self.elevation
        if self.time:
            location += ' on %s' % self.time.isoformat()
        if self.name:
            text = ['%s (%s)' % (self.name, location), ]
        else:
            text = [location, ]
        if self.description:
            text.append('[%s]' % self.description)
        return ' '.join(text)

    def togpx(self):
        """Generate a GPX waypoint element subtree.

        Returns:
            etree.Element: GPX element
        """
        element = create_elem(self.__class__._elem_name,
                              {'lat': str(self.latitude),
                               'lon': str(self.longitude)})
        if self.name:
            element.append(create_elem('name', text=self.name))
        if self.description:
            element.append(create_elem('desc', text=self.description))
        if self.elevation:
            element.append(create_elem('ele', text=str(self.elevation)))
        if self.time:
            element.append(create_elem('time', text=self.time.isoformat()))
        return element


class _SegWrap(list):
    """Abstract class for representing segmented elements from GPX data files.

    .. versionadded:: 0.12.0
    """

    def __init__(self, gpx_file=None, metadata=None):
        """Initialise a new ``_SegWrap`` object."""
        super(_SegWrap, self).__init__()
        self.metadata = metadata if metadata else _GpxMeta()
        self._gpx_file = gpx_file
        if gpx_file:
            self.import_locations(gpx_file)

    def distance(self, method='haversine'):
        """Calculate distances between locations in segments.

        Args:
            method (str): Method used to calculate distance

        Returns:
            list of list of float: Groups of distance between points in
                segments
        """
        distances = []
        for segment in self:
            if len(segment) < 2:
                distances.append([])
            else:
                distances.append(segment.distance(method))
        return distances

    def bearing(self, format='numeric'):
        """Calculate bearing between locations in segments.

        Args:
            format (str): Format of the bearing string to return

        Returns:
            list of list of float: Groups of bearings between points in
                segments
        """
        bearings = []
        for segment in self:
            if len(segment) < 2:
                bearings.append([])
            else:
                bearings.append(segment.bearing(format))
        return bearings

    def final_bearing(self, format='numeric'):
        """Calculate final bearing between locations in segments.

        Args:
            format (str): Format of the bearing string to return

        Returns:
            list of list of float: Groups of bearings between points in
                segments
        """
        bearings = []
        for segment in self:
            if len(segment) < 2:
                bearings.append([])
            else:
                bearings.append(segment.final_bearing(format))
        return bearings

    def inverse(self):
        """Calculate the inverse geodesic between locations in segments.

        Returns:
            list of 2-tuple of float: Groups in bearing and distance between
                points in segments
        """
        inverses = []
        for segment in self:
            if len(segment) < 2:
                inverses.append([])
            else:
                inverses.append(segment.inverse())
        return inverses

    def midpoint(self):
        """Calculate the midpoint between locations in segments.

        Returns:
            list of Point: Groups of midpoint between points in segments
        """
        midpoints = []
        for segment in self:
            if len(segment) < 2:
                midpoints.append([])
            else:
                midpoints.append(segment.midpoint())
        return midpoints

    def range(self, location, distance):
        """Test whether locations are within a given range of ``location``.

        Args:
            location (Point): Location to test range against
            distance (float): Distance to test location is within

        Returns:
            list of list of Point: Groups of points in range per segment
        """
        return (segment.range(location, distance) for segment in self)

    def destination(self, bearing, distance):
        """Calculate destination locations for given distance and bearings.

        Args:
            bearing (float): Bearing to move on in degrees
            distance (float): Distance in kilometres

        Returns:
            list of list of Point: Groups of points shifted by ``distance``
                and ``bearing``
        """
        return (segment.destination(bearing, distance) for segment in self)
    forward = destination

    def sunrise(self, date=None, zenith=None):
        """Calculate sunrise times for locations.

        Args:
            date (datetime.date): Calculate rise or set for given date
            zenith (str): Calculate sunrise events, or end of twilight
        Returns:
            list of list of datetime.datetime: The time for the sunrise for
                each point in each segment
        """
        return (segment.sunrise(date, zenith) for segment in self)

    def sunset(self, date=None, zenith=None):
        """Calculate sunset times for locations.

        Args:
            date (datetime.date): Calculate rise or set for given date
            zenith (str): Calculate sunset events, or start of twilight times

        Returns:
            list of list of datetime.datetime: The time for the sunset for each
                point in each segment
        """
        return (segment.sunset(date, zenith) for segment in self)

    def sun_events(self, date=None, zenith=None):
        """Calculate sunrise/sunset times for locations.

        Args:
            date (datetime.date): Calculate rise or set for given date
            zenith (str): Calculate rise/set events, or twilight times

        Returns:
            list of list of 2-tuple of datetime.datetime: The time for the
                sunrise and sunset events for each point in each segment
        """
        return (segment.sun_events(date, zenith) for segment in self)

    def to_grid_locator(self, precision='square'):
        """Calculate Maidenhead locator for locations.

        Args:
            precision (str): Precision with which generate locator string

        Returns:
            list of list of str: Groups of Maidenhead locator for each point in
                segments
        """
        return (segment.to_grid_locator(precision) for segment in self)

    def speed(self):
        """Calculate speed between locations per segment.

        Returns:
            list of list of float: Speed between points in each segment in km/h
        """
        return (segment.speed() for segment in self)


class _GpxMeta(object):
    """Class for representing GPX global metadata.

    .. versionadded:: 0.12.0
    """

    __slots__ = ('name', 'desc', 'author', 'copyright', 'link', 'time',
                 'keywords', 'bounds', 'extensions')

    def __init__(self, name=None, desc=None, author=None, copyright=None,
                 link=None, time=None, keywords=None, bounds=None,
                 extensions=None):
        """Initialise a new `_GpxMeta` object.

        Args:
            name (str): Name for the export
            desc (str): Description for the GPX export
            author (dict): Author of the entire GPX data
            copyright (dict): Copyright data for the exported data
            link (list of str or dict): Links associated with the data
            time (utils.Timestamp):Time the data was generated
            keywords (str): Keywords associated with the data
            bounds (dict or list of Point): Area used in the data
            extensions (list of etree.Element): Any external data associated
                with the export
        """
        super(_GpxMeta, self).__init__()
        self.name = name
        self.desc = desc
        self.author = author if author else {}
        self.copyright = copyright if copyright else {}
        self.link = link if link else []
        self.time = time
        self.keywords = keywords
        self.bounds = bounds
        self.extensions = extensions

    def togpx(self):
        """Generate a GPX metadata element subtree.

        Returns:
            etree.Element: GPX metadata element
        """
        metadata = create_elem('metadata')
        if self.name:
            metadata.append(create_elem('name', text=self.name))
        if self.desc:
            metadata.append(create_elem('desc', text=self.desc))
        if self.author:
            element = create_elem('author')
            if self.author['name']:
                element.append(create_elem('name', text=self.author['name']))
            if self.author['email']:
                attr = dict(zip(self.author['email'].split('@'),
                                ('id', 'domain')))
                element.append(create_elem('email', attr))
            if self.author['link']:
                element.append(create_elem('link', text=self.author['link']))
            metadata.append(element)
        if self.copyright:
            if self.copyright['name']:
                author = {'author': self.copyright['name']}
            else:
                author = None
            element = create_elem('copyright', author)
            if self.copyright['year']:
                element.append(create_elem('year',
                                           text=self.copyright['year']))
            if self.copyright['license']:
                license = create_elem('license')
                element.append(license)
            metadata.append(element)
        if self.link:
            for link in self.link:
                if isinstance(link, basestring):
                    element = create_elem('link', {'href': link})
                else:
                    element = create_elem('link', {'href': link['href']})
                    if link['text']:
                        element.append(create_elem('text', text=link['text']))
                    if link['type']:
                        element.append(create_elem('type', text=link['type']))
                metadata.append(element)
        if isinstance(self.time, (time.struct_time, tuple)):
            text = time.strftime('%Y-%m-%dT%H:%M:%S%z', self.time)
        elif isinstance(self.time, utils.Timestamp):
            text = self.time.isoformat()
        else:
            text = time.strftime('%Y-%m-%dT%H:%M:%S%z')
        metadata.append(create_elem('time', text=text))
        if self.keywords:
            metadata.append(create_elem('keywords', text=self.keywords))
        if self.bounds:
            if not isinstance(self.bounds, dict):
                latitudes = list(map(attrgetter('latitude'), self.bounds))
                longitudes = list(map(attrgetter('longitude'), self.bounds))
                bounds = {
                    'minlat': str(min(latitudes)),
                    'maxlat': str(max(latitudes)),
                    'minlon': str(min(longitudes)),
                    'maxlon': str(max(longitudes)),
                }
            else:
                bounds = dict((k, str(v)) for k, v in self.bounds.items())
            metadata.append(create_elem('bounds', bounds))
        if self.extensions:
            element = create_elem('extensions')
            for i in self.extensions:
                element.append(i)
            metadata.append(self.extensions)
        return metadata

    def import_metadata(self, elements):
        """Import information from GPX metadata.

        Args:
            elements (etree.Element): GPX metadata subtree
        """
        metadata_elem = lambda name: etree.QName(GPX_NS, name)

        for child in elements.getchildren():
            tag_ns, tag_name = child.tag[1:].split('}')
            if not tag_ns == GPX_NS:
                continue
            if tag_name in ('name', 'desc', 'keywords'):
                setattr(self, tag_name, child.text)
            elif tag_name == 'time':
                self.time = utils.Timestamp.parse_isoformat(child.text)
            elif tag_name == 'author':
                self.author['name'] = child.findtext(metadata_elem('name'))
                aemail = child.find(metadata_elem('email'))
                if aemail:
                    self.author['email'] = '%s@%s' % (aemail.get('id'),
                                                      aemail.get('domain'))
                self.author['link'] = child.findtext(metadata_elem('link'))
            elif tag_name == 'bounds':
                self.bounds = {
                    'minlat': child.get('minlat'),
                    'maxlat': child.get('maxlat'),
                    'minlon': child.get('minlon'),
                    'maxlon': child.get('maxlon'),
                }
            elif tag_name == 'extensions':
                self.extensions = child.getchildren()
            elif tag_name == 'copyright':
                if child.get('author'):
                    self.copyright['name'] = child.get('author')
                self.copyright['year'] = child.findtext(metadata_elem('year'))
                self.copyright['license'] = child.findtext(metadata_elem('license'))
            elif tag_name == 'link':
                link = {
                    'href': child.get('href'),
                    'type': child.findtext(metadata_elem('type')),
                    'text': child.findtext(metadata_elem('text')),
                }
                self.link.append(link)


[docs]class Waypoint(_GpxElem): """Class for representing a waypoint element from GPX data files. .. versionadded:: 0.8.0 See also: _GpxElem """ __slots__ = ('name', 'description', ) _elem_name = 'wpt'
[docs]class Waypoints(point.TimedPoints): """Class for representing a group of ``Waypoint`` objects. .. versionadded:: 0.8.0 """ def __init__(self, gpx_file=None, metadata=None): """Initialise a new ``Waypoints`` object.""" super(Waypoints, self).__init__() self.metadata = metadata if metadata else _GpxMeta() self._gpx_file = gpx_file if gpx_file: self.import_locations(gpx_file)
[docs] def import_locations(self, gpx_file): """Import GPX data files. ``import_locations()`` returns a list with :class:`~gpx.Waypoint` objects. It expects data files in GPX format, as specified in `GPX 1.1 Schema Documentation`_, which is XML such as:: <?xml version="1.0" encoding="utf-8" standalone="no"?> <gpx version="1.1" creator="PocketGPSWorld.com" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"> <wpt lat="52.015" lon="-0.221"> <name>Home</name> <desc>My place</desc> </wpt> <wpt lat="52.167" lon="0.390"> <name>MSR</name> <desc>Microsoft Research, Cambridge</desc> </wpt> </gpx> The reader uses the :mod:`ElementTree` module, so should be very fast when importing data. The above file processed by ``import_locations()`` will return the following ``list`` object:: [Waypoint(52.015, -0.221, "Home", "My place"), Waypoint(52.167, 0.390, "MSR", "Microsoft Research, Cambridge")] Args: gpx_file (iter): GPX data to read Returns: list: Locations with optional comments .. _GPX 1.1 Schema Documentation: http://www.topografix.com/GPX/1/1/ """ self._gpx_file = gpx_file data = utils.prepare_xml_read(gpx_file, objectify=True) try: self.metadata.import_metadata(data.metadata) except AttributeError: pass for waypoint in data.wpt: latitude = waypoint.get('lat') longitude = waypoint.get('lon') try: name = waypoint.name.text except AttributeError: name = None try: description = waypoint.desc.text except AttributeError: description = None try: elevation = float(waypoint.ele.text) except AttributeError: elevation = None try: time = utils.Timestamp.parse_isoformat(waypoint.time.text) except AttributeError: time = None self.append(Waypoint(latitude, longitude, name, description, elevation, time))
[docs] def export_gpx_file(self): """Generate GPX element tree from ``Waypoints`` object. Returns: etree.ElementTree: GPX element tree depicting ``Waypoints`` object """ gpx = create_elem('gpx', GPX_ELEM_ATTRIB) if not self.metadata.bounds: self.metadata.bounds = self[:] gpx.append(self.metadata.togpx()) for place in self: gpx.append(place.togpx()) return etree.ElementTree(gpx)
[docs]class Trackpoint(_GpxElem): """Class for representing a trackpoint element from GPX data files. .. versionadded:: 0.10.0 See also: _GpxElem """ __slots__ = ('name', 'description', ) _elem_name = 'trkpt'
[docs]class Trackpoints(_SegWrap): """Class for representing a group of :class:`Trackpoint` objects. .. versionadded:: 0.10.0 """
[docs] def import_locations(self, gpx_file): """Import GPX data files. ``import_locations()`` returns a series of lists representing track segments with :class:`Trackpoint` objects as contents. It expects data files in GPX format, as specified in `GPX 1.1 Schema Documentation`_, which is XML such as:: <?xml version="1.0" encoding="utf-8" standalone="no"?> <gpx version="1.1" creator="upoints/0.12.2" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"> <trk> <trkseg> <trkpt lat="52.015" lon="-0.221"> <name>Home</name> <desc>My place</desc> </trkpt> <trkpt lat="52.167" lon="0.390"> <name>MSR</name> <desc>Microsoft Research, Cambridge</desc> </trkpt> </trkseg> </trk> </gpx> The reader uses the :mod:`ElementTree` module, so should be very fast when importing data. The above file processed by ``import_locations()`` will return the following ``list`` object:: [[Trackpoint(52.015, -0.221, "Home", "My place"), Trackpoint(52.167, 0.390, "MSR", "Microsoft Research, Cambridge")], ] Args: gpx_file (iter): GPX data to read Returns: list: Locations with optional comments .. _GPX 1.1 Schema Documentation: http://www.topografix.com/GPX/1/1/ """ self._gpx_file = gpx_file data = utils.prepare_xml_read(gpx_file, objectify=True) try: self.metadata.import_metadata(data.metadata) except AttributeError: pass for segment in data.trk.trkseg: points = point.TimedPoints() for trackpoint in segment.trkpt: latitude = trackpoint.get('lat') longitude = trackpoint.get('lon') try: name = trackpoint.name.text except AttributeError: name = None try: description = trackpoint.desc.text except AttributeError: description = None try: elevation = float(trackpoint.ele.text) except AttributeError: elevation = None try: time = utils.Timestamp.parse_isoformat(trackpoint.time.text) except AttributeError: time = None points.append(Trackpoint(latitude, longitude, name, description, elevation, time)) self.append(points)
[docs] def export_gpx_file(self): """Generate GPX element tree from ``Trackpoints``. Returns: etree.ElementTree: GPX element tree depicting ``Trackpoints`` objects """ gpx = create_elem('gpx', GPX_ELEM_ATTRIB) if not self.metadata.bounds: self.metadata.bounds = [j for i in self for j in i] gpx.append(self.metadata.togpx()) track = create_elem('trk') gpx.append(track) for segment in self: chunk = create_elem('trkseg') track.append(chunk) for place in segment: chunk.append(place.togpx()) return etree.ElementTree(gpx)
[docs]class Routepoint(_GpxElem): """Class for representing a ``rtepoint`` element from GPX data files. .. versionadded:: 0.10.0 See also: _GpxElem """ __slots__ = ('name', 'description', ) _elem_name = 'rtept'
[docs]class Routepoints(_SegWrap): """Class for representing a group of :class:`Routepoint` objects. .. versionadded:: 0.10.0 """
[docs] def import_locations(self, gpx_file): """Import GPX data files. ``import_locations()`` returns a series of lists representing track segments with :class:`Routepoint` objects as contents. It expects data files in GPX format, as specified in `GPX 1.1 Schema Documentation`_, which is XML such as:: <?xml version="1.0" encoding="utf-8" standalone="no"?> <gpx version="1.1" creator="upoints/0.12.2" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"> <rte> <rtept lat="52.015" lon="-0.221"> <name>Home</name> <desc>My place</desc> </rtept> <rtept lat="52.167" lon="0.390"> <name>MSR</name> <desc>Microsoft Research, Cambridge</desc> </rtept> </rte> </gpx> The reader uses the :mod:`ElementTree` module, so should be very fast when importing data. The above file processed by ``import_locations()`` will return the following ``list`` object:: [[Routepoint(52.015, -0.221, "Home", "My place"), Routepoint(52.167, 0.390, "MSR", "Microsoft Research, Cambridge")], ] Args: gpx_file (iter): GPX data to read Returns: list: Locations with optional comments .. _GPX 1.1 Schema Documentation: http://www.topografix.com/GPX/1/1/ """ self._gpx_file = gpx_file data = utils.prepare_xml_read(gpx_file, objectify=True) try: self.metadata.import_metadata(data.metadata) except AttributeError: pass for route in data.rte: points = point.TimedPoints() for routepoint in route.rtept: latitude = routepoint.get('lat') longitude = routepoint.get('lon') try: name = routepoint.name.text except AttributeError: name = None try: description = routepoint.desc.text except AttributeError: description = None try: elevation = float(routepoint.ele.text) except AttributeError: elevation = None try: time = utils.Timestamp.parse_isoformat(routepoint.time.text) except AttributeError: time = None points.append(Routepoint(latitude, longitude, name, description, elevation, time)) self.append(points)
[docs] def export_gpx_file(self): """Generate GPX element tree from :class:`Routepoints`. Returns: etree.ElementTree: GPX element tree depicting :class:`Routepoints` objects """ gpx = create_elem('gpx', GPX_ELEM_ATTRIB) if not self.metadata.bounds: self.metadata.bounds = [j for i in self for j in i] gpx.append(self.metadata.togpx()) for rte in self: chunk = create_elem('rte') gpx.append(chunk) for place in rte: chunk.append(place.togpx()) return etree.ElementTree(gpx)