Repository: tkrajina/gpxpy

Issue:
Add course_between-Method for Track Points
As described in the readme, the speed-attribute is not supported in GPX 1.1 anymore, I guess that's the same with the course-attribute. As for speed, there is a method `speed_between()` to calculate the speed between two points, is it possible to implement a similar method for the course between two points?

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/20765923/badge)](https://coveralls.io/builds/20765923)

Coverage increased (+0.1%) to 85.619% when pulling **d62adcc802a09b7bd55424ff571a565117c7356b on gvenewour:course-between** into **412d190dda52a91e1647cabce82965b5349689fd on tkrajina:dev**.

tkrajina: OK, just jumping quickly to say that this looks like in interesting feature and I definitely plan to check the code and merge it (once I find some time, which may not be in the next couple of weeks).

Patch:
diff --git a/gpxpy/geo.py b/gpxpy/geo.py
index bb597c1..0b37443 100644
--- a/gpxpy/geo.py
+++ b/gpxpy/geo.py
@@ -50,6 +50,48 @@ def haversine_distance(latitude_1: float, longitude_1: float, latitude_2: float,
     return d
 
 
+def get_course(latitude_1: float, longitude_1: float, latitude_2: float, longitude_2: float,
+               loxodromic: bool=True) -> float:
+    """
+    The initial course from one point to another,
+    expressed in decimal degrees clockwise from true North
+    (not magnetic)
+    (0.0 <= value < 360.0)
+
+    Use the default loxodromic model in most cases
+    (except when visualizing the long routes of maritime transport and aeroplanes)
+
+    Implemented from http://www.movable-type.co.uk/scripts/latlong.html
+    (sections 'Bearing' and 'Rhumb lines')
+    """
+
+    d_lon = mod_math.radians(longitude_2 - longitude_1)
+    lat1 = mod_math.radians(latitude_1)
+    lat2 = mod_math.radians(latitude_2)
+
+    if not loxodromic:
+        y = mod_math.sin(d_lon) * mod_math.cos(lat2)
+        x = mod_math.cos(lat1) * mod_math.sin(lat2) - \
+            mod_math.sin(lat1) * mod_math.cos(lat2) * mod_math.cos(d_lon)
+    else:
+        radian_circle = 2*mod_math.pi
+
+        if abs(d_lon) > mod_math.pi:
+            if d_lon > 0:
+                d_lon = - (radian_circle - d_lon)
+            else:
+                d_lon = radian_circle + d_lon
+
+        y = d_lon
+
+        delta = mod_math.pi/4
+        x = mod_math.log(mod_math.tan(delta + 0.5*lat2)
+                         / mod_math.tan(delta + 0.5*lat1))
+
+    course = mod_math.degrees(mod_math.atan2(y, x))
+    return (course + 360.) % 360.
+
+
 def length(locations: List["Location"]=[], _3d: bool=False) -> float:
     if not locations:
         return 0
diff --git a/gpxpy/gpx.py b/gpxpy/gpx.py
index d82cdb0..c09750c 100644
--- a/gpxpy/gpx.py
+++ b/gpxpy/gpx.py
@@ -21,7 +21,6 @@ import math as mod_math
 import collections as mod_collections
 import copy as mod_copy
 import datetime as mod_datetime
-import datetime as mod_datetime
 
 from . import utils as mod_utils
 from . import geo as mod_geo
@@ -628,10 +627,57 @@ class GPXTrackPoint(mod_geo.Location):
 
         return length / float(seconds)
 
+    def course_between(self, track_point: "GPXTrackPoint", loxodromic: bool=True) -> Optional[float]:
+        """
+        Compute the instantaneous course from one point to another.
+
+        Both loxodromic (Rhumb line) and orthodromic (Great circle) navigation
+        models are available.
+
+        The default navigation model is loxodromic.
+
+        There is no difference between these models in course computation
+        when points are relatively close to each other (less than ≈150 km)
+
+        In most cases the default model is OK.
+
+        However, the orthodromic navigation model can be important for the
+        long-distance (> ≈1000 km) logs acquired from maritime transport
+        or aeroplanes
+
+        Generally, the choice between these two models depends on a vehicle type,
+        distance and navigation equipment used.
+
+        More information on these two navigation models:
+        https://www.movable-type.co.uk/scripts/latlong.html
+
+        NOTE: This is a computed course, not the GPXTrackPoint course that comes in
+              the GPX file.
+
+        Parameters
+        ----------
+        track_point : GPXTrackPoint
+        loxodromic : True
+                     Set to False to use the orthodromic navigation model
+
+        Returns
+        ----------
+        course : float
+                Course returned in decimal degrees, true (not magnetic)
+                (0.0 <= value < 360.0)
+        """
+
+        if not track_point:
+            return None
+
+        course = mod_geo.get_course(self.latitude, self.longitude,
+                                    track_point.latitude, track_point.longitude,
+                                    loxodromic)
+        return course
+
     def __str__(self) -> str:
         return f'[trkpt:{self.latitude},{self.longitude}@{self.elevation}@{self.time}]'
 
-
 class GPXTrackSegment:
     gpx_10_fields = [
             mod_gpxfield.GPXComplexField('points', tag='trkpt', classs=GPXTrackPoint, is_list=True),


Test patch:
diff --git a/test.py b/test.py
index 0b3e734..a7d4b84 100644
--- a/test.py
+++ b/test.py
@@ -879,6 +879,88 @@ class GPXTests(mod_unittest.TestCase):
             self.assertTrue(test_gpx.tracks[0].segments[0].points[0].vertical_dilution == 301.1)
             self.assertTrue(test_gpx.tracks[0].segments[0].points[0].position_dilution == 302.1)
 
+    def test_course_between(self) -> None:
+        gpx = mod_gpx.GPX()
+        track = mod_gpx.GPXTrack()
+
+        segment = mod_gpx.GPXTrackSegment()
+        points = segment.points
+
+        # The points are extremely distant.
+        # Therefore, the computed orthodromic and loxodromic courses
+        # should diverge significantly.
+
+        points.append(mod_gpx.GPXTrackPoint(latitude=-73, longitude=-150))
+        points.append(mod_gpx.GPXTrackPoint(latitude=43.5798, longitude=35.71265))
+        points.append(mod_gpx.GPXTrackPoint(latitude=85, longitude=0.12345))
+        track.segments.append(segment)
+        gpx.tracks.append(track)
+
+        self.assertEqual(points[0].course_between(points[0]), 0)
+        # self.assertIsNone(points[2].course_between(None))
+
+        course_01 = points[0].course_between(points[1])
+        course_12 = points[1].course_between(points[2])
+        course_02 = points[0].course_between(points[2])
+
+        self.assertAlmostEqual(course_01, 312.089, 3)  # type: ignore
+        self.assertAlmostEqual(course_12, 344.790, 3)  # type: ignore
+        self.assertAlmostEqual(course_02, 27.5055, 3)  # type: ignore
+
+        # The default computational model should be loxodromic:
+
+        self.assertAlmostEqual(points[0].course_between(points[1], loxodromic=True), course_01, 6)  # type: ignore
+        self.assertAlmostEqual(points[1].course_between(points[2], loxodromic=True), course_12, 6)  # type: ignore
+        self.assertAlmostEqual(points[0].course_between(points[2], loxodromic=True), course_02, 6)  # type: ignore
+
+        # Verifying the orthodromic results
+
+        course_orthodromic_01 = points[0].course_between(points[1], loxodromic=False)
+        course_orthodromic_12 = points[1].course_between(points[2], loxodromic=False)
+        course_orthodromic_02 = points[0].course_between(points[2], loxodromic=False)
+
+        self.assertAlmostEqual(course_orthodromic_01, 188.409, 3)  # type: ignore
+        self.assertAlmostEqual(course_orthodromic_12, 355.6886, 3)  # type: ignore
+        self.assertAlmostEqual(course_orthodromic_02, 11.2136, 3)  # type: ignore
+
+        # Short distance tests:
+
+        gpx_short = self.parse('track_with_speed.gpx')
+        points_short = gpx_short.tracks[0].segments[0].points
+
+        course_short_01 = points_short[0].course_between(points_short[1])
+        course_short_12 = points_short[1].course_between(points_short[2])
+        course_short_02 = points_short[0].course_between(points_short[2])
+
+        # When the points are not too distant (less than about 100-150km),
+        # the orthodromic and loxodromic bearings should be almost identical:
+
+        self.assertAlmostEqual(points_short[0].course_between(points_short[1], loxodromic=False), course_short_01, 3)  # type: ignore
+        self.assertAlmostEqual(points_short[1].course_between(points_short[2], loxodromic=False), course_short_12, 3)  # type: ignore
+        self.assertAlmostEqual(points_short[0].course_between(points_short[2], loxodromic=False), course_short_02, 3)  # type: ignore
+
+    def test_get_course(self) -> None:
+        pts = [[-73, -150], [43.5798, 35.71265], [85, 0.12345]]
+
+        # same long distance checks as in test_get_course_between
+        self.assertAlmostEqual(mod_geo.get_course(pts[0][0], pts[0][1], pts[1][0], pts[1][1]), 312.089, 3)  # type: ignore
+        self.assertAlmostEqual(mod_geo.get_course(pts[1][0], pts[1][1], pts[2][0], pts[2][1]), 344.790, 3)  # type: ignore
+        self.assertAlmostEqual(mod_geo.get_course(pts[0][0], pts[0][1], pts[2][0], pts[2][1]), 27.5055, 3)  # type: ignore
+
+        self.assertAlmostEqual(mod_geo.get_course(pts[0][0], pts[0][1], pts[1][0], pts[1][1],  # type: ignore
+                                                  loxodromic=True), 312.089, 3)
+        self.assertAlmostEqual(mod_geo.get_course(pts[1][0], pts[1][1], pts[2][0], pts[2][1],  # type: ignore
+                                                  loxodromic=True), 344.790, 3)
+        self.assertAlmostEqual(mod_geo.get_course(pts[0][0], pts[0][1], pts[2][0], pts[2][1],  # type: ignore
+                                                  loxodromic=True), 27.5055, 3)
+
+        self.assertAlmostEqual(mod_geo.get_course(pts[0][0], pts[0][1], pts[1][0], pts[1][1],  # type: ignore
+                                                  loxodromic=False), 188.409, 3)
+        self.assertAlmostEqual(mod_geo.get_course(pts[1][0], pts[1][1], pts[2][0], pts[2][1],  # type: ignore
+                                                  loxodromic=False), 355.6886, 3)
+        self.assertAlmostEqual(mod_geo.get_course(pts[0][0], pts[0][1], pts[2][0], pts[2][1],  # type: ignore
+                                                  loxodromic=False), 11.2136, 3)
+
     def test_name_comment_and_symbol(self) -> None:
         gpx = mod_gpx.GPX()
         track = mod_gpx.GPXTrack()