Repository: fatiando/harmonica

Issue:
Unify computation of distances in spherical and Cartesian coordinates
**Description of the desired feature**

To prevent repeated code, would be nice to have single functions for computing distances between two points in both spherical and Cartesian coordinates. They must be Numba jitted functions to speed things up.

For the spherical coordinates case, the points are given in `longitude`, `latitude` and `radius` coordinates, but several trigonometric functions must be applied in order to ultimately get the distance between these two points. Some forward models precompute these quantities in order to save time. So, would be nice to have a private function that takes all these precomputed quantities as arguments and returns the distance, and a public function that just takes the points' coordinates, compute this quantities and use this private function to get the distance between them.

Moreover, some forward models just needs the distance square, so we could make another private function that just returns the distance square, i.e. doesn't apply the square root.

For example:
```python
@jit(nopython=True)
def distance_spherical(point_a, point_b):
    # Get coordinates of the two points
    longitude, latitude, radius = point_a[:]
    longitude_p, latitude_p, radius_p = point_b[:]
    # Convert angles to radians
    longitude, latitude = np.radians(longitude), np.radians(latitude)
    longitude_p, latitude_p = np.radians(longitude_p), np.radians(latitude_p)
    # Compute trigonometric quantities
    cosphi_p = np.cos(latitude_p)
    sinphi_p = np.sin(latitude_p)
    cosphi = np.cos(latitude)
    sinphi = np.sin(latitude)
    return _distance_spherical(
        longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
    )


@jit(nopython=True)
def _distance_spherical(
    longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
):
    return np.sqrt(
        _distance_sq_spherical(
            longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
        )
    )


@jit(nopython=True)
def _distance_sq_spherical(
    longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
):
    coslambda = np.cos(longitude_p - longitude)
    cospsi = sinphi_p * sinphi + cosphi_p * cosphi * coslambda
    return (radius - radius_p) ** 2 + 2 * radius * radius_p * (1 - cospsi)
```

**Are you willing to help implement and maintain this feature?** Yes


Comments:

Patch:
diff --git a/harmonica/forward/point_mass.py b/harmonica/forward/point_mass.py
index 5b12660..d2c3e5c 100644
--- a/harmonica/forward/point_mass.py
+++ b/harmonica/forward/point_mass.py
@@ -5,6 +5,12 @@ import numpy as np
 from numba import jit
 
 from ..constants import GRAVITATIONAL_CONST
