Repository: fatiando/harmonica

Issue:
Function to convert geocentric spherical coordinates back to geodetic
**Description of the desired feature**

In #28 we added the function `geodetic_to_spherical` to convert geodetic coordinates to geocentric spherical coordinates. It would be great to have the inverse transform as well (`spherical_to_geodetic`). The two functions should have similar docstrings/interfaces and include links to each other in their docstrings (using a `See also` section [[example](https://github.com/fatiando/verde/blob/master/verde/coordinates.py#L196)]).


Comments:

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index c28a961..1ca7fa0 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -42,6 +42,7 @@ Coordinates Conversions
    :toctree: generated/
 
     geodetic_to_spherical
+    spherical_to_geodetic
 
 Input and Output
 ----------------
diff --git a/harmonica/__init__.py b/harmonica/__init__.py
index 132394a..fb6c1e0 100644
--- a/harmonica/__init__.py
+++ b/harmonica/__init__.py
@@ -11,7 +11,7 @@ from .ellipsoid import (
 from .io import load_icgem_gdf
 from .isostasy import isostasy_airy
 from .gravity_corrections import normal_gravity, bouguer_correction
-from .coordinates import geodetic_to_spherical
+from .coordinates import geodetic_to_spherical, spherical_to_geodetic
 
 
 # Get the version number through versioneer
diff --git a/harmonica/coordinates.py b/harmonica/coordinates.py
index cad1259..227f3cf 100644
--- a/harmonica/coordinates.py
+++ b/harmonica/coordinates.py
@@ -28,6 +28,10 @@ def geodetic_to_spherical(latitude, height):
         (in degrees).
     radius : float or array
         The radial coordinate in the geocentric spherical reference system (in meters).
+
+    See also
+    --------
+    spherical_to_geodetic : Convert from geocentric spherical to geodetic coordinates.
     """
     ellipsoid = get_ellipsoid()
     # Convert latitude to radians
@@ -42,5 +46,77 @@ def geodetic_to_spherical(latitude, height):
         height + (1 - ellipsoid.first_eccentricity ** 2) * prime_vertical_radius
     ) * np.sin(latitude_rad)
     radius = np.sqrt(xy_projection ** 2 + z_cartesian ** 2)
-    geocentric_latitude = 180 / np.pi * np.arcsin(z_cartesian / radius)
+    geocentric_latitude = np.degrees(np.arcsin(z_cartesian / radius))
     return geocentric_latitude, radius
+
+
+def spherical_to_geodetic(geocentric_latitude, radius):
+    """
+    Convert from geocentric spherical to geodetic coordinates.
+
+    The geodetic datum is defined by the default :class:`harmonica.ReferenceEllipsoid`
+    set by the :func:`harmonica.set_ellipsoid` function.
+    The coordinates are converted following [Vermeille2002]_.
+
+    Parameters
+    ----------
+    geocentric_latitude : float or array
+        The latitude coordinate in the geocentric spherical reference system
+        (in degrees).
+    radius : float or array
+        The radial coordinate in the geocentric spherical reference system (in meters).
+
+    Returns
+    -------
+    latitude : float or array
+        The geodetic latitude (in degrees).
+    height : float or array
+        The ellipsoidal (geometric or geodetic) height (in meters).
+
+    See also
+    --------
+    geodetic_to_spherical : Convert from geodetic to geocentric spherical coordinates.
+    """
+    ellipsoid = get_ellipsoid()
+    k, big_d, big_z = _spherical_to_geodetic_parameters(geocentric_latitude, radius)
+    latitude = np.degrees(
+        2 * np.arctan(big_z / (big_d + np.sqrt(big_d ** 2 + big_z ** 2)))
+    )
+    height = (
+        (k + ellipsoid.first_eccentricity ** 2 - 1)
+        / k
+        * np.sqrt(big_d ** 2 + big_z ** 2)
+    )
+    return latitude, height
+
+
+def _spherical_to_geodetic_parameters(geocentric_latitude, radius):
+    "Compute parameters for spherical to geodetic coordinates conversion"
+    ellipsoid = get_ellipsoid()
+    # Convert latitude to radians
+    geocentric_latitude_rad = np.radians(geocentric_latitude)
+    big_z = radius * np.sin(geocentric_latitude_rad)
+    p_0 = (
+        radius ** 2
+        * np.cos(geocentric_latitude_rad) ** 2
+        / ellipsoid.semimajor_axis ** 2
+    )
+    q_0 = (
+        (1 - ellipsoid.first_eccentricity ** 2)
+        / ellipsoid.semimajor_axis ** 2
+        * big_z ** 2
+    )
+    r_0 = (p_0 + q_0 - ellipsoid.first_eccentricity ** 4) / 6
+    s_0 = ellipsoid.first_eccentricity ** 4 * p_0 * q_0 / 4 / r_0 ** 3
+    t_0 = np.cbrt(1 + s_0 + np.sqrt(2 * s_0 + s_0 ** 2))
+    u_0 = r_0 * (1 + t_0 + 1 / t_0)
+    v_0 = np.sqrt(u_0 ** 2 + q_0 * ellipsoid.first_eccentricity ** 4)
+    w_0 = ellipsoid.first_eccentricity ** 2 * (u_0 + v_0 - q_0) / 2 / v_0
+    k = np.sqrt(u_0 + v_0 + w_0 ** 2) - w_0
+    big_d = (
+        k
+        * radius
+        * np.cos(geocentric_latitude_rad)
+        / (k + ellipsoid.first_eccentricity ** 2)
+    )
+    return k, big_d, big_z


Test patch:
diff --git a/harmonica/tests/test_coordinates.py b/harmonica/tests/test_coordinates.py
index 1f8beb5..d660b0b 100644
--- a/harmonica/tests/test_coordinates.py
+++ b/harmonica/tests/test_coordinates.py
@@ -4,7 +4,7 @@ Test coordinates conversions.
 import numpy as np
 import numpy.testing as npt
 
-from ..coordinates import geodetic_to_spherical
+from ..coordinates import geodetic_to_spherical, spherical_to_geodetic
 from ..ellipsoid import (
     KNOWN_ELLIPSOIDS,
     set_ellipsoid,
@@ -54,3 +54,66 @@ def test_geodetic_to_spherical_on_poles():
             geocentric_latitude, radius = geodetic_to_spherical(latitude, height)
             npt.assert_allclose(geocentric_latitude, latitude, rtol=rtol)
             npt.assert_allclose(radius, height + ellipsoid.semiminor_axis, rtol=rtol)
+
+
+def test_spherical_to_geodetic_with_spherical_ellipsoid():
+    "Test spherical to geodetic coordinates conversion if ellipsoid is a sphere."
+    rtol = 1e-10
+    sphere_radius = 1.0
+    # Define a "spherical" ellipsoid with radius equal to 1m.
+    # To do so, we define a zero flattening, thus an infinite inverse flattening.
+    spherical_ellipsoid = ReferenceEllipsoid(
+        "unit_sphere", sphere_radius, np.infty, 0, 0
+    )
+    with set_ellipsoid(spherical_ellipsoid):
+        geocentric_latitude = np.linspace(-90, 90, 5)
+        radius = np.linspace(0.8, 1.2, 5)
+        latitude, height = spherical_to_geodetic(geocentric_latitude, radius)
+        npt.assert_allclose(geocentric_latitude, latitude, rtol=rtol)
+        npt.assert_allclose(radius, sphere_radius + height, rtol=rtol)
+
+
+def test_spherical_to_geodetic_on_equator():
+    "Test spherical to geodetic coordinates conversion on equator."
+    rtol = 1e-10
+    size = 5
+    geocentric_latitude = np.zeros(size)
+    for ellipsoid_name in KNOWN_ELLIPSOIDS:
+        with set_ellipsoid(ellipsoid_name):
+            ellipsoid = get_ellipsoid()
+            radius = np.linspace(-1e4, 1e4, size) + ellipsoid.semimajor_axis
+            latitude, height = spherical_to_geodetic(geocentric_latitude, radius)
+            npt.assert_allclose(geocentric_latitude, latitude, rtol=rtol)
+            npt.assert_allclose(radius, height + ellipsoid.semimajor_axis, rtol=rtol)
+
+
+def test_spherical_to_geodetic_on_poles():
+    "Test spherical to geodetic coordinates conversion on poles."
+    rtol = 1e-10
+    size = 5
+    geocentric_latitude = np.array([90.0] * size + [-90.0] * size)
+    for ellipsoid_name in KNOWN_ELLIPSOIDS:
+        with set_ellipsoid(ellipsoid_name):
+            ellipsoid = get_ellipsoid()
+            radius = np.hstack(
+                [np.linspace(-1e4, 1e4, size) + ellipsoid.semiminor_axis] * 2
+            )
+            latitude, height = spherical_to_geodetic(geocentric_latitude, radius)
+            npt.assert_allclose(geocentric_latitude, latitude, rtol=rtol)
+            npt.assert_allclose(radius, height + ellipsoid.semiminor_axis, rtol=rtol)
+
+
+def test_identity():
+    "Test if geodetic_to_spherical and spherical_to_geodetic is the identity operator"
+    rtol = 1e-10
+    latitude = np.linspace(-90, 90, 19)
+    height = np.linspace(-1e4, 1e4, 8)
+    latitude, height = np.meshgrid(latitude, height)
+    for ellipsoid_name in KNOWN_ELLIPSOIDS:
+        with set_ellipsoid(ellipsoid_name):
+            geocentric_latitude, radius = geodetic_to_spherical(latitude, height)
+            converted_latitude, converted_height = spherical_to_geodetic(
+                geocentric_latitude, radius
+            )
+            npt.assert_allclose(latitude, converted_latitude, rtol=rtol)
+            npt.assert_allclose(height, converted_height, rtol=rtol)