Repository: fatiando/boule

Issue:
Include a reference Sphere class
As shown in #13, flattening of 0 leads to not-a-number in the computations due to division by the first eccentricity (which is 0). In these cases, it would make more sense to have a `boule.Sphere` class that mirrors the `Ellipsoid` but is much simpler:

```python
Sphere(radius=..., geocentric_grav_const=..., angular_velocity=...)
```

All the eccentricities, gravity at the pole and equator, and coordinate conversions aren't needed. The forward modelling equations are also simpler (should be implemented in `Sphere.normal_gravity`).

Comments:

Patch:
diff --git a/boule/__init__.py b/boule/__init__.py
index 00640f1..e83adcf 100644
--- a/boule/__init__.py
+++ b/boule/__init__.py
@@ -2,6 +2,7 @@
 # Import functions/classes to make the public API
 from . import version
 from .ellipsoid import Ellipsoid
+from .sphere import Sphere
 from .realizations import WGS84, GRS80, MARS
 
 
diff --git a/boule/ellipsoid.py b/boule/ellipsoid.py
index 441d9d6..8ed4217 100644
--- a/boule/ellipsoid.py
+++ b/boule/ellipsoid.py
@@ -1,6 +1,7 @@
 """
 Module for defining and setting the reference ellipsoid.
 """
+from warnings import warn
 import attr
 import numpy as np
 
@@ -41,30 +42,30 @@ class Ellipsoid:
     Examples
     --------
 
-    We can define a reference unit sphere by using 0 as the flattening:
+    We can define an ellipsoid with flattening equal to 0.5 and unit semimajor axis:
 
