Repository: fatiando/harmonica

Issue:
Bouguer correction
**Description of the desired feature**

Our gravity processing will need the Bouguer correction. The correction aims to remove the effect of masses above the reference ellipsoid (topography) or the mass difference below the ellipsoid (oceans). The function should take two densities, one to apply to the masses at height > 0 (above) and another for masses at height < 0 (below). No contrasts should be calculate (i.e., to remove the effect of the oceans the user should give `density_below=1040 - 2670`). It's OK to provide defaults for above and below densities.

This should be a straight forward function:

```
def bouguer_plate(height, density_above=2670, density_below=-1630):
    ...
    return results
```

Notice that this function doesn't perform the correction itself, it calculates the effect of the Bouguer plates that has to be subtracted from the data. This makes it more inline with calculating a topographic effect using prisms or tesseroids.

Comments:

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 40ac957..c28a961 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -14,6 +14,7 @@ Gravity Corrections
     :toctree: generated/
 
     normal_gravity
+    bouguer_correction
 
 Isostasy
 --------
diff --git a/examples/gravity_disturbance.py b/examples/gravity_disturbance.py
index 69442ae..b34e32b 100644
--- a/examples/gravity_disturbance.py
+++ b/examples/gravity_disturbance.py
@@ -18,13 +18,13 @@ print(data)
 
 # Calculate normal gravity and the disturbance
 gamma = hm.normal_gravity(data.latitude, data.height_over_ell)
-data["disturbance"] = data.gravity - gamma
+disturbance = data.gravity - gamma
 
 # Make a plot of data using Cartopy
 plt.figure(figsize=(10, 10))