+from .utils import (
+    check_coordinate_system,
+    distance_cartesian,
+    distance_spherical,
+    distance_spherical_core,
+)
 
 
 def point_mass_gravity(
@@ -141,10 +147,7 @@ def point_mass_gravity(
         "spherical": {"potential": kernel_potential_spherical, "g_r": kernel_g_r},
     }
     # Sanity checks for coordinate_system and field
-    if coordinate_system not in ("cartesian", "spherical"):
-        raise ValueError(
-            "Coordinate system {} not recognized".format(coordinate_system)
-        )
+    check_coordinate_system(coordinate_system)
     if field not in kernels[coordinate_system]:
         raise ValueError("Gravity field {} not recognized".format(field))
     # Figure out the shape and size of the output array
@@ -199,8 +202,8 @@ def kernel_potential_cartesian(easting, northing, down, easting_p, northing_p, d
     """
     Kernel function for potential gravity field in Cartesian coordinates
     """
-    return 1 / _distance_cartesian(
-        [easting, northing, down], [easting_p, northing_p, down_p]
+    return 1 / distance_spherical(
+        (easting, northing, down), (easting_p, northing_p, down_p)
     )
 
 
@@ -209,31 +212,10 @@ def kernel_g_z(easting, northing, down, easting_p, northing_p, down_p):
     """
     Kernel function for downward component of gravity gradient in Cartesian coordinates
     """
-    distance_sq = _distance_cartesian_sq(
+    distance = distance_cartesian(
         [easting, northing, down], [easting_p, northing_p, down_p]
     )
-    return (down_p - down) / distance_sq ** (3 / 2)
-
-
-@jit(nopython=True)
-def _distance_cartesian_sq(point_a, point_b):
-    """
-    Calculate the square distance between two points given in Cartesian coordinates
-    """
-    easting, northing, down = point_a[:]
-    easting_p, northing_p, down_p = point_b[:]
-    distance_sq = (
-        (easting - easting_p) ** 2 + (northing - northing_p) ** 2 + (down - down_p) ** 2
-    )
-    return distance_sq
-
-
-@jit(nopython=True)
-def _distance_cartesian(point_a, point_b):
-    """
-    Calculate the distance between two points given in Cartesian coordinates
-    """
-    return np.sqrt(_distance_cartesian_sq(point_a, point_b))
+    return (down_p - down) / distance ** 3
 
 
 @jit(nopython=True)
@@ -290,10 +272,10 @@ def kernel_potential_spherical(
     """
     Kernel function for potential gravity field in spherical coordinates
     """
-    coslambda = np.cos(longitude_p - longitude)
-    cospsi = sinphi_p * sinphi + cosphi_p * cosphi * coslambda
-    distance_sq = (radius - radius_p) ** 2 + 2 * radius * radius_p * (1 - cospsi)
-    return 1 / np.sqrt(distance_sq)
+    distance, _, _ = distance_spherical_core(
+        longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
+    )
+    return 1 / distance
 
 
 @jit(nopython=True)
@@ -303,8 +285,8 @@ def kernel_g_r(
     """
     Kernel function for radial component of gravity gradient in spherical coordinates
     """
-    coslambda = np.cos(longitude_p - longitude)
-    cospsi = sinphi_p * sinphi + cosphi_p * cosphi * coslambda
-    distance_sq = (radius - radius_p) ** 2 + 2 * radius * radius_p * (1 - cospsi)
+    distance, cospsi, _ = distance_spherical_core(
+        longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
+    )
     delta_z = radius_p * cospsi - radius
-    return delta_z / distance_sq ** (3 / 2)
+    return delta_z / distance ** 3
diff --git a/harmonica/forward/tesseroid.py b/harmonica/forward/tesseroid.py
index d3d679e..0717bee 100644
--- a/harmonica/forward/tesseroid.py
+++ b/harmonica/forward/tesseroid.py
@@ -6,6 +6,7 @@ import numpy as np
 from numpy.polynomial.legendre import leggauss
 
 from ..constants import GRAVITATIONAL_CONST
+from .utils import distance_spherical
 from .point_mass import jit_point_mass_spherical, kernel_potential_spherical, kernel_g_r
 
 STACK_SIZE = 100
@@ -511,24 +512,13 @@ def _distance_tesseroid_point(
     """
     Calculate the distance between a computation point and the center of a tesseroid.
     """
-    # Get coordinates of computation point
-    longitude, latitude, radius = coordinates[:]
     # Get center of the tesseroid
     w, e, s, n, bottom, top = tesseroid[:]
     longitude_p = (w + e) / 2
     latitude_p = (s + n) / 2
     radius_p = (bottom + top) / 2
-    # Convert angles to radians
-    longitude, latitude = np.radians(longitude), np.radians(latitude)
-    longitude_p, latitude_p = np.radians(longitude_p), np.radians(latitude_p)
-    # Compute distance
-    cosphi_p = np.cos(latitude_p)
-    sinphi_p = np.sin(latitude_p)
-    cosphi = np.cos(latitude)
-    sinphi = np.sin(latitude)
-    coslambda = np.cos(longitude_p - longitude)
-    cospsi = sinphi_p * sinphi + cosphi_p * cosphi * coslambda
-    distance = np.sqrt((radius - radius_p) ** 2 + 2 * radius * radius_p * (1 - cospsi))
+    # Get distance between computation point and tesseroid center
+    distance = distance_spherical(coordinates, (longitude_p, latitude_p, radius_p))
     return distance
 
 
diff --git a/harmonica/forward/utils.py b/harmonica/forward/utils.py
new file mode 100644
index 0000000..4c1f28e
--- /dev/null
+++ b/harmonica/forward/utils.py
@@ -0,0 +1,174 @@
+"""
+Utilities for forward modelling in Cartesian and spherical coordinates.
+"""
+import numpy as np
+from numba import jit
+
+
+def distance(point_p, point_q, coordinate_system="cartesian"):
+    """
+    Calculate the distance between two points in Cartesian or spherical coordinates
+
+    Parameters
+    ----------
+    point_p : list or tuple or 1d-array
+        List, tuple or array containing the coordinates of the first point in the
+        following order: (``easting``, ``northing`` and ``down``) if given in Cartesian
+        coordinates, or (``longitude``, ``latitude`` and ``radius``) if given in
+        a spherical geocentric coordiante system.
+        All ``easting``, ``northing`` and ``down`` must be in meters.
+        Both ``longitude`` and ``latitude`` must be in degrees, while ``radius`` in
+        meters.
+    point_q : list or tuple or 1d-array
+        List, tuple or array containing the coordinates of the second point in the
+        following order: (``easting``, ``northing`` and ``down``) if given in Cartesian
+        coordinates, or (``longitude``, ``latitude`` and ``radius``) if given in
+        a spherical geocentric coordiante system.
+        All ``easting``, ``northing`` and ``down`` must be in meters.
+        Both ``longitude`` and ``latitude`` must be in degrees, while ``radius`` in
+        meters.
+    coordinate_system : str (optional)
+        Coordinate system of the coordinates of the computation points and the point
+        masses. Available coordinates systems: ``cartesian``, ``spherical``.
+        Default ``cartesian``.
+
+    Returns
+    -------
+    distance : float
+        Distance between ``point_p`` and ``point_q``.
+    """
+    check_coordinate_system(coordinate_system)
+    if coordinate_system == "cartesian":
+        dist = distance_cartesian(point_p, point_q)
+    if coordinate_system == "spherical":
+        dist = distance_spherical(point_p, point_q)
+    return dist
+
+
+def check_coordinate_system(
+    coordinate_system, valid_coord_systems=("cartesian", "spherical")
+):
+    """
+    Check if the coordinate system is a valid one.
+
+    Parameters
+    ----------
+    coordinate_system : str
+        Coordinate system to be checked.
+    valid_coord_system : tuple or list (optional)
+        Tuple or list containing the valid coordinate systems.
+        Default (``cartesian``, ``spherical``).
+    """
+    if coordinate_system not in valid_coord_systems:
+        raise ValueError(
+            "Coordinate system {} not recognized.".format(coordinate_system)
+        )
+
+
+@jit(nopython=True)
+def distance_cartesian(point_p, point_q):
+    """
+    Calculate the distance between two points given in Cartesian coordinates
+
+    Parameters
+    ----------
+    point_p : tuple or 1d-array
+        Tuple or array containing the coordinates of the first point in the
+        following order: (``easting``, ``northing`` and ``down``)
+        All coordinates must be in meters.
+    point_q : tuple or 1d-array
+        Tuple or array containing the coordinates of the second point in the
+        following order: (``easting``, ``northing`` and ``down``)
+        All coordinates must be in meters.
+
+    Returns
+    -------
+    distance : float
+        Distance between ``point_p`` and ``point_q``.
+    """
+    easting, northing, down = point_p[:]
+    easting_p, northing_p, down_p = point_q[:]
+    dist = np.sqrt(
+        (easting - easting_p) ** 2 + (northing - northing_p) ** 2 + (down - down_p) ** 2
+    )
+    return dist
+
+
+@jit(nopython=True)
+def distance_spherical(point_p, point_q):
+    """
+    Calculate the distance between two points in spherical coordinates
+
+    All angles must be in degrees and radii in meters.
+
+    Parameters
+    ----------
+    point_p : tuple or 1d-array
+        Tuple or array containing the coordinates of the first point in the
+        following order: (``longitude``, ``latitude`` and ``radius``).
+        Both ``longitude`` and ``latitude`` must be in degrees, while ``radius`` in
+        meters.
+    point_q : tuple or 1d-array
+        Tuple or array containing the coordinates of the second point in the
+        following order: (``longitude``, ``latitude`` and ``radius``).
+        Both ``longitude`` and ``latitude`` must be in degrees, while ``radius`` in
+        meters.
+
+    Returns
+    -------
+    distance : float
+        Distance between ``point_p`` and ``point_q``.
+    """
+    # Get coordinates of the two points
+    longitude, latitude, radius = point_p[:]
+    longitude_p, latitude_p, radius_p = point_q[:]
+    # Convert angles to radians
+    longitude, latitude = np.radians(longitude), np.radians(latitude)
+    longitude_p, latitude_p = np.radians(longitude_p), np.radians(latitude_p)
+    # Compute trigonometric quantities
+    cosphi_p = np.cos(latitude_p)
+    sinphi_p = np.sin(latitude_p)
+    cosphi = np.cos(latitude)
+    sinphi = np.sin(latitude)
+    dist, _, _ = distance_spherical_core(
+        longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
+    )
+    return dist
+
+
+@jit(nopython=True)
+def distance_spherical_core(
+    longitude, cosphi, sinphi, radius, longitude_p, cosphi_p, sinphi_p, radius_p
+):
+    """
+    Core computation for the distance between two points in spherical coordinates
+
+    It computes the distance between two points in spherical coordinates given
+    precomputed quantities related to the coordinates of both points: the ``longitude``
+    in radians, the sine and cosine of the ``latitude`` and the ``radius`` in meters.
+    Precomputing this quantities may save computation time on some cases.
+
+    Parameters
+    ----------
+    longitude, cosphi, sinphi, radius : floats
+        Quantities related to the coordinates of the first point. ``cosphi`` and
+        ``sinphi`` are the cosine and sine of the latitude coordinate of the first
+        point, respectively. ``longitude`` must be in radians and ``radius`` in meters.
+    longitude_p, cosphi_p, sinphi_p, radius_p : floats
+        Quantities related to the coordinates of the second point. ``cosphi_p`` and
+        ``sinphi_p`` are the cosine and sine of the latitude coordinate of the second
+        point, respectively. ``longitude`` must be in radians and ``radius`` in meters.
+
+    Returns
+    -------
+    distance : float
+        Distance between the two points.
+    cospsi : float
+        Cosine of the psi angle.
+    coslambda : float
+        Cosine of the diference between the longitudes of both points.
+    """
+    coslambda = np.cos(longitude_p - longitude)
+    cospsi = sinphi_p * sinphi + cosphi_p * cosphi * coslambda
+    dist = np.sqrt((radius - radius_p) ** 2 + 2 * radius * radius_p * (1 - cospsi))
+    return dist, cospsi, coslambda


Test patch:
diff --git a/harmonica/tests/test_forward_utils.py b/harmonica/tests/test_forward_utils.py
new file mode 100644
index 0000000..d8986cb
--- /dev/null
+++ b/harmonica/tests/test_forward_utils.py
@@ -0,0 +1,34 @@
+"""
+Test utils functions for forward modelling
+"""
+import pytest
+import numpy.testing as npt
+
+from ..forward.utils import distance, check_coordinate_system
+
+
+@pytest.mark.use_numba
+def test_distance():
+    "Test if computated is distance is right"
+    # Cartesian coordinate system
+    point_a = (1.1, 1.2, 1.3)
+    point_b = (1.1, 1.2, 2.4)
+    npt.assert_allclose(distance(point_a, point_b, coordinate_system="cartesian"), 1.1)
+    # Spherical coordinate system
+    point_a = (32.3, 40.1, 1e4)
+    point_b = (32.3, 40.1, 1e4 + 100)
+    npt.assert_allclose(distance(point_a, point_b, coordinate_system="spherical"), 100)
+
+
+def test_distance_invalid_coordinate_system():
+    "Check if invalid coordinate system is passed to distance function"
+    point_a = (0, 0, 0)
+    point_b = (1, 1, 1)
+    with pytest.raises(ValueError):
+        distance(point_a, point_b, "this-is-not-a-valid-coordinate-system")
+
+
+def test_check_coordinate_system():
+    "Check if invalid coordinate system is passed to _check_coordinate_system"
+    with pytest.raises(ValueError):
+        check_coordinate_system("this-is-not-a-valid-coordinate-system")