-    >>> sphere = Ellipsoid(
-    ...     name="sphere",
-    ...     long_name="Unit sphere",
+    >>> ellipsoid = Ellipsoid(
+    ...     name="oblate-ellipsoid",
+    ...     long_name="Oblate Ellipsoid",
     ...     semimajor_axis=1,
-    ...     flattening=0,
+    ...     flattening=0.5,
     ...     geocentric_grav_const=1,
     ...     angular_velocity=0
     ... )
-    >>> print(sphere) # doctest: +ELLIPSIS
-    Ellipsoid(name='sphere', ...)
-    >>> print(sphere.long_name)
-    Unit sphere
-    >>> print("{:.2f}".format(sphere.semiminor_axis))
-    1.00
-    >>> print("{:.2f}".format(sphere.mean_radius))
-    1.00
-    >>> print("{:.2f}".format(sphere.linear_eccentricity))
-    0.00
-    >>> print("{:.2f}".format(sphere.first_eccentricity))
-    0.00
-    >>> print("{:.2f}".format(sphere.second_eccentricity))
-    0.00
+    >>> print(ellipsoid) # doctest: +ELLIPSIS
+    Ellipsoid(name='oblate-ellipsoid', ...)
+    >>> print(ellipsoid.long_name)
+    Oblate Ellipsoid
+    >>> print("{:.2f}".format(ellipsoid.semiminor_axis))
+    0.50
+    >>> print("{:.2f}".format(ellipsoid.mean_radius))
+    0.83
+    >>> print("{:.2f}".format(ellipsoid.linear_eccentricity))
+    0.87
+    >>> print("{:.2f}".format(ellipsoid.first_eccentricity))
+    0.87
+    >>> print("{:.2f}".format(ellipsoid.second_eccentricity))
+    1.73
 
     """
 
@@ -76,6 +77,19 @@ class Ellipsoid:
     long_name = attr.ib(default=None)
     reference = attr.ib(default=None)
 
+    @flattening.validator
+    def check_flattening(
+        self, flattening, value
+    ):  # pylint: disable=no-self-use,unused-argument
+        """
+        Check if flattening is not equal (or almost) zero
+        """
+        warn_msg = "Use boule.Sphere for representing ellipsoids with zero flattening."
+        if value == 0:
+            warn("Flattening equal to zero. " + warn_msg)
+        if value < 1e-7:
+            warn("Flattening '{}' too close to zero. ".format(value) + warn_msg)
+
     @property
     def semiminor_axis(self):
         "The small (polar) axis of the ellipsoid [meters]"
diff --git a/boule/sphere.py b/boule/sphere.py
new file mode 100644
index 0000000..498ec3b
--- /dev/null
+++ b/boule/sphere.py
@@ -0,0 +1,161 @@
+"""
+Module for defining and setting the reference sphere.
+"""
+import attr
+import numpy as np
+
+from . import Ellipsoid
+
+
+# Don't let ellipsoid parameters be changed to avoid messing up calculations
+# accidentally.
+@attr.s(frozen=True)
+class Sphere(Ellipsoid):
+    """
+    Reference spherical ellipsoid
+
+    Represents ellipsoids with zero flattening (spheres). Inherits methods and
+    properties of the :class:`boule.Ellipsoid`, guaranteeing no singularities
+    due to zero flattening (and thus zero eccentricity).
+
+    All parameters are in SI units.
+
+    Parameters
+    ----------
+    name : str
+        A short name for the ellipsoid, for example ``'MOON'``.
+    radius : float
+        The radius of the sphere [meters].
+    geocentric_grav_const : float
+        The geocentric gravitational constant (GM) [m^3 s^-2].
+    angular_velocity : float
+        The angular velocity of the rotating ellipsoid (omega) [rad s^-1].
+    long_name : str or None
+        A long name for the ellipsoid, for example ``"Moon Reference System"``
+        (optional).
+    reference : str or None
+        Citation for the ellipsoid parameter values (optional).
+
+    Examples
+    --------
+
+    We can define a unit sphere:
+
+    >>> sphere = Sphere(
+    ...     name="sphere",
+    ...     radius=1,
+    ...     geocentric_grav_const=1,
+    ...     angular_velocity=0,
+    ...     long_name="Spherical Ellipsoid",
+    ... )
+    >>> print(sphere) # doctest: +ELLIPSIS
+    Sphere(name='sphere', ...)
+    >>> print(sphere.long_name)
+    Spherical Ellipsoid
+    >>> print("{:.2f}".format(sphere.semiminor_axis))
+    1.00
+    >>> print("{:.2f}".format(sphere.mean_radius))
+    1.00
+    >>> print("{:.2f}".format(sphere.flattening))
+    0.00
+    >>> print("{:.2f}".format(sphere.linear_eccentricity))
+    0.00
+    >>> print("{:.2f}".format(sphere.first_eccentricity))
+    0.00
+    >>> print("{:.2f}".format(sphere.second_eccentricity))
+    0.00
+    >>> print(sphere.normal_gravity(latitude=0, height=1))
+    25000.0
+    >>> print(sphere.normal_gravity(latitude=90, height=1))
+    25000.0
+    """
+
+    name = attr.ib()
+    radius = attr.ib()
+    geocentric_grav_const = attr.ib()
+    angular_velocity = attr.ib()
+    long_name = attr.ib(default=None)
+    reference = attr.ib(default=None)
+    # semimajor_axis and flattening shouldn't be defined on initialization:
+    #   - semimajor_axis will be equal to radius
+    #   - flattening will be equal to zero
+    semimajor_axis = attr.ib(init=False)
+    flattening = attr.ib(init=False, default=0)
+
+    @semimajor_axis.default
+    def _set_semimajor_axis(self):
+        "The semimajor axis should be the radius"
+        return self.radius
+
+    def normal_gravity(self, latitude, height):
+        """
+        Calculate normal gravity at any latitude and height
+
+        Computes the magnitude of the gradient of the gravity potential
+        (gravitational + centrifugal) generated by the sphere at the given
+        latitude and height.
+
+        Parameters
+        ----------
+        latitude : float or array
+            The latitude where the normal gravity will be computed (in degrees).
+        height : float or array
+            The height (above the sphere) of computation point (in meters).
+
+        Returns
+        -------
+        gamma : float or array
+            The normal gravity in mGal.
+
+        References
+        ----------
+        [Heiskanen-Moritz]_
+        """
+        return self._gravity_sphere(height) + self._centrifugal_force(latitude, height)
+
+    def _gravity_sphere(self, height):
+        """
+        Calculate the gravity generated by a solid sphere (mGal)
+        """
+        return 1e5 * self.geocentric_grav_const / (self.radius + height) ** 2
+
+    def _centrifugal_force(self, latitude, height):
+        """
+        Calculate the centrifugal force due to the rotation of the sphere (mGal)
+        """
+        return 1e5 * (
+            (-1)
+            * self.angular_velocity ** 2
+            * (self.radius + height)
+            * np.cos(np.radians(latitude))
+        )
+
+    @property
+    def gravity_equator(self):
+        """
+        The norm of the gravity vector at the equator on the sphere [m/s^2]
+
+        Overrides the inherited method from :class:`boule.Ellipsoid` to avoid
+        singularities due to zero flattening.
+        """
+        return self._gravity_on_surface
+
+    @property
+    def gravity_pole(self):
+        """
+        The norm of the gravity vector at the poles on the sphere [m/s^2]
+
+        Overrides the inherited method from :class:`boule.Ellipsoid` to avoid
+        singularities due to zero flattening.
+        """
+        return self._gravity_on_surface
+
+    @property
+    def _gravity_on_surface(self):
+        """
+        Compute norm of the gravity vector on the surface of the sphere [m/s^2]
+
+        Due to rotational symmetry, the norm of the gravity vector is the same
+        on every point of the surface.
+        """
+        return self.geocentric_grav_const / self.radius ** 2
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 74052c5..225d36a 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -14,10 +14,12 @@ Reference Ellipsoid
    :toctree: generated/
 
     Ellipsoid
+    Sphere
 
-All ellipsoids are instances of the :class:`~boule.Ellipsoid` class. See the
-class reference documentation for a list its derived physical properties
-(attributes) and computations/transformations that it can perform (methods).
+All ellipsoids are instances of the :class:`~boule.Ellipsoid` or the
+:class:`~boule.Sphere` classes. See the class reference documentation for
+a list their derived physical properties (attributes) and
+computations/transformations that they can perform (methods).
 
 Utilities
 ---------
diff --git a/doc/references.rst b/doc/references.rst
index faeac11..924b508 100644
--- a/doc/references.rst
+++ b/doc/references.rst
@@ -2,6 +2,7 @@ References
 ==========
 
 .. [Ardalan2009] Ardalan, A. A., Karimi, R., & Grafarend, E. W. (2009). A New Reference Equipotential Surface, and Reference Ellipsoid for the Planet Mars. Earth, Moon, and Planets, 106(1), 1. https://doi.org/10.1007/s11038-009-9342-7
+.. [Heiskanen-Moritz] Heiskanen, W.A. & Moritz, H. (1967) Physical Geodesy. W.H. Freeman and Company.
 .. [Hofmann-WellenhofMoritz2006] Hofmann-Wellenhof, B., & Moritz, H. (2006). Physical Geodesy (2nd, corr. ed. 2006 edition ed.). Wien ; New York: Springer.
 .. [LiGotze2001] Li, X. and H. J. Gotze, 2001, Tutorial: Ellipsoid, geoid, gravity, geodesy, and geophysics, Geophysics, 66(6), p. 1660-1668, doi:`10.1190/1.1487109 <https://doi.org/10.1190/1.1487109>`__
 .. [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>`__
diff --git a/tutorials/overview.py b/tutorials/overview.py
index cc2ad88..a540ffb 100644
--- a/tutorials/overview.py
+++ b/tutorials/overview.py
@@ -62,28 +62,36 @@ print(bl.MARS.gravity_pole)
 print(bl.MARS.gravity_equator)
 
 ###############################################################################
-# You can also define your own ellipsoid. For example, this would be the
-# definition of a sphere with 1000 m radius and dummy values for :math:`GM` and
-# :math:`\omega`:
-
-sphere = bl.Ellipsoid(
-    name="Sphere",
-    long_name="Ellipsoid with 0 flattening",
-    flattening=0,
+# You can also define your own ellipsoid. For example, this would be a definition of an
+# ellipsoid with 1000 m semimajor axis, flattening equal to 0.5 and dummy values for
+# :math:`GM` and :math:`\omega`:
+
+ellipsoid = bl.Ellipsoid(
+    name="Ellipsoid",
+    long_name="Ellipsoid with 0.5 flattening",
+    flattening=0.5,
     semimajor_axis=1000,
     geocentric_grav_const=1,
     angular_velocity=1,
 )
-print(sphere)
-print(sphere.semiminor_axis)
-print(sphere.first_eccentricity)
+print(ellipsoid)
+print(ellipsoid.semiminor_axis)
+print(ellipsoid.first_eccentricity)
+
 
 ###############################################################################
-# However, the equations for calculating gravity are not suited for the 0
-# flattening case. **So don't define reference spheres like this.** This is due
-# to the first eccentricity being 0 (it appears in divisions in the equations).
+# If the ellipsoid has zero flattening (a sphere), you must use the
+# :class:`boule.Sphere` class instead. For example, this would be the definition of
+# a sphere with 1000 m radius and dummy values for :math:`GM` and :math:`\omega`:
 
-print(sphere.gravity_pole)
+sphere = bl.Sphere(
+    name="Sphere",
+    long_name="Ellipsoid with 0 flattening",
+    radius=1000,
+    geocentric_grav_const=1,
+    angular_velocity=1,
+)
+print(sphere)
 
 ###############################################################################
 # Computations


Test patch:
diff --git a/boule/tests/test_ellipsoid.py b/boule/tests/test_ellipsoid.py
index fe07cd4..9931889 100644
--- a/boule/tests/test_ellipsoid.py
+++ b/boule/tests/test_ellipsoid.py
@@ -2,6 +2,7 @@
 """
 Test the base Ellipsoid class.
 """
+import warnings
 import pytest
 import numpy as np
 import numpy.testing as npt
@@ -12,47 +13,30 @@ from .. import Ellipsoid, ELLIPSOIDS
 ELLIPSOID_NAMES = [e.name for e in ELLIPSOIDS]
 
 
-@pytest.fixture
-def sphere():
-    "A spherical ellipsoid"
-    ellipsoid = Ellipsoid(
-        name="unit_sphere",
-        semimajor_axis=1.0,
-        flattening=0,
-        geocentric_grav_const=0,
-        angular_velocity=0,
-    )
-    return ellipsoid
-
-
-def test_geodetic_to_spherical_with_spherical_ellipsoid(sphere):
-    "Test geodetic to geocentric conversion if ellipsoid is a sphere."
-    rtol = 1e-10
-    size = 5
-    longitude = np.linspace(0, 180, size)
-    latitude = np.linspace(-90, 90, size)
-    height = np.linspace(-0.2, 0.2, size)
-    sph_longitude, sph_latitude, radius = sphere.geodetic_to_spherical(
-        longitude, latitude, height
-    )
-    npt.assert_allclose(sph_longitude, longitude, rtol=rtol)
-    npt.assert_allclose(sph_latitude, latitude, rtol=rtol)
-    npt.assert_allclose(radius, sphere.mean_radius + height, rtol=rtol)
-
-
-def test_spherical_to_geodetic_with_spherical_ellipsoid(sphere):
-    "Test spherical to geodetic conversion if ellipsoid is a sphere."
-    rtol = 1e-10
-    size = 5
-    spherical_longitude = np.linspace(0, 180, size)
-    spherical_latitude = np.linspace(-90, 90, size)
-    radius = np.linspace(0.8, 1.2, size)
-    longitude, latitude, height = sphere.spherical_to_geodetic(
-        spherical_longitude, spherical_latitude, radius
-    )
-    npt.assert_allclose(spherical_longitude, longitude, rtol=rtol)
-    npt.assert_allclose(spherical_latitude, latitude, rtol=rtol)
-    npt.assert_allclose(radius, sphere.mean_radius + height, rtol=rtol)
+def test_ellipsoid_zero_flattening():
+    """
+    Check if error is raised after passing zero flattening
+    """
+    # Test with zero flattening
+    with warnings.catch_warnings(record=True) as warn:
+        Ellipsoid(
+            name="zero-flattening",
+            semimajor_axis=1,
+            flattening=0,
+            geocentric_grav_const=1,
+            angular_velocity=0,
+        )
+        assert len(warn) >= 1
+    # Test with almost zero flattening
+    with warnings.catch_warnings(record=True) as warn:
+        Ellipsoid(
+            name="almost-zero-flattening",
+            semimajor_axis=1,
+            flattening=1e-8,
+            geocentric_grav_const=1,
+            angular_velocity=0,
+        )
+        assert len(warn) >= 1
 
 
 @pytest.mark.parametrize("ellipsoid", ELLIPSOIDS, ids=ELLIPSOID_NAMES)
diff --git a/boule/tests/test_sphere.py b/boule/tests/test_sphere.py
new file mode 100644
index 0000000..f4daedd
--- /dev/null
+++ b/boule/tests/test_sphere.py
@@ -0,0 +1,157 @@
+# pylint: disable=redefined-outer-name
+"""
+Test the base Sphere class.
+"""
+import pytest
+import numpy as np
+import numpy.testing as npt
+
+from .. import Sphere
+
+
+@pytest.fixture
+def sphere():
+    "A spherical ellipsoid"
+    ellipsoid = Sphere(
+        name="unit_sphere", radius=1.0, geocentric_grav_const=2.0, angular_velocity=1.3,
+    )
+    return ellipsoid
+
+
+def test_sphere_flattening(sphere):
+    """
+    Check if flattening property is equal to zero
+    """
+    assert sphere.flattening == 0
+
+
+def test_sphere_semimajor_axis(sphere):
+    """
+    Check if semimajor_axis is equal to the radius
+    """
+    npt.assert_allclose(sphere.semimajor_axis, sphere.radius)
+
+
+def test_geodetic_to_spherical(sphere):
+    "Test geodetic to geocentric conversion on spherical ellipsoid."
+    rtol = 1e-10
+    size = 5
+    longitude = np.linspace(0, 180, size)
+    latitude = np.linspace(-90, 90, size)
+    height = np.linspace(-0.2, 0.2, size)
+    sph_longitude, sph_latitude, radius = sphere.geodetic_to_spherical(
+        longitude, latitude, height
+    )
+    npt.assert_allclose(sph_longitude, longitude, rtol=rtol)
+    npt.assert_allclose(sph_latitude, latitude, rtol=rtol)
+    npt.assert_allclose(radius, sphere.radius + height, rtol=rtol)
+
+
+def test_spherical_to_geodetic(sphere):
+    "Test spherical to geodetic conversion on spherical ellipsoid."
+    rtol = 1e-10
+    size = 5
+    spherical_longitude = np.linspace(0, 180, size)
+    spherical_latitude = np.linspace(-90, 90, size)
+    radius = np.linspace(0.8, 1.2, size)
+    longitude, latitude, height = sphere.spherical_to_geodetic(
+        spherical_longitude, spherical_latitude, radius
+    )
+    npt.assert_allclose(spherical_longitude, longitude, rtol=rtol)
+    npt.assert_allclose(spherical_latitude, latitude, rtol=rtol)
+    npt.assert_allclose(radius, sphere.radius + height, rtol=rtol)
+
+
+def test_ellipsoidal_properties(sphere):
+    """
+    Check inherited properties from Ellipsoid
+    """
+    npt.assert_allclose(sphere.semiminor_axis, sphere.radius)
+    npt.assert_allclose(sphere.linear_eccentricity, 0)
+    npt.assert_allclose(sphere.first_eccentricity, 0)
+    npt.assert_allclose(sphere.second_eccentricity, 0)
+    npt.assert_allclose(sphere.mean_radius, sphere.radius)
+    npt.assert_allclose(
+        sphere.emm,
+        sphere.angular_velocity ** 2
+        * sphere.radius ** 3
+        / sphere.geocentric_grav_const,
+    )
+
+
+def test_prime_vertical_radius(sphere):
+    """
+    Check prime vertical radius
+    """
+    latitudes = np.linspace(-90, 90, 18)
+    npt.assert_allclose(
+        sphere.prime_vertical_radius(np.sin(np.radians(latitudes))), sphere.radius
+    )
+
+
+def test_geocentric_radius(sphere):
+    """
+    Check geocentric radius
+    """
+    latitudes = np.linspace(-90, 90, 18)
+    for geodetic in (True, False):
+        npt.assert_allclose(
+            sphere.geocentric_radius(latitudes, geodetic=geodetic), sphere.radius
+        )
+
+
+def test_gravity_on_equator_and_poles(sphere):
+    """
+    Check gravity on equator and poles
+    """
+    expected_value = sphere.geocentric_grav_const / sphere.radius ** 2
+    npt.assert_allclose(sphere.gravity_pole, expected_value)
+    npt.assert_allclose(sphere.gravity_equator, expected_value)
+
+
+def test_normal_gravity_no_rotation():
+    """
+    Check normal gravity without rotation
+    """
+    gm_constant = 3
+    radius = 1
+    sphere = Sphere(
+        name="sphere",
+        radius=radius,
+        geocentric_grav_const=gm_constant,
+        angular_velocity=0,
+    )
+    # Create a set of points a different latitudes and same height
+    for height in [1, 2, 3, 4]:
+        latitudes = np.linspace(-90, 90, 19)
+        heights = height * np.ones_like(latitudes)
+        # Check if normal gravity is equal on every point (rotational symmetry)
+        expected_gravity = 1e5 * gm_constant / (radius + height) ** 2
+        npt.assert_allclose(expected_gravity, sphere.normal_gravity(latitudes, heights))
+
+
+def test_normal_gravity_only_rotation():
+    """
+    Check normal gravity only with rotation (no gravitational attraction)
+    """
+    radius = 1
+    omega = 2
+    sphere = Sphere(
+        name="sphere", radius=radius, geocentric_grav_const=0, angular_velocity=omega
+    )
+    # Check normal gravity on the equator
+    for height in [1, 2, 3, 4]:
+        expected_value = -1e5 * (omega ** 2) * (radius + height)
+        npt.assert_allclose(
+            expected_value, sphere.normal_gravity(latitude=0, height=height),
+        )
+    # Check normal gravity on the poles (must be equal to zero)
+    for height in [1, 2, 3, 4]:
+        assert sphere.normal_gravity(latitude=90, height=height) < 1e-15
+        assert sphere.normal_gravity(latitude=-90, height=height) < 1e-15
+    # Check normal gravity at 45 degrees latitude
+    for height in [1, 2, 3, 4]:
+        expected_value = -1e5 * (omega ** 2) * (radius + height) * np.sqrt(2) / 2
+        npt.assert_allclose(
+            expected_value, sphere.normal_gravity(latitude=45, height=height),
+        )