Skip to content

Commit db072a1

Browse files
authored
Solve numerical instabilities of almost spheroids (#611)
Solve the numerical instabilities in gravity and magnetic fields due to prolate, oblate or triaxial ellipsoids that are close enough to a sphere. Fallback to sphere analytic solutions when the ellipsoid's semiaxes lengths are close enough within a relative tolerance. **Relevant issues/PRs:** Part of #594
1 parent 33786b2 commit db072a1

File tree

4 files changed

+158
-6
lines changed

4 files changed

+158
-6
lines changed

harmonica/_forward/ellipsoids/gravity.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717

1818
from ...errors import NoPhysicalPropertyWarning
1919
from ...typing import Coordinates, Ellipsoid
20-
from .utils import calculate_lambda, get_elliptical_integrals, is_internal
20+
from .utils import (
21+
calculate_lambda,
22+
get_elliptical_integrals,
23+
is_almost_a_sphere,
24+
is_internal,
25+
)
2126

2227

2328
def ellipsoid_gravity(
@@ -152,7 +157,7 @@ def _compute_gravity_ellipsoid(
152157
# Mask internal points
153158
internal = is_internal(x, y, z, a, b, c)
154159

155-
if a == b == c:
160+
if is_almost_a_sphere(a, b, c):
156161
# Fallback to sphere equations which are simpler
157162
factor = -4 / 3 * np.pi * a**3 * GRAVITATIONAL_CONST * density
158163
gx, gy, gz = tuple(np.zeros_like(x) for _ in range(3))

harmonica/_forward/ellipsoids/magnetic.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
calculate_lambda,
2626
get_derivatives_of_elliptical_integrals,
2727
get_elliptical_integrals,
28+
is_almost_a_sphere,
2829
is_internal,
2930
)
3031

@@ -359,14 +360,14 @@ def get_demagnetization_tensor_internal(a: float, b: float, c: float):
359360
360361
where :math:`\mathbf{M}` is the magnetization vector of the ellipsoid.
361362
"""
362-
if a > b > c:
363+
if is_almost_a_sphere(a, b, c):
364+
n_diagonal = 1 / 3 * np.ones(3)
365+
elif a > b > c:
363366
n_diagonal = _demag_tensor_triaxial_internal(a, b, c)
364367
elif a > b and b == c:
365368
n_diagonal = _demag_tensor_prolate_internal(a, b)
366369
elif a < b and b == c:
367370
n_diagonal = _demag_tensor_oblate_internal(a, b)
368-
elif a == b == c:
369-
n_diagonal = 1 / 3 * np.ones(3)
370371
else:
371372
msg = "Could not determine ellipsoid type for values given."
372373
raise ValueError(msg)
@@ -532,7 +533,7 @@ def get_demagnetization_tensor_external(
532533

533534
coords = (x, y, z)
534535

535-
if a == b == c:
536+
if is_almost_a_sphere(a, b, c):
536537
r = np.sqrt(x**2 + y**2 + z**2)
537538
r5, r3 = r**5, r**3
538539
factor = -(a**3 / 3)

harmonica/_forward/ellipsoids/utils.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
import numpy.typing as npt
99
from scipy.special import ellipeinc, ellipkinc
1010

11+
# Relative tolerance for two ellipsoid semiaxes to be considered almost equal.
12+
# E.g.: two semiaxes a and b are considered almost equal if:
13+
# | a - b | < max(a, b) * SEMIAXES_RTOL
14+
SEMIAXES_RTOL = 1e-5
15+
1116

1217
def is_internal(x, y, z, a, b, c):
1318
"""
@@ -27,6 +32,41 @@ def is_internal(x, y, z, a, b, c):
2732
return ((x**2) / (a**2) + (y**2) / (b**2) + (z**2) / (c**2)) < 1
2833

2934

35+
def is_almost_a_sphere(a: float, b: float, c: float) -> bool:
36+
"""
37+
Check if a given ellipsoid approximates a sphere.
38+
39+
Returns True if ellipsoid's semiaxes lenghts are close enough to each other to be
40+
approximated by a sphere.
41+
42+
Parameters
43+
----------
44+
a, b, c: float
45+
Ellipsoid's semiaxes lenghts.
46+
47+
Returns
48+
-------
49+
bool
50+
"""
51+
# Exactly a sphere
52+
if a == b == c:
53+
return True
54+
55+
# Prolate or oblate that is almost a sphere
56+
if b == c and np.abs(a - b) < SEMIAXES_RTOL * max(a, b):
57+
return True
58+
59+
# Triaxial that is almost a sphere
60+
if ( # noqa: SIM103
61+
a != b != c
62+
and np.abs(a - b) < SEMIAXES_RTOL * max(a, b)
63+
and np.abs(b - c) < SEMIAXES_RTOL * max(b, c)
64+
):
65+
return True
66+
67+
return False
68+
69+
3070
def calculate_lambda(x, y, z, a, b, c):
3171
"""
3272
Get the lambda ellipsoidal coordinate for a given ellipsoid and observation points.

harmonica/tests/ellipsoids/test_gravity.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,3 +663,109 @@ def test_warning(self, ellipsoid_class, ellipsoid_args):
663663
# Check the gravity acceleration components are zero
664664
for g_component in (gx, gy, gz):
665665
assert g_component == 0.0
666+
667+
668+
class TestNumericalInstability:
669+
"""
670+
Test fix for numerical instabilities when semiaxes lengths are very similar.
671+
672+
When the semiaxes are almost equal to each other (in the order of machine
673+
precision), analytic solutions for prolate, oblate, and triaxial ellipsoids might
674+
fall under singularities, triggering numerical instabilities.
675+
676+
Given two semiaxes a and b, define a ratio as:
677+
678+
.. code::
679+
680+
ratio = | a - b | / max(a, b)
681+
682+
These test functions will build the ellipsoids using very small values of this
683+
ratio.
684+
"""
685+
686+
# Properties of the sphere
687+
radius = 50.0
688+
center = (0, 0, 0)
689+
density = 200.0
690+
691+
def get_coordinates(self, azimuth=45, polar=45):
692+
"""
693+
Generate coordinates of observation points along a certain direction.
694+
"""
695+
r = np.linspace(self.radius, 5 * self.radius, 501)
696+
azimuth, polar = np.rad2deg(azimuth), np.rad2deg(polar)
697+
easting = r * np.cos(azimuth) * np.cos(polar)
698+
northing = r * np.sin(azimuth) * np.cos(polar)
699+
upward = r * np.sin(polar)
700+
return (easting, northing, upward)
701+
702+
@pytest.fixture
703+
def sphere(self):
704+
return Sphere(self.radius, center=self.center, density=self.density)
705+
706+
def test_gravity_prolate(self, sphere):
707+
"""
708+
Test gravity field of prolate ellipsoid that is almost a sphere.
709+
"""
710+
coordinates = self.get_coordinates()
711+
712+
# Semiaxes ratio. Sufficiently small to trigger the numerical instabilities
713+
ratio = 1e-9
714+
715+
a = self.radius
716+
b = (1 - ratio) * a
717+
ellipsoid = ProlateEllipsoid(
718+
a, b, yaw=0, pitch=0, center=self.center, density=self.density
719+
)
720+
ge_sphere, gn_sphere, gu_sphere = ellipsoid_gravity(coordinates, sphere)
721+
ge_ell, gn_ell, gu_ell = ellipsoid_gravity(coordinates, ellipsoid)
722+
723+
rtol, atol = 1e-7, 1e-8
724+
np.testing.assert_allclose(ge_ell, ge_sphere, rtol=rtol, atol=atol)
725+
np.testing.assert_allclose(gn_ell, gn_sphere, rtol=rtol, atol=atol)
726+
np.testing.assert_allclose(gu_ell, gu_sphere, rtol=rtol, atol=atol)
727+
728+
def test_gravity_oblate(self, sphere):
729+
"""
730+
Test gravity field of oblate ellipsoid that is almost a sphere.
731+
"""
732+
coordinates = self.get_coordinates()
733+
734+
# Semiaxes ratio. Sufficiently small to trigger the numerical instabilities
735+
ratio = 1e-14
736+
737+
a = self.radius
738+
b = (1 + ratio) * a
739+
ellipsoid = OblateEllipsoid(
740+
a, b, yaw=0, pitch=0, center=self.center, density=self.density
741+
)
742+
ge_sphere, gn_sphere, gu_sphere = ellipsoid_gravity(coordinates, sphere)
743+
ge_ell, gn_ell, gu_ell = ellipsoid_gravity(coordinates, ellipsoid)
744+
745+
rtol, atol = 1e-7, 1e-8
746+
np.testing.assert_allclose(ge_ell, ge_sphere, rtol=rtol, atol=atol)
747+
np.testing.assert_allclose(gn_ell, gn_sphere, rtol=rtol, atol=atol)
748+
np.testing.assert_allclose(gu_ell, gu_sphere, rtol=rtol, atol=atol)
749+
750+
def test_gravity_triaxial(self, sphere):
751+
"""
752+
Test gravity field of triaxial ellipsoid that is almost a sphere.
753+
"""
754+
coordinates = self.get_coordinates()
755+
756+
# Semiaxes ratio. Sufficiently small to trigger the numerical instabilities
757+
ratio = 1e-14
758+
759+
a = self.radius
760+
b = (1 - ratio) * a
761+
c = (1 - 2 * ratio) * a
762+
ellipsoid = TriaxialEllipsoid(
763+
a, b, c, yaw=0, pitch=0, roll=0, center=self.center, density=self.density
764+
)
765+
ge_sphere, gn_sphere, gu_sphere = ellipsoid_gravity(coordinates, sphere)
766+
ge_ell, gn_ell, gu_ell = ellipsoid_gravity(coordinates, ellipsoid)
767+
768+
rtol, atol = 1e-7, 1e-8
769+
np.testing.assert_allclose(ge_ell, ge_sphere, rtol=rtol, atol=atol)
770+
np.testing.assert_allclose(gn_ell, gn_sphere, rtol=rtol, atol=atol)
771+
np.testing.assert_allclose(gu_ell, gu_sphere, rtol=rtol, atol=atol)

0 commit comments

Comments
 (0)