Repository: fatiando/boule

Issue:
Add geocentric surface radius calculation to TriaxialEllipsoid
**Description of the desired feature:**
<!--
Add as much detail as you can in your description below. If possible, include an
example of how you would like to use this feature (even better if it's a code
example).
-->

#72 added a `TriaxialEllipsoid` class defining only the base geometric properties. In #47 @MarkWieczorek mentioned that calculating the surface radius of the ellipsoid would be useful. Seems like a good place to start adding some computations.

https://en.wikipedia.org/wiki/Ellipsoid is a good place to start with equations and references.



**Are you willing to help implement and maintain this feature?** 
<!--
Every feature we add is code that we will have to maintain and keep updated.
Please let us know if you're willing to help maintain this feature in the future.
-->
Willing to help if anyone wants to give this a shot but probably won't do it myself.

Discussion:
welcome[bot]: 💖 **Thank you for opening your first pull request in this repository!** 💖

A few things to keep in mind:

* [Authorship Guidelines](https://github.com/fatiando/community/blob/main/AUTHORSHIP.md): Our rules for giving you credit for your contributions, including authorship on publications and [Zenodo](https://zenodo.org/communities/fatiando) archives.
* [Code of Conduct](https://github.com/fatiando/community/blob/main/CODE_OF_CONDUCT.md): How we expect people to interact in our projects.

⭐ **No matter what, we are really grateful that you put in the effort to do this!** ⭐

santisoler: Hi @blazej-bucha! Thanks so much for opening this PR! I'm really sorry for the delay of my reply.

I think this is a great addition. Let me review it a little bit and will come back with comments.

Regarding the code-style Action that fails, apparently you run an older version of Black that used to format every power operator (`**`) with leading and trailing spaces. The updated version of Black we use in Boule formats simple power operators without spaces between the base and the power (check [this section in Black's docs](https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#line-breaks-binary-operators)).
blazej-bucha: Hi @santisoler,

Thanks for looking at the pull request and for your comments!  All your suggestions should now be applied.  In addition, reverting the order of input parameters in  `geocentric_radius` necessitated a few more edits in tests that call this method.

Formating should now hopefully be OK.  Previously, I used indeed an older version of Black from Debian repos.
blazej-bucha: @santisoler, thanks for your edits!  I directly commited all of them.

I'm now happy with the pull request, too.  Thanks again for reviewing the code.
santisoler: Thanks @blazej-bucha for the contribution! I've just made a few style changes and removed some unused variables that flake was complaining about. I'm merging this! 🚀 

Patch:
diff --git a/boule/_triaxialellipsoid.py b/boule/_triaxialellipsoid.py
index 8a951ad..d3fe2d6 100644
--- a/boule/_triaxialellipsoid.py
+++ b/boule/_triaxialellipsoid.py
@@ -184,3 +184,114 @@ class TriaxialEllipsoid:
             * self.semimedium_axis
             * self.semiminor_axis
         )
+
+    @property
+    def equatorial_flattening(self):
+        r"""
+        The equatorial flattening of the ellipsoid.
+        Definition: :math:`f_b = \frac{a - b}{a}`.
+        Units: adimensional.
+        """
+        return (self.semimajor_axis - self.semimedium_axis) / self.semimajor_axis
+
+    @property
+    def meridional_flattening(self):
+        r"""
+        The meridional flattening of the ellipsoid in the meridian plane
+        containing the semi-major axis.
+        Definition: :math:`f_c = \frac{a - c}{a}`.
+        Units: adimensional.
+        """
+        return (self.semimajor_axis - self.semiminor_axis) / self.semimajor_axis
+
+    def geocentric_radius(self, longitude, latitude, longitude_semimajor_axis=0.0):
+        r"""
+        Radial distance from the center of the ellipsoid to its surface.
+
+        Assumes geocentric spherical latitude and geocentric spherical
+        longitudes. The geocentric radius is calculated following [Pec1983]_.
+
+        Parameters
+        ----------
+        longitude : float or array
+            Longitude coordinates on spherical coordinate system in degrees.
+        latitude : float or array
+            Latitude coordinates on spherical coordinate system in degrees.
+        longitude_semimajor_axis : float (optional)
+            Longitude coordinate of the meridian containing the semi-major axis
+            on spherical coordinate system in degrees. Optional, default value
+            is 0.0.
+
+        Returns
+        -------
+        geocentric_radius : float or array
+            The geocentric radius for the given spherical latitude(s) and
+            spherical longitude(s) in the same units as the axes of the
+            ellipsoid.
+
+
+        .. tip::
+
+            No elevation is taken into account.
+
+        Notes
+        -----
+
+        Given geocentric spherical latitude :math:`\phi` and geocentric
+        spherical longitude :math:`\lambda`, the geocentric surface radius
+        :math:`R` is computed as (see Eq. 1 of [Pec1983]_)
+
+        .. math::
+
+            R(\phi, \lambda) =
+            \frac{
+                a \, (1 - f_c) \, (1 - f_b)
+            }{
+                \sqrt{
+                    1
+                    - (2 f_c - f_c^2) \cos^2 \phi
+                    - (2 f_b - f_b^2) \sin^2 \phi
+                    - (1 - f_c)^2 (2 f_b - f_b^2)
+                        \cos^2 \phi \cos^2 (\lambda - \lambda_a)
+                }
+            },
+
+        where :math:`f_c` is the meridional flattening
+
+        .. math::
+
+            f_c = \frac{a - c}{a},
+
+        :math:`f_b` is the equatorial flattening
+
+        .. math::
+
+            f_b = \frac{a - b}{a},
+
+        with :math:`a`, :math:`b` and :math:`c` being the semi-major,
+        semi-medium and semi-minor axes of the ellipsoid, and :math:`\lambda_a`
+        being the geocentric spherical longitude of the meridian containing the
+        semi-major axis.
+
+        Note that [Pec1983]_ use geocentric spherical co-latitude, while here
+        we used geocentric spherical latitude.
+        """
+        latitude_rad = np.radians(latitude)
+        longitude_rad = np.radians(longitude)
+        longitude_semimajor_axis_rad = np.radians(longitude_semimajor_axis)
+
+        coslat, sinlat = np.cos(latitude_rad), np.sin(latitude_rad)
+
+        fc = self.meridional_flattening
+        fb = self.equatorial_flattening
+
+        radius = (self.semimajor_axis * (1.0 - fc) * (1.0 - fb)) / np.sqrt(
+            1.0
+            - (2.0 * fc - fc**2) * coslat**2
+            - (2.0 * fb - fb**2) * sinlat**2
+            - (1.0 - fc) ** 2
+            * (2.0 * fb - fb**2)
+            * coslat**2
+            * np.cos(longitude_rad - longitude_semimajor_axis_rad) ** 2
+        )
+        return radius
diff --git a/doc/references.rst b/doc/references.rst
index 8d9dc7e..840f01c 100644
--- a/doc/references.rst
+++ b/doc/references.rst
@@ -11,3 +11,4 @@ References
 .. [Russell2012] Russell, C. T., Raymond, C. A., Coradini, A., McSween, H. Y., Zuber, M. T., Nathues, A., et al. (2012). Dawn at Vesta: Testing the Protoplanetary Paradigm. Science. doi:`10.1126/science.1219381 <https://doi.org/10.1126/science.1219381>`__
 .. [Vermeille2002] Vermeille, H., 2002. Direct transformation from geocentric coordinates to geodetic coordinates. Journal of Geodesy. 76. 451-454. doi:`10.1007/s00190-002-0273-6 <https://doi.org/10.1007/s00190-002-0273-6>`__
 .. [Wieczorek2015] Wieczorek, M. A. (2015). Gravity and Topography of the Terrestrial Planets. In Treatise on Geophysics (pp. 153–193). Elsevier. doi:`10.1016/b978-0-444-53802-4.00169-x <https://doi.org/10.1016/b978-0-444-53802-4.00169-x>`__
+.. [Pec1983] Pěč, K. & Martinec, Z. (1983). Expansion of geoid heights over a triaxial Earth's ellipsoid into a spherical harmonic series.  Studia Geophysica et Geodaetica, 27, 217-232. doi: `10.1007/BF01592791 <https://doi.org/10.1007/BF01592791>`__


Test patch:
diff --git a/boule/tests/test_triaxialellipsoid.py b/boule/tests/test_triaxialellipsoid.py
index 1f088b2..aaeb46c 100644
--- a/boule/tests/test_triaxialellipsoid.py
+++ b/boule/tests/test_triaxialellipsoid.py
@@ -10,9 +10,10 @@ Test the base TriaxialEllipsoid class.
 import warnings
 
 import numpy as np
+import numpy.testing as npt
 import pytest
 
-from .. import TriaxialEllipsoid
+from .. import Ellipsoid, TriaxialEllipsoid
 
 
 @pytest.fixture
@@ -199,3 +200,83 @@ def test_volume_lt_majorsphere(triaxialellipsoid):
     assert (
         triaxialellipsoid.volume < 4 * np.pi / 3 * triaxialellipsoid.semimajor_axis**3
     )
+
+
+def test_geocentric_radius_poles(triaxialellipsoid):
+    """
+    Check against values at the poles
+    """
+    latitude = np.array([-90.0, -90.0, 90.0, 90.0])
+    longitude = np.array([0.0, 90.0, 0.0, 90.0])
+    radius_true = np.full(latitude.shape, triaxialellipsoid.semiminor_axis)
+    npt.assert_allclose(
+        radius_true, triaxialellipsoid.geocentric_radius(longitude, latitude)
+    )
+
+
+def test_geocentric_radius_equator(triaxialellipsoid):
+    """
+    Check against values at the equator
+    """
+    latitude = np.zeros(4)
+    longitude = np.array([0.0, 90.0, 180.0, 270.0])
+    radius_true = np.array(
+        [
+            triaxialellipsoid.semimajor_axis,
+            triaxialellipsoid.semimedium_axis,
+            triaxialellipsoid.semimajor_axis,
+            triaxialellipsoid.semimedium_axis,
+        ]
+    )
+    npt.assert_allclose(
+        radius_true, triaxialellipsoid.geocentric_radius(longitude, latitude)
+    )
+
+
+def test_geocentric_radius_semimajor_axis_longitude(triaxialellipsoid):
+    """
+    Check against non-zero longitude of the semi-major axis
+    """
+    latitude = np.zeros(4)
+    longitude = np.array([0.0, 90.0, 180.0, 270.0])
+    radius_true = np.array(
+        [
+            triaxialellipsoid.semimedium_axis,
+            triaxialellipsoid.semimajor_axis,
+            triaxialellipsoid.semimedium_axis,
+            triaxialellipsoid.semimajor_axis,
+        ]
+    )
+    npt.assert_allclose(
+        radius_true, triaxialellipsoid.geocentric_radius(longitude, latitude, 90.0)
+    )
+
+
+def test_geocentric_radius_biaxialellipsoid(triaxialellipsoid):
+    """
+    Check against values of a reference biaxial ellipsoid
+    """
+    # Get the defining parameters of "triaxialellipsoid"
+    a = triaxialellipsoid.semimajor_axis
+    c = triaxialellipsoid.semiminor_axis
+    gm = triaxialellipsoid.geocentric_grav_const
+    omega = triaxialellipsoid.angular_velocity
+
+    # Instantiate the "Ellipsoid" class using the following defining parameters
+    # from "triaxialellipsoid": semimajor axis "a", semiminor axis "c",
+    # geocentric gravitational constant "GM" and angular velocity "omega"
+    biaxialellipsoid_ref = Ellipsoid("biaxell_ref", a, (a - c) / a, gm, omega)
+
+    # Instantiate the "TriaxialEllipsoid" class such that it in fact represents
+    # "biaxialellipsoid_ref"
+    biaxialellipsoid = TriaxialEllipsoid("biaxell", a, a, c, gm, omega)
+
+    latitude = np.arange(-90.0, 90.0, 1.0)
+    longitude = np.linspace(0.0, 360.0, num=latitude.size, endpoint=False)
+
+    # Compute the reference geocentric radii using the reference biaxial
+    # ellipsoid
+    radius_true = biaxialellipsoid_ref.geocentric_radius(latitude, geodetic=False)
+    npt.assert_allclose(
+        radius_true, biaxialellipsoid.geocentric_radius(longitude, latitude)
+    )