Repository: fatiando/harmonica

Issue:
Airy Isostasy calculations
**Description of the desired feature**

We need a function that calculates the Moho depth from topography based on an Airy isostatic model. This will be useful for calculating isostatic gravity anomalies, for example.

The function only needs to know the topographic height values and crust, mantle, and water densities (optional):

```python
def isostasy_airy(topography, density_crust, density_mantle, density_water=None):
    ...
    return moho_depth
```

If water density is not given, negative topography will be ignored and the Moho depth for those values will be set to NaN.

The function can live in a new `isostasy.py` module.

I'm entirely happy with the function name. Any suggestions?

Comments:

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 431219c..9c8a0f1 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -15,6 +15,14 @@ Gravity Corrections
 
     normal_gravity
 
+Isostasy
+--------
+
+.. autosummary::
+    :toctree: generated/
+
+    isostasy_airy
+
 Reference Ellipsoids
 --------------------
 
diff --git a/examples/isostasy_airy.py b/examples/isostasy_airy.py
new file mode 100644
index 0000000..adfc88e
--- /dev/null
+++ b/examples/isostasy_airy.py
@@ -0,0 +1,65 @@
+"""
+Airy Isostasy
+=============
+
+Calculation of the compensated root/antiroot of the topographic structure assuming the
+Airy hypothesis.
+If you want to obtain the isostatic Moho, you need to assume a normal crust value.
+"""
+import matplotlib.pyplot as plt
+import cartopy.crs as ccrs
+import harmonica as hm
+
+# Load the elevation model
+data = hm.datasets.fetch_topography_earth()
+print(data)
+data_africa = data.sel(latitude=slice(-40, 30), longitude=slice(-20, 60))
+print(data_africa)
+# Root calculation considering the ocean
+root = hm.isostasy_airy(
+    data_africa.topography.values,
+    density_upper_crust=2670,
+    density_lower_crust=2800,
+    density_mantle=3300,
+    density_oceanic_crust=2900,
+    density_water=1000,
+)
+data_africa["root"] = (data_africa.dims, root)
+
+# Root calculation without considering the ocean
+root_without_ocean = hm.isostasy_airy(
+    data_africa.topography.values,
+    density_upper_crust=2670,
+    density_lower_crust=2800,
+    density_mantle=3300,
+)
+data_africa["root_without_ocean"] = (data_africa.dims, root_without_ocean)
+
+# To obtain the depth of the Moho is necessary to assume a normal crust value
+T = 35000
+data_africa["moho"] = -(T + data_africa.root)
+data_africa["moho_without_ocean"] = -(T + data_africa.root_without_ocean)
+
+# Make maps of both versions using an Albers Equal Area projection
+proj = ccrs.AlbersEqualArea(central_longitude=20, central_latitude=0)
+trans = ccrs.PlateCarree()
+
+# Setup some common arguments for the colorbar and pseudo-color plot
+cbar_kwargs = dict(pad=0, orientation="horizontal")
+pcolor_args = dict(cmap="viridis", add_colorbar=False, transform=ccrs.PlateCarree())
+
+# Draw the maps
+fig, axes = plt.subplots(1, 2, figsize=(13, 9), subplot_kw=dict(projection=proj))
+fig.suptitle("Isostatic Moho using ETOPO1 and Airy hypothesis")
+ax = axes[0]
+tmp = data_africa.moho.plot.pcolormesh(ax=ax, **pcolor_args)
+plt.colorbar(tmp, ax=ax, **cbar_kwargs).set_label("[meters]")
+ax.gridlines()
+ax.set_title("Moho depth")
+ax = axes[1]
+tmp = data_africa.moho_without_ocean.plot.pcolormesh(ax=ax, **pcolor_args)
+plt.colorbar(tmp, ax=ax, **cbar_kwargs).set_label("[meters]")
+ax.gridlines()
+ax.set_title("Moho depth without considering the ocean")
+plt.tight_layout()
+plt.show()
diff --git a/harmonica/__init__.py b/harmonica/__init__.py
index 5fa22ef..518442a 100644
--- a/harmonica/__init__.py
+++ b/harmonica/__init__.py
@@ -9,6 +9,7 @@ from .ellipsoid import (
     ReferenceEllipsoid,
 )
 from .io import load_icgem_gdf
+from .isostasy import isostasy_airy
 from .gravity_corrections import normal_gravity
 from .coordinates import geodetic_to_spherical
 