-ax = plt.axes(projection=ccrs.Orthographic(central_longitude=100))
-pc = data.disturbance.plot.pcolormesh(
-    ax=ax, transform=ccrs.PlateCarree(), add_colorbar=False
+ax = plt.axes(projection=ccrs.Orthographic(central_longitude=160))
+pc = disturbance.plot.pcolormesh(
+    ax=ax, transform=ccrs.PlateCarree(), add_colorbar=False, cmap="seismic"
 )
 plt.colorbar(
     pc, label="mGal", orientation="horizontal", aspect=50, pad=0.01, shrink=0.5
diff --git a/examples/gravity_disturbance_topofree.py b/examples/gravity_disturbance_topofree.py
new file mode 100644
index 0000000..a6b3baf
--- /dev/null
+++ b/examples/gravity_disturbance_topofree.py
@@ -0,0 +1,59 @@
+"""
+Topography-free (Bouguer) Gravity Disturbances
+==============================================
+
+The gravity disturbance is the observed gravity minus the normal gravity
+(:func:`harmonica.normal_gravity`). The signal that is left is assumed to be due to the
+differences between the actual Earth and the reference ellipsoid. Big components in
+this signal are the effects of topographic masses outside of the ellipsoid and
+residual effects of the oceans (we removed ellipsoid crust where there was actually
+ocean water). These two components are relatively well known and can be removed from the
+gravity disturbance. The simplest way of calculating the effects of topography and
+oceans is through the Bouguer plate approximation.
+
+We'll use :func:`harmonica.bouguer_correction` to calculate a topography-free gravity
+disturbance for Earth using our sample gravity and topography data. One thing to note is
+that the ETOPO1 topography is referenced to the geoid, not the ellipsoid. Since we want
+to remove the masses between the surface of the Earth and ellipsoid, we need to add the
+geoid height to the topography before Bouguer correction.
+"""
+import matplotlib.pyplot as plt
+import cartopy.crs as ccrs
+import xarray as xr
+import harmonica as hm
+
+# Load the global gravity, topography, and geoid grids
+data = xr.merge(
+    [
+        hm.datasets.fetch_gravity_earth(),
+        hm.datasets.fetch_geoid_earth(),
+        hm.datasets.fetch_topography_earth(),
+    ]
+)
+print(data)
+
+# Calculate normal gravity and the disturbance
+gamma = hm.normal_gravity(data.latitude, data.height_over_ell)
+disturbance = data.gravity - gamma
+
+# Reference the topography to the ellipsoid
+topography_ell = data.topography + data.geoid
+
+# Calculate the Bouguer planar correction and the topography-free disturbance. Use the
+# default densities for the crust and ocean water.
+bouguer = hm.bouguer_correction(topography_ell)
+disturbance_topofree = disturbance - bouguer
+
+# Make a plot of data using Cartopy
+plt.figure(figsize=(10, 10))
+ax = plt.axes(projection=ccrs.Orthographic(central_longitude=-60))
+pc = disturbance_topofree.plot.pcolormesh(
+    ax=ax, transform=ccrs.PlateCarree(), add_colorbar=False
+)
+plt.colorbar(
+    pc, label="mGal", orientation="horizontal", aspect=50, pad=0.01, shrink=0.5
+)
+ax.set_title("Topography-free (Bouguer) gravity of disturbance of the Earth")
+ax.coastlines()
+plt.tight_layout()
+plt.show()
diff --git a/harmonica/__init__.py b/harmonica/__init__.py
index 518442a..132394a 100644
--- a/harmonica/__init__.py
+++ b/harmonica/__init__.py
@@ -10,7 +10,7 @@ from .ellipsoid import (
 )
 from .io import load_icgem_gdf
 from .isostasy import isostasy_airy
-from .gravity_corrections import normal_gravity
+from .gravity_corrections import normal_gravity, bouguer_correction
 from .coordinates import geodetic_to_spherical
 
 
diff --git a/harmonica/constants.py b/harmonica/constants.py
new file mode 100644
index 0000000..f82e187
--- /dev/null
+++ b/harmonica/constants.py
@@ -0,0 +1,6 @@
+"""
+Fundamental constants used throughout the library.
+"""
+
+#: The gravitational constant in SI units :math:`m^3 kg^{-1} s^{-1}`
+GRAVITATIONAL_CONST = 0.00000000006673
diff --git a/harmonica/gravity_corrections.py b/harmonica/gravity_corrections.py
index 8bfc5b7..d176b87 100644
--- a/harmonica/gravity_corrections.py
+++ b/harmonica/gravity_corrections.py
@@ -4,6 +4,7 @@ Gravity corrections like Normal Gravity and Bouguer corrections.
 import numpy as np
 
 from .ellipsoid import get_ellipsoid
+from .constants import GRAVITATIONAL_CONST
 
 
 def normal_gravity(latitude, height):  # pylint: disable=too-many-locals
@@ -73,3 +74,66 @@ def normal_gravity(latitude, height):  # pylint: disable=too-many-locals
 
     # Convert gamma from SI to mGal
     return gamma * 1e5
+
+
+def bouguer_correction(topography, density_crust=2670, density_water=1040):
+    r"""
+    Gravitational effect of topography using a planar Bouguer plate approximation.
+
+    Used to remove the gravitational attraction of topography above the ellipsoid from
+    the gravity disturbance. The infinite plate approximation is adequate for regions
+    with flat topography and observation points close to the surface of the Earth.
+
+    This function calculates the classic Bouguer correction:
+
+    .. math::
+
+        g_{bg} = 2 \pi G \rho h
+
+    in which :math:`G` is the gravitational constant and :math:`g_{bg}` is the
+    gravitational effect of an infinite plate of thickness :math:`h` and density
+    :math:`\rho`.
+
+    In the oceans, subtracting normal gravity from the observed gravity results in over
+    correction because the normal Earth has crust where there was water in the real
+    Earth. The Bouguer correction for the oceans aims to remove this residual effect due
+    to the over correction:
+
+    .. math::
+
+        g_{bg} = 2 \pi G (\rho_w - \rho_c) |h|
+
+    in which :math:`\rho_w` is the density of water and :math:`\rho_c` is the density of
+    the crust of the normal Earth. We need to take the absolute value of the bathymetry
+    :math:`h` because it is negative and the equation requires a thickness value
+    (positive).
+
+    Parameters
+    ----------
+    topography : array or :class:`xarray.DataArray`
+        Topography height and bathymetry depth in meters. Should be referenced to the
+        ellipsoid (ie, geometric heights).
+    density_crust : float
+        Density of the crust in :math:`kg/m^3`. Used as the density of topography on
+        land and the density of the normal Earth's crust in the oceans.
+    density_water : float
+        Density of water in :math:`kg/m^3`.
+
+    Returns
+    -------
+    grav_bouguer : array or :class:`xarray.DataArray`
+         The gravitational effect of topography and residual bathymetry in mGal.
+
+    """
+    # Need to cast to array to make sure numpy indexing works as expected for 1D
+    # DataArray topography
+    oceans = np.array(topography < 0)
+    continent = np.logical_not(oceans)
+    density = np.full(topography.shape, np.nan, dtype="float")
+    density[continent] = density_crust
+    # The minus sign is used to negate the bathymetry (which is negative and the
+    # equation calls for "thickness", not height). This is more practical than taking
+    # the absolute value of the topography.
+    density[oceans] = -1 * (density_water - density_crust)
+    bouguer = 1e5 * 2 * np.pi * GRAVITATIONAL_CONST * density * topography
+    return bouguer


Test patch:
diff --git a/harmonica/tests/test_normal_gravity.py b/harmonica/tests/test_gravity_corrections.py
similarity index 53%
rename from harmonica/tests/test_normal_gravity.py
rename to harmonica/tests/test_gravity_corrections.py
index f9b739e..eb4f0f9 100644
--- a/harmonica/tests/test_normal_gravity.py
+++ b/harmonica/tests/test_gravity_corrections.py
@@ -1,11 +1,13 @@
 """
-Testing normal gravity calculation.
+Test the gravity correction functions (normal gravity, Bouguer, etc).
 """
+import xarray as xr
 import numpy as np
 import numpy.testing as npt
 
-from ..gravity_corrections import normal_gravity
+from ..gravity_corrections import normal_gravity, bouguer_correction
 from ..ellipsoid import KNOWN_ELLIPSOIDS, set_ellipsoid, get_ellipsoid
+from ..constants import GRAVITATIONAL_CONST
 
 
 def test_normal_gravity():
@@ -41,7 +43,7 @@ def test_normal_gravity_arrays():
             npt.assert_allclose(gammas, normal_gravity(latitudes, heights), rtol=rtol)
 
 
-def test_no_zero_height():
+def test_normal_gravity_non_zero_height():
     "Normal gravity above and below the ellipsoid."
     for ellipsoid_name in KNOWN_ELLIPSOIDS:
         with set_ellipsoid(ellipsoid_name):
@@ -54,3 +56,41 @@ def test_no_zero_height():
             assert gamma_pole < normal_gravity(90, -1000)
             assert gamma_pole < normal_gravity(-90, -1000)
             assert gamma_eq < normal_gravity(0, -1000)
+
+
+def test_bouguer_correction():
+    "Test the Bouguer correction using easy to calculate values"
+    topography = np.linspace(-10, 20, 100)
+    # With these densities, the correction should be equal to the topography
+    rhoc = 1 / (1e5 * 2 * np.pi * GRAVITATIONAL_CONST)
+    rhow = 0
+    bouguer = bouguer_correction(topography, density_crust=rhoc, density_water=rhow)
+    assert bouguer.shape == topography.shape
+    npt.assert_allclose(bouguer, topography)
+    # Check that the shape is preserved for 2D arrays
+    bouguer = bouguer_correction(
+        topography.reshape(10, 10), density_crust=rhoc, density_water=rhow
+    )
+    assert bouguer.shape == (10, 10)
+    npt.assert_allclose(bouguer, topography.reshape(10, 10))
+
+
+def test_bouguer_correction_zero_topo():
+    "Bouguer correction for zero topography should be zero"
+    npt.assert_allclose(bouguer_correction(np.zeros(20)), 0)
+
+
+def test_bouguer_correction_xarray():
+    "Should work the same for an xarray input"
+    topography = xr.DataArray(
+        np.linspace(-10, 20, 100).reshape((10, 10)),
+        coords=(np.arange(10), np.arange(10)),
+        dims=("x", "y"),
+    )
+    # With these densities, the correction should be equal to the topography
+    rhoc = 1 / (1e5 * 2 * np.pi * GRAVITATIONAL_CONST)
+    rhow = 0
+    bouguer = bouguer_correction(topography, density_crust=rhoc, density_water=rhow)
+    assert isinstance(bouguer, xr.DataArray)
+    assert bouguer.shape == topography.shape
+    npt.assert_allclose(bouguer.values, topography.values)