Processing and gridding gravity data with Fatiando a Terra
Contents
Processing and gridding gravity data with Fatiando a Terra#
Authors: Santiago Soler, Andrea Balza Morales, Agustina Pesce, Leonardo Uieda
In this tutorial, we will walk through the steps of transforming observed absolute gravity measurements into a grid of residual gravity disturbances at a constant height. On our way there, we will showcase some of the core utilities of many of our open-source libraries:
Calculate the gravity disturbance using Boule
Interpolate data and apply map projections to grids using Verde
Perform topographic correction and equivalent-source interpolation using Harmonica
Video#
You can watch a video of our tutorial, “Processing gravity data with Harmonica,” streamed live on Apr 22, 2021.
Note
Remember that the Youtube tutorials may differ slightly from the ones presented on this website, which contains the more recent version of our tutorials
Study area#
The Bushveld Igneous Complex is located in South Africa and is the largest known layered igneous intrusion. It has been tilted and eroded forming the outcrops around what appears to be the edge of a great geological basin: the Transvaal Basin. It is approximately 2 billion years old and is divided into four different limbs: the northern, southern, eastern, and western limbs. The Bushveld Complex comprises the Rustenburg Layered suite, the Lebowa Granites and the Rooiberg Felsics, that are overlain by the Karoo sediments. See [Webb et al., 2004] for an overview and previous interpretations of the Bushveld in depth.
Import packages#
Start by loading everything we need.
# The standard Python science stack
import numpy as np
import pandas as pd
import xarray as xr
# For projections (wrapped for Proj)
import pyproj
# Plotting maps using GMT
import pygmt
# The Fatiando stack
import ensaio
import pooch
import verde as vd
import boule as bl
import harmonica as hm
Fetch the data#
The three data sets required are:
Gravity
Topography
Global geoid model
The global geoid model (co-registered with our topography grid) will be use to convert the topography from orthometric (referenced to “sea level” or the geoid) to geometric (referenced to the WGS84 ellipsoid) heights. This conversion is required to calculate gravity disturbances instead of anomalies (more on that below).
For downloading the data we can use two options: using Ensaio or using directly Pooch.
Downloading data using Ensaio#
Ensaio (Portuguese for “rehearsal”) is a Python package for downloading open-access sample datasets for Geoscience, specifically from the Fatiando a Terra Datasets collection. To see available datasets with a short description of each, you can visit the documentation. Ensaio provides functions for downloading datasets from to your computer. These functions don’t attempt to do any loading of the data into memory and only return the path of the downloaded file on your computer.
path_gravity = ensaio.fetch_southern_africa_gravity(version=1)
path_topography = ensaio.fetch_earth_topography(version=1)
path_geoid = ensaio.fetch_earth_geoid(version=1)
print(path_gravity)
print(path_topography)
print(path_geoid)
Downloading file 'southern-africa-gravity.csv.xz' from 'doi:10.5281/zenodo.5882430/southern-africa-gravity.csv.xz' to '/home/runner/.cache/ensaio/v1'.
Downloading file 'earth-topography-10arcmin.nc' from 'doi:10.5281/zenodo.5882203/earth-topography-10arcmin.nc' to '/home/runner/.cache/ensaio/v1'.
Downloading file 'earth-geoid-10arcmin.nc' from 'doi:10.5281/zenodo.5882205/earth-geoid-10arcmin.nc' to '/home/runner/.cache/ensaio/v1'.
/home/runner/.cache/ensaio/v1/southern-africa-gravity.csv.xz
/home/runner/.cache/ensaio/v1/earth-topography-10arcmin.nc
/home/runner/.cache/ensaio/v1/earth-geoid-10arcmin.nc
Ensaio uses Pooch under the hood to manage downloading and caching the data on your computer. Therefore, the other option is to use Pooch directly.
Downloading data using Pooch#
Pooch is a more general tool to download data files from anywhere on the web and store them locally for reuse. It is used to manage sample data downloads not only by our own tools but also other popular Scientific Python libraries like scikit-image, xarray among others. To download the data from our curated sample datasets stored on Zenodo we need the Digital Object Identifier (DOI) of the Zenodo data archive:
doi = "10.5281/zenodo.5167357"
path_gravity = pooch.retrieve(
f"doi:{doi}/southern-africa-gravity.csv.xz",
known_hash="md5:1dee324a14e647855366d6eb01a1ef35",
)
path_topography = pooch.retrieve(
f"doi:{doi}/earth-topography-10arcmin.nc",
known_hash="md5:c43b61322e03669c4313ba3d9a58028d",
)
path_geoid = pooch.retrieve(
f"doi:{doi}/earth-geoid-10arcmin.nc",
known_hash="md5:39b97344e704eb68fa381df2eb47da0f",
)
print(path_gravity)
print(path_topography)
print(path_geoid)
Whether you use Ensaio or Pooch directly, you will have the same datasets, the only difference is in the paths to the location of the files.
Load the data#
Now that the data files have been downloaded and cached to our computer, we can use standard Python libraries to load the data.
Use Pandas to read the gravity data from the compressed CSV file.
data = pd.read_csv(path_gravity)
data
longitude | latitude | height_sea_level_m | gravity_mgal | |
---|---|---|---|---|
0 | 18.34444 | -34.12971 | 32.2 | 979656.12 |
1 | 18.36028 | -34.08833 | 592.5 | 979508.21 |
2 | 18.37418 | -34.19583 | 18.4 | 979666.46 |
3 | 18.40388 | -34.23972 | 25.0 | 979671.03 |
4 | 18.41112 | -34.16444 | 228.7 | 979616.11 |
... | ... | ... | ... | ... |
14354 | 21.22500 | -17.95833 | 1053.1 | 978182.09 |
14355 | 21.27500 | -17.98333 | 1033.3 | 978183.09 |
14356 | 21.70833 | -17.99166 | 1041.8 | 978182.69 |
14357 | 21.85000 | -17.95833 | 1033.3 | 978193.18 |
14358 | 21.98333 | -17.94166 | 1022.6 | 978211.38 |
14359 rows × 4 columns
And use xarray to read the topography and geoid grids from the netCDF files.
topography = xr.load_dataarray(path_topography)
topography
<xarray.DataArray 'topography' (latitude: 1081, longitude: 2161)> array([[ 2742., 2742., 2742., ..., 2742., 2742., 2742.], [ 2768., 2768., 2768., ..., 2768., 2768., 2768.], [ 2822., 2822., 2821., ..., 2822., 2822., 2822.], ..., [-4243., -4244., -4245., ..., -4242., -4243., -4243.], [-4156., -4157., -4157., ..., -4156., -4156., -4156.], [-4237., -4237., -4237., ..., -4237., -4237., -4237.]], dtype=float32) Coordinates: * longitude (longitude) float64 -180.0 -179.8 -179.7 ... 179.7 179.8 180.0 * latitude (latitude) float64 -90.0 -89.83 -89.67 -89.5 ... 89.67 89.83 90.0 Attributes: Conventions: CF-1.8 title: Topographic and bathymetric height of ETOPO1 (ice surfac... crs: WGS84 source: Generated from a spherical harmonic model by the ICGEM C... license: public domain references: https://doi.org/10.7289/V5C8276M long_name: topographic height above mean sea level standard_name: height_above_mean_sea_level description: height topography/bathymetry referenced to mean sea level units: m actual_range: [-10023. 6082.] icgem_metadata: generating_institute: gfz-potsdam\ngenerating_date: 2021...
geoid = xr.load_dataarray(path_geoid)
geoid
<xarray.DataArray 'geoid' (latitude: 1081, longitude: 2161)> array([[-29.5 , -29.5 , -29.5 , ..., -29.5 , -29.5 , -29.5 ], [-29.5 , -29.5 , -29.5 , ..., -29.5 , -29.5 , -29.5 ], [-29.6 , -29.6 , -29.6 , ..., -29.6 , -29.6 , -29.6 ], ..., [ 14.7 , 14.7 , 14.7 , ..., 14.7 , 14.7 , 14.7 ], [ 15.2 , 15.2 , 15.2 , ..., 15.2 , 15.2 , 15.2 ], [ 15.400001, 15.400001, 15.400001, ..., 15.400001, 15.400001, 15.400001]], dtype=float32) Coordinates: * longitude (longitude) float64 -180.0 -179.8 -179.7 ... 179.7 179.8 180.0 * latitude (latitude) float64 -90.0 -89.83 -89.67 -89.5 ... 89.67 89.83 90.0 Attributes: Conventions: CF-1.8 title: Geoid height (EIGEN-6C4) with respect to WGS84 crs: WGS84 source: Generated from the EIGEN-6C4 model by the ICGEM Calculat... license: Creative Commons Attribution 4.0 International Licence references: https://doi.org/10.5880/icgem.2015.1 long_name: geoid height standard_name: geoid_height_above_reference_ellipsoid description: height of the geoid with respect to the WGS84 ellipsoid units: m actual_range: [-106.5 86. ] icgem_metadata: generating_institute: gfz-potsdam\ngenerating_date: 2021...
Crop the data to the Bushveld#
The grids are global and the gravity data covers all of Southern Africa. So before we start working we’ll need to crop the data to the immediate region around the Bushveld complex. For the topography and geoid grids, we’ll use a larger region to avoid edge effects in the topographic correction.
# West, East, South, North (degrees)
region = (25, 32, -27, -23)
# Pad the region for slicing the grids
region_pad = vd.pad_region(region, pad=5)
To slice a pandas.DataFrame
, we can use Verde’s inside
function to get the index of all points falling inside the given region.
data = data[vd.inside((data.longitude, data.latitude), region)]
data
longitude | latitude | height_sea_level_m | gravity_mgal | |
---|---|---|---|---|
8648 | 25.01500 | -26.26334 | 1230.2 | 978681.38 |
8649 | 25.01932 | -26.38713 | 1297.0 | 978669.02 |
8650 | 25.02499 | -26.39667 | 1304.8 | 978669.28 |
8651 | 25.04500 | -26.07668 | 1165.2 | 978681.08 |
8652 | 25.07668 | -26.35001 | 1262.5 | 978665.19 |
... | ... | ... | ... | ... |
12974 | 31.51500 | -23.86333 | 300.5 | 978776.85 |
12975 | 31.52499 | -23.30000 | 280.7 | 978798.55 |
12976 | 31.54832 | -23.19333 | 245.7 | 978803.55 |
12977 | 31.57333 | -23.84833 | 226.8 | 978808.44 |
13557 | 31.37500 | -23.00000 | 285.6 | 978734.77 |
3877 rows × 4 columns
For the grids, we use the sel
method of the xarray.DataArray
objects directly (one of the many perks of using xarray for gridded data).
geoid = geoid.sel(longitude=slice(*region_pad[:2]), latitude=slice(*region_pad[2:]))
topography = topography.sel(longitude=slice(*region_pad[:2]), latitude=slice(*region_pad[2:]))
topography
<xarray.DataArray 'topography' (latitude: 85, longitude: 103)> array([[ 1013., 1236., 1335., ..., -3867., -4446., -4842.], [ 1206., 1270., 1227., ..., -3790., -4350., -4736.], [ 1245., 1197., 1104., ..., -3847., -4362., -4703.], ..., [ 1128., 1123., 1113., ..., -7., -32., -18.], [ 1115., 1107., 1098., ..., 17., -10., -21.], [ 1102., 1094., 1084., ..., 18., 9., -13.]], dtype=float32) Coordinates: * longitude (longitude) float64 20.0 20.17 20.33 20.5 ... 36.67 36.83 37.0 * latitude (latitude) float64 -32.0 -31.83 -31.67 ... -18.33 -18.17 -18.0 Attributes: Conventions: CF-1.8 title: Topographic and bathymetric height of ETOPO1 (ice surfac... crs: WGS84 source: Generated from a spherical harmonic model by the ICGEM C... license: public domain references: https://doi.org/10.7289/V5C8276M long_name: topographic height above mean sea level standard_name: height_above_mean_sea_level description: height topography/bathymetry referenced to mean sea level units: m actual_range: [-10023. 6082.] icgem_metadata: generating_institute: gfz-potsdam\ngenerating_date: 2021...
Plot the data#
Use PyGMT to plot the data so we can see what we have. PyGMT is particularly well suited for dealing with spatial data, maps, and projections.
fig = pygmt.Figure()
pygmt.makecpt(cmap="viridis", series=[data.gravity_mgal.min(), data.gravity_mgal.max()])
fig.plot(
x=data.longitude,
y=data.latitude,
color=data.gravity_mgal,
cmap=True,
style="c4p",
projection="M15c",
frame="afg",
)
fig.colorbar(frame='af+l"observed gravity [mGal]"')
fig.show()
Also plot the topography grid with the data points overlaid so we can get a sense for how much topographic variation there is and how much we padded the grids.
fig = pygmt.Figure()
fig.grdimage(topography, projection="M15c", cmap="etopo1", frame=True)
fig.colorbar(frame='af+l"topography [m]"')
fig.coast(shorelines="white", area_thresh=1e6)
fig.plot(
x=data.longitude,
y=data.latitude,
style="c0.04c",
color="black",
)
fig.show()
Note
This topography grid is not ideal for use with gravity processing. The resolution of the grid is much coarser than our gravity data, which will cause under-correction when we remove the effect of the topography. For this example, we made the trade-off between a less-than-ideal dataset but one that is of manageable size. In practice, always try to match the spatial resolution of the data and topography grid.
Project the grids and data#
Since our study area is relatively small, we can use a map projection to transform our geographic data into a Cartesian coordinate system. This simplifies things like interpolation and topographic correction, which are often much faster if performed in Cartesian rather than spherical or geodetic coordinates.
We’ll use pyproj to create a Mercator projection function with latitude of true scale centered on the data.
projection = pyproj.Proj(proj="merc", lat_ts=data.latitude.mean())
We can now use the projection
to transform our gravity data and include the new coordinates in the pandas.DataFrame
.
easting, northing = projection(data.longitude.values, data.latitude.values)
data = data.assign(easting_m=easting, northing_m=northing)
data
longitude | latitude | height_sea_level_m | gravity_mgal | easting_m | northing_m | |
---|---|---|---|---|---|---|
8648 | 25.01500 | -26.26334 | 1230.2 | 978681.38 | 2.526066e+06 | -2.733052e+06 |
8649 | 25.01932 | -26.38713 | 1297.0 | 978669.02 | 2.526503e+06 | -2.746924e+06 |
8650 | 25.02499 | -26.39667 | 1304.8 | 978669.28 | 2.527075e+06 | -2.747994e+06 |
8651 | 25.04500 | -26.07668 | 1165.2 | 978681.08 | 2.529096e+06 | -2.712163e+06 |
8652 | 25.07668 | -26.35001 | 1262.5 | 978665.19 | 2.532295e+06 | -2.742763e+06 |
... | ... | ... | ... | ... | ... | ... |
12974 | 31.51500 | -23.86333 | 300.5 | 978776.85 | 3.182450e+06 | -2.466945e+06 |
12975 | 31.52499 | -23.30000 | 280.7 | 978798.55 | 3.183459e+06 | -2.405224e+06 |
12976 | 31.54832 | -23.19333 | 245.7 | 978803.55 | 3.185814e+06 | -2.393567e+06 |
12977 | 31.57333 | -23.84833 | 226.8 | 978808.44 | 3.188340e+06 | -2.465298e+06 |
13557 | 31.37500 | -23.00000 | 285.6 | 978734.77 | 3.168312e+06 | -2.372463e+06 |
3877 rows × 6 columns
Projecting the grids is more complicated since it involves some interpolation to maintain evenly spaced grid nodes. Verde provides the project_grid
function that performs all of these calculations automatically for us. We’ll use nearest-neighbor interpolation here since it’s faster and our project doesn’t warp the data too much.
topography_proj = vd.project_grid(topography, projection, method="nearest")
geoid_proj = vd.project_grid(geoid, projection, method="nearest")
topography_proj
/home/runner/micromamba-root/envs/fatiando-tutorials/lib/python3.9/site-packages/verde/base/base_classes.py:463: FutureWarning: The 'spacing', 'shape' and 'region' arguments will be removed in Verde v2.0.0. Please use the 'verde.grid_coordinates' function to define grid coordinates and pass them as the 'coordinates' argument.
warnings.warn(
/home/runner/micromamba-root/envs/fatiando-tutorials/lib/python3.9/site-packages/verde/base/base_classes.py:463: FutureWarning: The 'spacing', 'shape' and 'region' arguments will be removed in Verde v2.0.0. Please use the 'verde.grid_coordinates' function to define grid coordinates and pass them as the 'coordinates' argument.
warnings.warn(
<xarray.DataArray 'topography' (northing: 85, easting: 103)> array([[ 1.1245e+03, 1.1245e+03, 1.3350e+03, ..., -3.5210e+03, -4.4460e+03, -4.8420e+03], [ 1.2380e+03, 1.2380e+03, 1.2270e+03, ..., -3.4570e+03, -4.3500e+03, -4.7360e+03], [ 1.2210e+03, 1.2210e+03, 1.1040e+03, ..., -3.5170e+03, -4.3620e+03, -4.7030e+03], ..., [ 1.1255e+03, 1.1255e+03, 1.1130e+03, ..., 3.5000e+00, -3.2000e+01, -1.8000e+01], [ 1.1045e+03, 1.1045e+03, 1.0910e+03, ..., 1.1750e+01, -5.0000e-01, -1.7000e+01], [ 1.1045e+03, 1.1045e+03, 1.0910e+03, ..., 1.1750e+01, -5.0000e-01, -1.7000e+01]], dtype=float32) Coordinates: * easting (easting) float64 2.02e+06 2.036e+06 ... 3.72e+06 3.736e+06 * northing (northing) float64 -3.393e+06 -3.375e+06 ... -1.855e+06 -1.836e+06 Attributes: metadata: Generated by Chain(steps=[('mean',\n BlockReduce(...
Reference heights to the ellipsoid#
Before we can calculate gravity disturbances, we need both the observation height and the topography to be referenced to the ellipsoid instead of “sea level” or the geoid. We can do that by adding the geoid height to the current heights.
topography_geometric = topography_proj + geoid_proj
topography_geometric
<xarray.DataArray (northing: 85, easting: 103)> array([[ 1.1603000e+03, 1.1603000e+03, 1.3718000e+03, ..., -3.4988999e+03, -4.4250000e+03, -4.8216001e+03], [ 1.2743000e+03, 1.2743000e+03, 1.2638000e+03, ..., -3.4351499e+03, -4.3292998e+03, -4.7158999e+03], [ 1.2573500e+03, 1.2573500e+03, 1.1406000e+03, ..., -3.4955000e+03, -4.3417002e+03, -4.6833999e+03], ..., [ 1.1438500e+03, 1.1438500e+03, 1.1305000e+03, ..., -5.0000000e+00, -4.0200001e+01, -2.6000000e+01], [ 1.1221500e+03, 1.1221500e+03, 1.1079000e+03, ..., 2.5499992e+00, -9.3999996e+00, -2.5600000e+01], [ 1.1221500e+03, 1.1221500e+03, 1.1079000e+03, ..., 2.5499992e+00, -9.3999996e+00, -2.5600000e+01]], dtype=float32) Coordinates: * easting (easting) float64 2.02e+06 2.036e+06 ... 3.72e+06 3.736e+06 * northing (northing) float64 -3.393e+06 -3.375e+06 ... -1.855e+06 -1.836e+06
For the observation heights, we have to interpolate the geoid height values at the same points as the observations. We can do this with the cubic interpolation in Verde.
# Unravel the grid so that we can pass it to the interpolator
geoid_table = vd.grid_to_table(geoid_proj)
interpolator = vd.ScipyGridder(method="cubic")
interpolator.fit((geoid_table.easting, geoid_table.northing), geoid_table.geoid)
# Predict the geoid height at same locations as the observation points
data = data.assign(geoid_m=interpolator.predict((data.easting_m, data.northing_m)))
data = data.assign(height_geometric_m=data.height_sea_level_m + data.geoid_m)
data
longitude | latitude | height_sea_level_m | gravity_mgal | easting_m | northing_m | geoid_m | height_geometric_m | |
---|---|---|---|---|---|---|---|---|
8648 | 25.01500 | -26.26334 | 1230.2 | 978681.38 | 2.526066e+06 | -2.733052e+06 | 27.302536 | 1257.502536 |
8649 | 25.01932 | -26.38713 | 1297.0 | 978669.02 | 2.526503e+06 | -2.746924e+06 | 27.659737 | 1324.659737 |
8650 | 25.02499 | -26.39667 | 1304.8 | 978669.28 | 2.527075e+06 | -2.747994e+06 | 27.689820 | 1332.489820 |
8651 | 25.04500 | -26.07668 | 1165.2 | 978681.08 | 2.529096e+06 | -2.712163e+06 | 26.915163 | 1192.115163 |
8652 | 25.07668 | -26.35001 | 1262.5 | 978665.19 | 2.532295e+06 | -2.742763e+06 | 27.540560 | 1290.040560 |
... | ... | ... | ... | ... | ... | ... | ... | ... |
12974 | 31.51500 | -23.86333 | 300.5 | 978776.85 | 3.182450e+06 | -2.466945e+06 | 12.527840 | 313.027840 |
12975 | 31.52499 | -23.30000 | 280.7 | 978798.55 | 3.183459e+06 | -2.405224e+06 | 11.987479 | 292.687479 |
12976 | 31.54832 | -23.19333 | 245.7 | 978803.55 | 3.185814e+06 | -2.393567e+06 | 11.897978 | 257.597978 |
12977 | 31.57333 | -23.84833 | 226.8 | 978808.44 | 3.188340e+06 | -2.465298e+06 | 12.483330 | 239.283330 |
13557 | 31.37500 | -23.00000 | 285.6 | 978734.77 | 3.168312e+06 | -2.372463e+06 | 11.533216 | 297.133216 |
3877 rows × 8 columns
Gravity disturbance#
The gravity disturbance is defined as:
in which \((\lambda, \phi, h)\) are the longitude, latitude, and geometric height, \(g\) is the observed gravity acceleration, and \(\gamma\) is the calculated normal gravity of a reference ellipsoid. Notice that all 3 quantities are located at the same point (the observation point).
Note
Disturbances vs anomalies:. We want to calculate the disturbance \(\delta g(\lambda, \phi, h)\) instead of the traditional “gravity anomaly” \(\Delta g(\lambda, \phi) = g(\lambda, \phi, h=N) - \gamma(\lambda, \phi, h=0)\) because the anomaly is not exclusively related to the subsurface density anomalies, while the gravity disturbance is. In fact, it does not even make sense to talk about the “height of the gravity anomaly” since by definition it is only variable in longitude and latitude. Most geophysicists will agree that, in practice, the “height” of observations matter for modeling and interpretation. In most cases, even if we calculate gravity anomalies, we end up pretending that they are gravity disturbances when interpreting the data. So why not calculate disturbances directly?
We can use Boule to compute the normal gravity of the WGS84 reference ellipsoid on any point outside of the ellipsoid using a closed-form equation. This eliminates the need for a free-air correction, which is a rough approximation at best. With that, we can compute the gravity disturbance and store it in our pandas.DataFrame
.
data = data.assign(
normal_gravity_mgal=bl.WGS84.normal_gravity(data.latitude, data.height_geometric_m)
)
data = data.assign(gravity_disturbance_mgal=data.gravity_mgal - data.normal_gravity_mgal)
data
longitude | latitude | height_sea_level_m | gravity_mgal | easting_m | northing_m | geoid_m | height_geometric_m | normal_gravity_mgal | gravity_disturbance_mgal | |
---|---|---|---|---|---|---|---|---|---|---|
8648 | 25.01500 | -26.26334 | 1230.2 | 978681.38 | 2.526066e+06 | -2.733052e+06 | 27.302536 | 1257.502536 | 978656.289769 | 25.090231 |
8649 | 25.01932 | -26.38713 | 1297.0 | 978669.02 | 2.526503e+06 | -2.746924e+06 | 27.659737 | 1324.659737 | 978644.455439 | 24.564561 |
8650 | 25.02499 | -26.39667 | 1304.8 | 978669.28 | 2.527075e+06 | -2.747994e+06 | 27.689820 | 1332.489820 | 978642.725739 | 26.554261 |
8651 | 25.04500 | -26.07668 | 1165.2 | 978681.08 | 2.529096e+06 | -2.712163e+06 | 26.915163 | 1192.115163 | 978663.122713 | 17.957287 |
8652 | 25.07668 | -26.35001 | 1262.5 | 978665.19 | 2.532295e+06 | -2.742763e+06 | 27.540560 | 1290.040560 | 978652.468478 | 12.721522 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
12974 | 31.51500 | -23.86333 | 300.5 | 978776.85 | 3.182450e+06 | -2.466945e+06 | 12.527840 | 313.027840 | 978781.535934 | -4.685934 |
12975 | 31.52499 | -23.30000 | 280.7 | 978798.55 | 3.183459e+06 | -2.405224e+06 | 11.987479 | 292.687479 | 978750.536972 | 48.013028 |
12976 | 31.54832 | -23.19333 | 245.7 | 978803.55 | 3.185814e+06 | -2.393567e+06 | 11.897978 | 257.597978 | 978754.386590 | 49.163410 |
12977 | 31.57333 | -23.84833 | 226.8 | 978808.44 | 3.188340e+06 | -2.465298e+06 | 12.483330 | 239.283330 | 978803.297086 | 5.142914 |
13557 | 31.37500 | -23.00000 | 285.6 | 978734.77 | 3.168312e+06 | -2.372463e+06 | 11.533216 | 297.133216 | 978729.593092 | 5.176908 |
3877 rows × 10 columns
Now plot the gravity disturbance that we just calculated on a map.
fig = pygmt.Figure()
maxabs = vd.maxabs(data.gravity_disturbance_mgal)
pygmt.makecpt(cmap="polar+h", series=[-maxabs, maxabs])
fig.plot(
x=data.longitude,
y=data.latitude,
color=data.gravity_disturbance_mgal,
cmap=True,
style="c4p",
projection="M15c",
frame="af",
)
fig.colorbar(frame='af+l"gravity disturbance [mGal]"')
fig.show()
Topographic correction#
Now that we have removed normal gravity and calculated a gravity disturbance, the next step is to remove the effect of the topographic and oceanic masses above and below the ellipsoid. We need to do this in order to isolate the effect of the Bushveld rocks.
We will use Harmonica to calculate and remove the gravitational effect of the topography by modeling the masses as a layer of right-rectangular prisms.
The prism_layer
function creates a prism model based on a surface undulating around a reference level. In our case, the reference is the ellipsoid (0 height) and the surface is our topography (as geometric heights). The difficult part is assigning the correct density (or density contrast) to each part of our model:
\(\rho = 2670\ kg/m^3\) for continental parts above the ellipsoid
\(\rho = -2670\ kg/m^3\) for continental parts below the ellipsoid
\(\rho = 1040 - 2670\ kg/m^3\) in the oceans (1040 being an approximate density of ocean water)
Here we’ll define “continental” as places with positive orthometric height since 0 orthometric height roughly matches the coastlines.
Note
There is an extra effect of under- and over-correcting for ocean water in the oceans. This happens because we are assuming that the surface of the water is at 0 geometric height (the surface of the ellipsoid) when in fact it roughly follows the geoid. So in oceanic places where the geoid is below the ellipsoid, we correct for water where there was none and vice-versa. Luckily, this effect is negligible here since our study area is relatively far away from the ocean. We can safely ignore this correction but it should be performed when close to the coast or directly above the oceans.
# Start by placing the continental density above and below the ellipsoid
topography_density = np.where(topography_geometric > 0, 2670, -2670)
# Now correct the value in the oceans (topography < 0)
topography_density = np.where(topography_proj < 0, 1040 - 2670, topography_density)
# Create the prism model of the topographic masses
topography_model = hm.prism_layer(
coordinates=(topography_geometric.easting, topography_geometric.northing),
surface=topography_geometric,
reference=0,
properties={"density": topography_density}
)
topography_model
<xarray.Dataset> Dimensions: (northing: 85, easting: 103) Coordinates: * easting (easting) float64 2.02e+06 2.036e+06 ... 3.72e+06 3.736e+06 * northing (northing) float64 -3.393e+06 -3.375e+06 ... -1.855e+06 -1.836e+06 top (northing, easting) float32 1.16e+03 1.16e+03 ... 0.0 0.0 bottom (northing, easting) float64 0.0 0.0 0.0 0.0 ... 0.0 0.0 -9.4 -25.6 Data variables: density (northing, easting) int64 2670 2670 2670 2670 ... 2670 -1630 -1630 Attributes: coords_units: meters properties_units: SI
To help us visually what this correction is doing, let’s take a look at the density that we assigned across our topographic model.
fig = pygmt.Figure()
scale = vd.maxabs(topography_model.density)
pygmt.makecpt(cmap="roma+h", reverse=True, series=[-scale, scale])
fig.grdimage(topography_model.density, projection="X15c", frame=True)
fig.colorbar(frame='af+l"Density of the prism model [kg/m3]"')
fig.show()
As prescribed by the model, the density is 2670 kg/m³ in the continents and 1040 - 2670 kg/m³ in the oceans. The exceptions are parts of the continents that are below 0 geometric height, which have a density of -2670 kg/m³.
Now we can forward model the gravitational effect of the topographic/bathymetric masses at the data points and remove it from our gravity disturbance to calculate a topography-free (Bouguer) disturbance.
coordinates = (data.easting_m, data.northing_m, data.height_geometric_m)
data = data.assign(
terrain_effect_mgal=topography_model.prism_layer.gravity(coordinates, field="g_z"),
)
data = data.assign(
gravity_bouguer_mgal=data.gravity_disturbance_mgal - data.terrain_effect_mgal
)
data
longitude | latitude | height_sea_level_m | gravity_mgal | easting_m | northing_m | geoid_m | height_geometric_m | normal_gravity_mgal | gravity_disturbance_mgal | terrain_effect_mgal | gravity_bouguer_mgal | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
8648 | 25.01500 | -26.26334 | 1230.2 | 978681.38 | 2.526066e+06 | -2.733052e+06 | 27.302536 | 1257.502536 | 978656.289769 | 25.090231 | 139.812249 | -114.722018 |
8649 | 25.01932 | -26.38713 | 1297.0 | 978669.02 | 2.526503e+06 | -2.746924e+06 | 27.659737 | 1324.659737 | 978644.455439 | 24.564561 | 145.905545 | -121.340984 |
8650 | 25.02499 | -26.39667 | 1304.8 | 978669.28 | 2.527075e+06 | -2.747994e+06 | 27.689820 | 1332.489820 | 978642.725739 | 26.554261 | 145.905317 | -119.351056 |
8651 | 25.04500 | -26.07668 | 1165.2 | 978681.08 | 2.529096e+06 | -2.712163e+06 | 26.915163 | 1192.115163 | 978663.122713 | 17.957287 | 129.444479 | -111.487193 |
8652 | 25.07668 | -26.35001 | 1262.5 | 978665.19 | 2.532295e+06 | -2.742763e+06 | 27.540560 | 1290.040560 | 978652.468478 | 12.721522 | 142.540230 | -129.818707 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
12974 | 31.51500 | -23.86333 | 300.5 | 978776.85 | 3.182450e+06 | -2.466945e+06 | 12.527840 | 313.027840 | 978781.535934 | -4.685934 | 34.376896 | -39.062830 |
12975 | 31.52499 | -23.30000 | 280.7 | 978798.55 | 3.183459e+06 | -2.405224e+06 | 11.987479 | 292.687479 | 978750.536972 | 48.013028 | 29.023204 | 18.989825 |
12976 | 31.54832 | -23.19333 | 245.7 | 978803.55 | 3.185814e+06 | -2.393567e+06 | 11.897978 | 257.597978 | 978754.386590 | 49.163410 | 20.736237 | 28.427173 |
12977 | 31.57333 | -23.84833 | 226.8 | 978808.44 | 3.188340e+06 | -2.465298e+06 | 12.483330 | 239.283330 | 978803.297086 | 5.142914 | 18.758079 | -13.615165 |
13557 | 31.37500 | -23.00000 | 285.6 | 978734.77 | 3.168312e+06 | -2.372463e+06 | 11.533216 | 297.133216 | 978729.593092 | 5.176908 | 26.981424 | -21.804516 |
3877 rows × 12 columns
Plot the Bouguer disturbance on a map so we can see what we have.
fig = pygmt.Figure()
maxabs = vd.maxabs(data.gravity_bouguer_mgal)
pygmt.makecpt(cmap="polar", series=[-maxabs, maxabs])
fig.plot(
x=data.longitude,
y=data.latitude,
color=data.gravity_bouguer_mgal,
cmap=True,
style="c4p",
projection="M15c",
frame=True,
)
fig.colorbar(frame='af+l"Bouguer disturbance [mGal]"')
fig.show()
As expected, the values are mostly negative and are dominated by the Moho signal.
Regional-residual separation#
Bouguer gravity is dominated by the effect of variations in Moho depth because it involves large density contrasts and thicknesses. If we want to see the signal from the shallower intrusions of the Bushveld (often called the residual), we’ll need to remove the effect of the deeper and larger sources (often called the regional).
Traditionally, this is done by fitting a low-degree 2D polynomial to the data and using it as the regional field. We could do this with verde.Trend
but we’ll use a different (and better) approach. The reason for not using a polynomial is that it doesn’t take into account the variable height of the observations or the physical nature of the data (a potential field).
A possible alternative would be to model and remove the Moho effect via an Airy isostasy model, which assumes that all topography is locally compensated by the Moho depth variation. This could be done with harmonica.isostasy_airy
to calculate the Moho depth, followed by harmonica.prism_layer
to calculate and remove its gravitational effect.
Here, we will use a simpler approach with the equivalent sources present in Harmonica to fit a set of deep point sources to the data.
Setting a large depth for the sources means that their field will be long-wavelength and is a good way to represent our regional. The choice of depth is somewhat subjective (as is the choice of polynomial degree) and will depend on the interpretation of the results.
Note
The equivalent sources approach may have trouble scaling to large datasets. New features in Harmonica are in development to account for this and should be released with the next version of the package. For the moment, it is recommended to pass the data through a verde.BlockMean
prior to fitting the deep sources to avoid running out of memory.
# Create a set of deep sources at a depth of 500 km
deep_sources = hm.EquivalentSources(damping=1000, depth=500e3)
# Fit the sources to the data
deep_sources.fit((data.easting_m, data.northing_m, data.height_geometric_m), data.gravity_bouguer_mgal)
# Use the sources to predict the regional field
data = data.assign(
gravity_regional_mgal=deep_sources.predict((data.easting_m, data.northing_m, data.height_geometric_m))
)
# Calculate a residual field (which is what we want)
data = data.assign(
gravity_residual_mgal=data.gravity_bouguer_mgal - data.gravity_regional_mgal
)
data
longitude | latitude | height_sea_level_m | gravity_mgal | easting_m | northing_m | geoid_m | height_geometric_m | normal_gravity_mgal | gravity_disturbance_mgal | terrain_effect_mgal | gravity_bouguer_mgal | gravity_regional_mgal | gravity_residual_mgal | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
8648 | 25.01500 | -26.26334 | 1230.2 | 978681.38 | 2.526066e+06 | -2.733052e+06 | 27.302536 | 1257.502536 | 978656.289769 | 25.090231 | 139.812249 | -114.722018 | -116.712155 | 1.990137 |
8649 | 25.01932 | -26.38713 | 1297.0 | 978669.02 | 2.526503e+06 | -2.746924e+06 | 27.659737 | 1324.659737 | 978644.455439 | 24.564561 | 145.905545 | -121.340984 | -116.655864 | -4.685120 |
8650 | 25.02499 | -26.39667 | 1304.8 | 978669.28 | 2.527075e+06 | -2.747994e+06 | 27.689820 | 1332.489820 | 978642.725739 | 26.554261 | 145.905317 | -119.351056 | -116.674517 | -2.676540 |
8651 | 25.04500 | -26.07668 | 1165.2 | 978681.08 | 2.529096e+06 | -2.712163e+06 | 26.915163 | 1192.115163 | 978663.122713 | 17.957287 | 129.444479 | -111.487193 | -116.909344 | 5.422152 |
8652 | 25.07668 | -26.35001 | 1262.5 | 978665.19 | 2.532295e+06 | -2.742763e+06 | 27.540560 | 1290.040560 | 978652.468478 | 12.721522 | 142.540230 | -129.818707 | -116.931033 | -12.887675 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
12974 | 31.51500 | -23.86333 | 300.5 | 978776.85 | 3.182450e+06 | -2.466945e+06 | 12.527840 | 313.027840 | 978781.535934 | -4.685934 | 34.376896 | -39.062830 | -56.223067 | 17.160237 |
12975 | 31.52499 | -23.30000 | 280.7 | 978798.55 | 3.183459e+06 | -2.405224e+06 | 11.987479 | 292.687479 | 978750.536972 | 48.013028 | 29.023204 | 18.989825 | -49.678543 | 68.668368 |
12976 | 31.54832 | -23.19333 | 245.7 | 978803.55 | 3.185814e+06 | -2.393567e+06 | 11.897978 | 257.597978 | 978754.386590 | 49.163410 | 20.736237 | 28.427173 | -48.047643 | 76.474815 |
12977 | 31.57333 | -23.84833 | 226.8 | 978808.44 | 3.188340e+06 | -2.465298e+06 | 12.483330 | 239.283330 | 978803.297086 | 5.142914 | 18.758079 | -13.615165 | -54.873920 | 41.258755 |
13557 | 31.37500 | -23.00000 | 285.6 | 978734.77 | 3.168312e+06 | -2.372463e+06 | 11.533216 | 297.133216 | 978729.593092 | 5.176908 | 26.981424 | -21.804516 | -48.199166 | 26.394650 |
3877 rows × 14 columns
Plot a map of the residual field. This should resemble what we know about the geology of the Bushveld.
fig = pygmt.Figure()
scale = vd.maxabs(data.gravity_residual_mgal)
pygmt.makecpt(cmap="polar", series=[-scale, scale])
fig.plot(
x=data.longitude,
y=data.latitude,
color=data.gravity_residual_mgal,
cmap=True,
style="c5p",
projection="M15c",
frame=True,
)
fig.colorbar(frame='af+l"residual field [mGal]"')
fig.show()
Grid the residuals#
Finally, we can create a regular grid of the residuals at a uniform height. This is a good way to aid visualization and would also allow us to do calculations involving Fourier transforms and other techniques that required gridded data.
The best way to achieve this is, again, the equivalent sources in Harmonica. This method allows us to predict the data values on a grid at whatever height we wish (effectively upward-continuing the data), all in a single step. Since our goal here is to accurately model and predict the residual field, we’ll use a shallower depth so that the sources can reproduce the short wavelengths in our data.
eqs = hm.EquivalentSources(damping=10, depth=10e3)
eqs.fit((data.easting_m, data.northing_m, data.height_geometric_m), data.gravity_residual_mgal)
EquivalentSources(damping=10, depth=10000.0)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
EquivalentSources(damping=10, depth=10000.0)
Now use the equivalent source model to predict data values on a regular grid at a uniform height. In this case, we will use a height that is slightly larger than the highest observation point to avoid downward continuation. As a bonus, we can pass the projection functions that we used earlier to generate a grid in geographic coordinates (longitude, latitude) instead of Cartesian.
residual_grid = eqs.grid(
upward=2200, # Height in meters
region=region,
spacing=2 / 60, # Decimal degrees
data_names=["gravity_residual"],
dims=("latitude", "longitude"),
projection=projection,
)
residual_grid
/home/runner/micromamba-root/envs/fatiando-tutorials/lib/python3.9/site-packages/verde/base/base_classes.py:463: FutureWarning: The 'spacing', 'shape' and 'region' arguments will be removed in Verde v2.0.0. Please use the 'verde.grid_coordinates' function to define grid coordinates and pass them as the 'coordinates' argument.
warnings.warn(
<xarray.Dataset> Dimensions: (latitude: 121, longitude: 211) Coordinates: * longitude (longitude) float64 25.0 25.03 25.07 ... 31.93 31.97 32.0 * latitude (latitude) float64 -27.0 -26.97 -26.93 ... -23.03 -23.0 upward (latitude, longitude) float64 2.2e+03 2.2e+03 ... 2.2e+03 Data variables: gravity_residual (latitude, longitude) float64 0.7897 0.8992 ... 19.54 Attributes: metadata: Generated by EquivalentSources(damping=10, depth=10000.0)
Plot the gridded residuals on a map and overlay the observation points. It’s always important to know where the actual data points are so we can avoid interpreting features in the grid that are likely just an artifact of the data sampling. This is particularly important in areas with sparse and uneven data coverage.
fig = pygmt.Figure()
scale = vd.maxabs(residual_grid.gravity_residual)
pygmt.makecpt(cmap="polar", series=[-scale, scale], no_bg=True)
fig.grdimage(
residual_grid.gravity_residual,
shading="+a45+nt0.15",
projection="M15c",
frame=True,
)
fig.colorbar(frame='af+l"residual field [mGal]"')
fig.plot(
x=data.longitude,
y=data.latitude,
style="c0.02c",
color="black",
)
fig.show()
References#
- WCNJ04
Susan J. Webb, R. Grant Cawthorn, Teresia Nguuri, and David James. Gravity modeling of Bushveld Complex connectivity supported by Southern African Seismic Experiment results. South African Journal of Geology, 107(1-2):207–218, 06 2004. URL: https://doi.org/10.2113/107.1-2.207, doi:10.2113/107.1-2.207.
Where to go next#
Congratulations! You’ve now completed our gravity processing tutorial. Here are few things you can do now:
Try using your own data.
Change the values of certain parameters (topographic density, equivalent source depth, etc) and see what happens.
Learn more about the individual packages by reading their documentation: https://www.fatiando.org/learn
License#
All Python source code is made available under the BSD 3-clause license. You can freely use and modify the code, without warranty, so long as you provide attribution to the authors.
Unless otherwise specified, all figures and Jupyter notebooks are available under the Creative Commons Attribution 4.0 License (CC-BY).
The full text of these licenses is provided in the LICENSE.txt
file.