diff --git a/harmonica/isostasy.py b/harmonica/isostasy.py
new file mode 100644
index 0000000..e5ad408
--- /dev/null
+++ b/harmonica/isostasy.py
@@ -0,0 +1,73 @@
+"""
+Function to calculate the thickness of the roots and antiroots assuming the
+Airy isostatic hypothesis.
+"""
+import numpy as np
+
+
+def isostasy_airy(
+    topography,
+    density_upper_crust,
+    density_lower_crust,
+    density_mantle,
+    density_oceanic_crust=None,
+    density_water=None,
+):
+    r"""
+    Computes the thickness of the roots/antiroots using Airy's hypothesis.
+
+    In Airy's hypothesis of isotasy, the mountain range can be thought of as a
+    block of lithosphere (crust) floating in the asthenosphere. Mountains have
+    roots (:math:`r`), while ocean basins have antiroots (:math:`ar`)
+    [Hofmann-WellenhofMoritz2006]_ .
+    If :math:`T` is the normal thickness of the Earh's crust, :math:`T + r` and
+    :math:`T + ar` are the isostatic Moho at the cotinental and oceanic
+    points respectively.
+
+    On continental points:
+
+    .. math ::
+        r = \frac{\rho_{uc}}{\rho_m - \rho_{lc}} h
+
+    On oceanic points:
+
+    .. math ::
+        ar = \frac{\rho_{oc} - \rho_w}{\rho_m - \rho_{oc}} h
+
+    where :math:`h` is the topography/bathymetry, :math:`\rho_m` is the
+    density of the mantle, :math:`\rho_w` is the density of the water,
+    :math:`\rho_{oc}` is the density of the oceanic crust, :math:`\rho_{uc}` is
+    the density of the upper crust and :math:`\rho_{lc}` is the density of the
+    lower crust.
+
+    Parameters
+    ----------
+    density_mantle : float
+        Mantle density in :math:`kg/m^3`.
+    density_upper_crust : float
+        Density of the upper crust in :math:`kg/m^3`.
+    density_lower_crust : float
+        Density of the lower crust in :math:`kg/m^3`.
+    density_oceanic_crust : float
+        Density of the oceanic crust in :math:`kg/m^3`.
+    density_water : float
+        Water density in :math:`kg/m^3`.
+    topography : array
+        Topography height and bathymetry depth in meters.
+
+    Returns
+    -------
+    root : array
+         Thickness of the roots and antiroot in meters.
+    """
+    root = topography.astype(np.float64)
+    root[topography >= 0] *= density_upper_crust / (
+        density_mantle - density_lower_crust
+    )
+    if density_water is None or density_oceanic_crust is None:
+        root[topography < 0] = np.nan
+    else:
+        root[topography < 0] *= (density_oceanic_crust - density_water) / (
+            density_mantle - density_oceanic_crust
+        )
+    return root


Test patch:
diff --git a/harmonica/tests/test_isostasy.py b/harmonica/tests/test_isostasy.py
new file mode 100644
index 0000000..6b4e8c8
--- /dev/null
+++ b/harmonica/tests/test_isostasy.py
@@ -0,0 +1,66 @@
+"""
+Testing isostasy calculation
+"""
+import numpy as np
+import numpy.testing as npt
+
+from ..isostasy import isostasy_airy
+
+
+def test_zero_topography():
+    "Test zero root for zero topography"
+    topography = np.array([0], dtype=np.float64)
+    density_upper_crust = 1
+    density_lower_crust = 2
+    density_mantle = 4
+    density_oceanic_crust = 3
+    density_water = 1
+    npt.assert_equal(
+        isostasy_airy(
+            topography, density_upper_crust, density_lower_crust, density_mantle
+        ),
+        topography,
+    )
+    npt.assert_equal(
+        isostasy_airy(
+            topography,
+            density_upper_crust,
+            density_lower_crust,
+            density_mantle,
+            density_oceanic_crust=density_oceanic_crust,
+            density_water=density_water,
+        ),
+        topography,
+    )
+
+
+def test_no_zero_topography():
+    "Test no zero root for no zero topography"
+    topography = np.array([-2, -1, 0, 1, 2, 3], dtype=np.float64)
+    root = topography.copy()
+    root[:2] *= 2
+    root[2:] *= 0.5
+    root_no_oceanic = root.copy()
+    root_no_oceanic[:2] = np.nan
+    density_upper_crust = 1
+    density_lower_crust = 2
+    density_mantle = 4
+    density_oceanic_crust = 3
+    density_water = 1
+    npt.assert_equal(
+        isostasy_airy(
+            topography, density_upper_crust, density_lower_crust, density_mantle
+        ),
+        root_no_oceanic,
+    )
+    npt.assert_equal(
+        isostasy_airy(
+            topography,
+            density_upper_crust,
+            density_lower_crust,
+            density_mantle,
+            density_oceanic_crust=density_oceanic_crust,
+            density_water=density_water,
+        ),
+        root,
+    )