Repository: fatiando/harmonica

Issue:
Enhance EQL gridder to interpolate data in spherical coordinates
**Description of the desired feature**

Would be nice to improve the EQL Harmonic gridder to be able to interpolate data in spherical coordinates (without the need to project it). This feature would be very useful when gridding potential data on regional or continental scales where the curvature of the Earth must be taken into account. It can be done by modifying the `EQLHarmonic` class, allowing to pass a `coordinate_system` argument which will regulate how to compute the Green's function (1 / distance) between the point sources and the observation points.


<!-- Please be as detailed as you can in your description. If possible, include an example of how you would like to use this feature (even better if it's a code example). -->


**Are you willing to help implement and maintain this feature?** Yes

<!-- Every feature we add is code that we will have to maintain and keep updated. This takes a lot of effort. If you are willing to be involved in the project and help maintain your feature, it will make it easier for us to accept it. -->


Comments:

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 46a5f1c..46577b9 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -24,6 +24,7 @@ Equivalent Layers
     :toctree: generated/
 
     EQLHarmonic
+    EQLHarmonicSpherical
 
 Forward modelling
 -----------------
diff --git a/harmonica/__init__.py b/harmonica/__init__.py
index 20f322b..c972a82 100644
--- a/harmonica/__init__.py
+++ b/harmonica/__init__.py
@@ -9,7 +9,7 @@ from .gravity_corrections import bouguer_correction
 from .forward.point_mass import point_mass_gravity
 from .forward.tesseroid import tesseroid_gravity
 from .forward.prism import prism_gravity
-from .equivalent_layer.harmonic import EQLHarmonic
+from .equivalent_layer.harmonic import EQLHarmonic, EQLHarmonicSpherical
 
 # Get the version number through versioneer
 __version__ = version.full_version
diff --git a/harmonica/equivalent_layer/harmonic.py b/harmonica/equivalent_layer/harmonic.py
index 276c56b..1855af3 100644
--- a/harmonica/equivalent_layer/harmonic.py
+++ b/harmonica/equivalent_layer/harmonic.py
@@ -7,7 +7,10 @@ from sklearn.utils.validation import check_is_fitted
 import verde as vd
 import verde.base as vdb
 
-from ..forward.utils import distance_cartesian
+from ..forward.utils import (
+    distance_cartesian,
+    distance_spherical,
+)
 
 
 class EQLHarmonic(vdb.BaseGridder):
@@ -25,6 +28,8 @@ class EQLHarmonic(vdb.BaseGridder):
 
     It cannot be used for:
 
+    * Regional or global data where Earth's curvature must be taken into
+      account
     * Joint inversion of multiple data types (e.g., gravity + gravity
       gradients)
     * Reduction to the pole of magnetic total field anomaly data
@@ -80,10 +85,14 @@ class EQLHarmonic(vdb.BaseGridder):
         :meth:`~harmonica.EQLHarmonic.scatter` methods.
     """
 
-    def __init__(self, damping=None, points=None, relative_depth=500):
+    def __init__(
+        self, damping=None, points=None, relative_depth=500,
+    ):
         self.damping = damping
         self.points = points
         self.relative_depth = relative_depth
+        # Define Green's function for Cartesian coordinates
+        self.greens_function = greens_func_cartesian
 
     def fit(self, coordinates, data, weights=None):
         """
@@ -99,9 +108,9 @@ class EQLHarmonic(vdb.BaseGridder):
         ----------
         coordinates : tuple of arrays
             Arrays with the coordinates of each data point. Should be in the
-            following order: (easting, northing, upward, ...).
-            Only easting, northing, and upward will be used, all subsequent
-            coordinates will be ignored.
+            following order: (``easting``, ``northing``, ``upward``, ...).
+            Only ``easting``, ``northing``, and ``upward`` will be used, all
+            subsequent coordinates will be ignored.
         data : array
             The data values of each data point.
         weights : None or array
@@ -155,7 +164,9 @@ class EQLHarmonic(vdb.BaseGridder):
         dtype = coordinates[0].dtype
         coordinates = tuple(np.atleast_1d(i).ravel() for i in coordinates[:3])
         data = np.zeros(size, dtype=dtype)
-        predict_numba(coordinates, self.points_, self.coefs_, data)
+        predict_numba(
+            coordinates, self.points_, self.coefs_, data, self.greens_function
+        )
         return data.reshape(shape)
 
     def jacobian(
@@ -186,23 +197,197 @@ class EQLHarmonic(vdb.BaseGridder):
         jacobian : 2D array
             The (n_data, n_points) Jacobian matrix.
         """
+        # Compute Jacobian matrix
         n_data = coordinates[0].size
         n_points = points[0].size
         jac = np.zeros((n_data, n_points), dtype=dtype)
-        jacobian_numba(coordinates, points, jac)
+        jacobian_numba(coordinates, points, jac, self.greens_function)
         return jac
 
 
+class EQLHarmonicSpherical(EQLHarmonic):
+    r"""
+    Equivalent-layer for generic harmonic functions in spherical coordinates
+
+    This equivalent layer can be used for:
+
+    * Spherical coordinates (geographic coordinates must be converted before
+      use)
+    * Regional or global data where Earth's curvature must be taken into
+      account
+    * Gravity and magnetic data (including derivatives)
+    * Single data types
+    * Interpolation
+    * Upward continuation
+    * Finite-difference based derivative calculations
+
+    It cannot be used for:
+
+    * Joint inversion of multiple data types (e.g., gravity + gravity
+      gradients)
+    * Reduction to the pole of magnetic total field anomaly data
+    * Analytical derivative calculations
+
+    Point sources are located beneath the observed potential-field measurement
+    points by default [Cooper2000]_. Custom source locations can be used by
+    specifying the *points* argument. Coefficients associated with each point
+    source are estimated through linear least-squares with damping (Tikhonov
+    0th order) regularization.
+
+    The Green's function for point mass effects used is the inverse Cartesian
+    distance between the grid coordinates and the point source:
+
+    .. math::
+
+        \phi(\bar{x}, \bar{x}') = \frac{1}{||\bar{x} - \bar{x}'||}
+
+    where :math:`\bar{x}` and :math:`\bar{x}'` are the coordinate vectors of
+    the observation point and the source, respectively.
+
+    Parameters
+    ----------
+    damping : None or float
+        The positive damping regularization parameter. Controls how much
+        smoothness is imposed on the estimated coefficients.
+        If None, no regularization is used.
+    points : None or list of arrays (optional)
+        List containing the coordinates of the point sources used as the
+        equivalent layer. Coordinates are assumed to be in the following order:
+        (``longitude``, ``latitude``, ``radius``). Both ``longitude`` and
+        ``latitude`` must be in degrees and ``radius`` in meters.
+        If None, will place one point source bellow each observation point at
+        a fixed relative depth bellow the observation point [Cooper2000]_.
+        Defaults to None.
+    relative_depth : float
+        Relative depth at which the point sources are placed beneath the
+        observation points. Each source point will be set beneath each data
+        point at a depth calculated as the radius of the data point minus
+        this constant *relative_depth*. Use positive numbers (negative numbers
+        would mean point sources are above the data points). Ignored if
+        *points* is specified.
+
+    Attributes
+    ----------
+    points_ : 2d-array
+        Coordinates of the point sources used to build the equivalent layer.
+    coefs_ : array
+        Estimated coefficients of every point source.
+    region_ : tuple
+        The boundaries (``[W, E, S, N]``) of the data used to fit the
+        interpolator. Used as the default region for the
+        :meth:`~harmonica.EQLHarmonicSpherical.grid` and
+        :meth:`~harmonica.EQLHarmonicSpherical.scatter` methods.
+    """
+
+    def __init__(
+        self, damping=None, points=None, relative_depth=500,
+    ):
+        super().__init__(damping=damping, points=points, relative_depth=relative_depth)
+        # Define Green's function for spherical coordinates
+        self.greens_function = greens_func_spherical
+
+    def fit(self, coordinates, data, weights=None):
+        """
+        Fit the coefficients of the equivalent layer.
+
+        The data region is captured and used as default for the
+        :meth:`~harmonica.EQLHarmonicSpherical.grid` and
+        :meth:`~harmonica.EQLHarmonicSpherical.scatter` methods.
+
+        All input arrays must have the same shape.
+
+        Parameters
+        ----------
+        coordinates : tuple of arrays
+            Arrays with the coordinates of each data point. Should be in the
+            following order: (``longitude``, ``latitude``, ``radius``, ...).
+            Only ``longitude``, ``latitude``, and ``radius`` will be used, all
+            subsequent coordinates will be ignored.
+        data : array
+            The data values of each data point.
+        weights : None or array
+            If not None, then the weights assigned to each data point.
+            Typically, this should be 1 over the data uncertainty squared.
+
+        Returns
+        -------
+        self
+            Returns this estimator instance for chaining operations.
+        """
+        # Overwrite method just to change the docstring
+        super().fit(coordinates, data, weights=weights)
+        return self
+
+    def predict(self, coordinates):
+        """
+        Evaluate the estimated equivalent layer on the given set of points.
+
+        Requires a fitted estimator (see :meth:`~harmonica.EQLHarmonic.fit`).
+
+        Parameters
+        ----------
+        coordinates : tuple of arrays
+            Arrays with the coordinates of each data point. Should be in the
+            following order: (``longitude``, ``latitude``, ``radius``, ...).
+            Only ``longitude``, ``latitude`` and ``radius`` will be used, all
+            subsequent coordinates will be ignored.
+
+        Returns
+        -------
+        data : array
+            The data values evaluated on the given points.
+        """
+        # Overwrite method just to change the docstring
+        data = super().predict(coordinates)
+        return data
+
+    def jacobian(
+        self, coordinates, points, dtype="float64"
+    ):  # pylint: disable=no-self-use
+        """
+        Make the Jacobian matrix for the equivalent layer.
+
+        Each column of the Jacobian is the Green's function for a single point
+        source evaluated on all observation points.
+
+        Parameters
+        ----------
+        coordinates : tuple of arrays
+            Arrays with the coordinates of each data point. Should be in the
+            following order: (``longitude``, ``latitude``, ``radius``, ...).
+            Only ``longitude``, ``latitude`` and ``radius`` will be used, all
+            subsequent coordinates will be ignored.
+        points : tuple of arrays
+            Tuple of arrays containing the coordinates of the point sources
+            used as equivalent layer in the following order:
+            (``longitude``, ``latitude``, ``radius``).
+        dtype : str or numpy dtype
+            The type of the Jacobian array.
+
+        Returns
+        -------
+        jacobian : 2D array
+            The (n_data, n_points) Jacobian matrix.
+        """
+        # Overwrite method just to change the docstring
+        jacobian = super().jacobian(coordinates, points, dtype=dtype)
+        return jacobian
+
+
 @jit(nopython=True)
-def predict_numba(coordinates, points, coeffs, result):
+def predict_numba(coordinates, points, coeffs, result, greens_function):
     """
     Calculate the predicted data using numba for speeding things up.
+
+    It works both for Cartesian and spherical coordiantes.
+    We need to pass the corresponding Green's function through the
+    ``greens_function`` argument.
     """
     east, north, upward = coordinates[:]
     point_east, point_north, point_upward = points[:]
     for i in range(east.size):
         for j in range(point_east.size):
-            result[i] += coeffs[j] * greens_func(
+            result[i] += coeffs[j] * greens_function(
                 east[i],
                 north[i],
                 upward[i],
@@ -213,9 +398,11 @@ def predict_numba(coordinates, points, coeffs, result):
 
 
 @jit(nopython=True)
-def greens_func(east, north, upward, point_east, point_north, point_upward):
+def greens_func_cartesian(east, north, upward, point_east, point_north, point_upward):
     """
-    Calculate the Green's function for the equivalent layer using Numba.
+    Green's function for the equivalent layer in Cartesian coordinates
+
+    Uses Numba to speed up things.
     """
     distance = distance_cartesian(
         (east, north, upward), (point_east, point_north, point_upward)
@@ -224,15 +411,34 @@ def greens_func(east, north, upward, point_east, point_north, point_upward):
 
 
 @jit(nopython=True)
-def jacobian_numba(coordinates, points, jac):
+def greens_func_spherical(
+    longitude, latitude, radius, point_longitude, point_latitude, point_radius
+):
+    """
+    Green's function for the equivalent layer in spherical coordinates
+
+    Uses Numba to speed up things.
+    """
+    distance = distance_spherical(
+        (longitude, latitude, radius), (point_longitude, point_latitude, point_radius)
+    )
+    return 1 / distance
+
+
+@jit(nopython=True)
+def jacobian_numba(coordinates, points, jac, greens_function):
     """
     Calculate the Jacobian matrix using numba to speed things up.
+
+    It works both for Cartesian and spherical coordiantes.
+    We need to pass the corresponding Green's function through the
+    ``greens_function`` argument.
     """
     east, north, upward = coordinates[:]
     point_east, point_north, point_upward = points[:]
     for i in range(east.size):
         for j in range(point_east.size):
-            jac[i, j] = greens_func(
+            jac[i, j] = greens_function(
                 east[i],
                 north[i],
                 upward[i],


Test patch:
diff --git a/harmonica/tests/test_eql_harmonic.py b/harmonica/tests/test_eql_harmonic.py
index 45bac55..055051b 100644
--- a/harmonica/tests/test_eql_harmonic.py
+++ b/harmonica/tests/test_eql_harmonic.py
@@ -7,16 +7,19 @@ import numpy.testing as npt
 import verde as vd
 import verde.base as vdb
 
-from .. import EQLHarmonic, point_mass_gravity
-from ..equivalent_layer.harmonic import jacobian_numba
+from .. import EQLHarmonic, EQLHarmonicSpherical, point_mass_gravity
+from ..equivalent_layer.harmonic import (
+    jacobian_numba,
+    greens_func_cartesian,
+)
 from .utils import require_numba
 
 
 @require_numba
-def test_eql_harmonic():
+def test_eql_harmonic_cartesian():
     """
     Check that predictions are reasonable when interpolating from one grid to
-    a denser grid.
+    a denser grid. Use Cartesian coordiantes.
     """
     region = (-3e3, -1e3, 5e3, 7e3)
     # Build synthetic point masses
@@ -39,9 +42,10 @@ def test_eql_harmonic():
     npt.assert_allclose(true, eql.predict(grid), rtol=1e-3)
 
 
-def test_eql_harmonic_small_data():
+def test_eql_harmonic_small_data_cartesian():
     """
     Check predictions against synthetic data using few data points for speed
+    Use Cartesian coordinates.
     """
     region = (-3e3, -1e3, 5e3, 7e3)
     # Build synthetic point masses
@@ -69,9 +73,10 @@ def test_eql_harmonic_small_data():
     npt.assert_allclose(true, eql.predict(grid), rtol=0.05)
 
 
-def test_eql_harmonic_custom_points():
+def test_eql_harmonic_custom_points_cartesian():
     """
     Check that passing in custom points works and actually uses the points
+    Use Cartesian coordinates.
     """
     region = (-3e3, -1e3, 5e3, 7e3)
     # Build synthetic point masses
@@ -87,13 +92,12 @@ def test_eql_harmonic_custom_points():
         i.ravel()
         for i in vd.grid_coordinates(region=region, shape=(20, 20), extra_coords=-550)
     )
-    eql = EQLHarmonic(relative_depth=500, points=src_points)
+    eql = EQLHarmonic(points=src_points)
     eql.fit(coordinates, data)
     npt.assert_allclose(data, eql.predict(coordinates), rtol=1e-5)
 
     # Check that the proper source locations were set
-    npt.assert_allclose(src_points[:2], eql.points_[:2], rtol=1e-5)
-    npt.assert_allclose(-550, eql.points_[2], rtol=1e-5)
+    npt.assert_allclose(src_points, eql.points_, rtol=1e-5)
 
     # Gridding at higher altitude should be reasonably accurate when compared
     # to synthetic values
@@ -103,8 +107,11 @@ def test_eql_harmonic_custom_points():
 
 
 @pytest.mark.use_numba
-def test_eql_harmonic_jacobian():
-    "Test Jacobian matrix under symmetric system of point sources"
+def test_eql_harmonic_jacobian_cartesian():
+    """
+    Test Jacobian matrix under symmetric system of point sources.
+    Use Cartesian coordinates.
+    """
     easting, northing, upward = vd.grid_coordinates(
         region=[-100, 100, -100, 100], shape=(2, 2), extra_coords=0
     )
@@ -112,7 +119,7 @@ def test_eql_harmonic_jacobian():
     coordinates = vdb.n_1d_arrays((easting, northing, upward), n=3)
     n_points = points[0].size
     jacobian = np.zeros((n_points, n_points), dtype=points[0].dtype)
-    jacobian_numba(coordinates, points, jacobian)
+    jacobian_numba(coordinates, points, jacobian, greens_func_cartesian)
     # All diagonal elements must be equal
     diagonal = np.diag_indices(4)
     npt.assert_allclose(jacobian[diagonal][0], jacobian[diagonal])
@@ -125,3 +132,123 @@ def test_eql_harmonic_jacobian():
     nearest_neighbours[diagonal] = False
     nearest_neighbours[anti_diagonal] = False
     npt.assert_allclose(jacobian[nearest_neighbours][0], jacobian[nearest_neighbours])
+
+
+@require_numba
+def test_eql_harmonic_spherical():
+    """
+    Check that predictions are reasonable when interpolating from one grid to
+    a denser grid. Use spherical coordiantes.
+    """
+    region = (-70, -60, -40, -30)
+    radius = 6400e3
+    # Build synthetic point masses
+    points = vd.grid_coordinates(
+        region=region, shape=(6, 6), extra_coords=radius - 500e3
+    )
+    masses = vd.datasets.CheckerBoard(amplitude=1e13, region=region).predict(points)
+    # Define a set of observation points
+    coordinates = vd.grid_coordinates(
+        region=region, shape=(40, 40), extra_coords=radius
+    )
+    # Get synthetic data
+    data = point_mass_gravity(
+        coordinates, points, masses, field="g_z", coordinate_system="spherical"
+    )
+
+    # The interpolation should be perfect on the data points
+    eql = EQLHarmonicSpherical(relative_depth=500e3)
+    eql.fit(coordinates, data)
+    npt.assert_allclose(data, eql.predict(coordinates), rtol=1e-5)
+
+    # Gridding onto a denser grid should be reasonably accurate when compared
+    # to synthetic values
+    grid = vd.grid_coordinates(region=region, shape=(60, 60), extra_coords=radius)
+    true = point_mass_gravity(
+        grid, points, masses, field="g_z", coordinate_system="spherical"
+    )
+    npt.assert_allclose(true, eql.predict(grid), rtol=1e-3)
+
+
+def test_eql_harmonic_small_data_spherical():
+    """
+    Check predictions against synthetic data using few data points for speed
+    Use spherical coordinates.
+    """
+    region = (-70, -60, -40, -30)
+    radius = 6400e3
+    # Build synthetic point masses
+    points = vd.grid_coordinates(
+        region=region, shape=(6, 6), extra_coords=radius - 500e3
+    )
+    masses = vd.datasets.CheckerBoard(amplitude=1e13, region=region).predict(points)
+    # Define a set of observation points
+    coordinates = vd.grid_coordinates(
+        region=region, shape=(20, 20), extra_coords=radius
+    )
+    # Get synthetic data
+    data = point_mass_gravity(
+        coordinates, points, masses, field="g_z", coordinate_system="spherical"
+    )
+
+    # The interpolation should be perfect on the data points
+    eql = EQLHarmonicSpherical(relative_depth=500e3)
+    eql.fit(coordinates, data)
+    npt.assert_allclose(data, eql.predict(coordinates), rtol=1e-5)
+
+    # Check that the proper source locations were set
+    tmp = [i.ravel() for i in coordinates]
+    npt.assert_allclose(tmp[:2], eql.points_[:2], rtol=1e-5)
+    npt.assert_allclose(tmp[2] - 500e3, eql.points_[2], rtol=1e-5)
+
+    # Gridding at higher altitude should be reasonably accurate when compared
+    # to synthetic values
+    grid = vd.grid_coordinates(region=region, shape=(20, 20), extra_coords=radius + 2e3)
+    true = point_mass_gravity(
+        grid, points, masses, field="g_z", coordinate_system="spherical"
+    )
+    npt.assert_allclose(true, eql.predict(grid), rtol=0.05)
+
+
+def test_eql_harmonic_custom_points_spherical():
+    """
+    Check that passing in custom points works and actually uses the points
+    Use spherical coordinates.
+    """
+    region = (-70, -60, -40, -30)
+    radius = 6400e3
+    # Build synthetic point masses
+    points = vd.grid_coordinates(
+        region=region, shape=(6, 6), extra_coords=radius - 500e3
+    )
+    masses = vd.datasets.CheckerBoard(amplitude=1e13, region=region).predict(points)
+    # Define a set of observation points
+    coordinates = vd.grid_coordinates(
+        region=region, shape=(20, 20), extra_coords=radius
+    )
+    # Get synthetic data
+    data = point_mass_gravity(
+        coordinates, points, masses, field="g_z", coordinate_system="spherical"
+    )
+
+    # The interpolation should be perfect on the data points
+    src_points = tuple(
+        i.ravel()
+        for i in vd.grid_coordinates(
+            region=region, shape=(20, 20), extra_coords=radius - 500e3
+        )
+    )
+    eql = EQLHarmonicSpherical(points=src_points)
+    eql.fit(coordinates, data)
+    npt.assert_allclose(data, eql.predict(coordinates), rtol=1e-5)
+
+    # Check that the proper source locations were set
+    npt.assert_allclose(src_points, eql.points_, rtol=1e-5)
+
+    # Gridding at higher altitude should be reasonably accurate when compared
+    # to synthetic values
+    grid = vd.grid_coordinates(region=region, shape=(20, 20), extra_coords=radius + 2e3)
+    true = point_mass_gravity(
+        grid, points, masses, field="g_z", coordinate_system="spherical"
+    )
+    npt.assert_allclose(true, eql.predict(grid), rtol=0.05)