Gravity: density below the surface#

Gravity measured along a line at the surface depends on how dense the ground is below it. This notebook finds the density anomaly of 40 buried prisms (a 10 x 4 grid, 1000 m across and 25 to 250 m deep) from the vertical gravity measured at 41 points above them.

In the words of the terminology:

  • the state is the density anomaly of each prism (g/cm³);

  • the observations are the gravity anomalies at the surface (mGal);

  • the forward operator is Newton’s law for a rectangular prism: the gravity at each point from a unit density in each prism;

  • the prior is no anomaly anywhere, with a prior covariance that makes neighboring prisms alike, and the observation covariance says the measurements are good to 1% of the largest signal.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

import fips
from fips.visualization import plot_comparison

The data and the geometry#

The observed gravity anomalies, and where the prisms are.

gravity = np.array(
    [
        6.5112333e-03,
        7.1144759e-03,
        7.8050213e-03,
        8.6004187e-03,
        9.5228049e-03,
        1.0600394e-02,
        1.1869543e-02,
        1.3377636e-02,
        1.5187140e-02,
        1.7381291e-02,
        2.0071869e-02,
        2.3409173e-02,
        2.7592423e-02,
        3.2872699e-02,
        3.9524661e-02,
        4.7739666e-02,
        5.7427097e-02,
        6.8126652e-02,
        7.9261565e-02,
        9.0330964e-02,
        1.0070638e-01,
        1.0950167e-01,
        1.1588202e-01,
        1.1936106e-01,
        1.1961197e-01,
        1.1617789e-01,
        1.0868957e-01,
        9.7370419e-02,
        8.3429883e-02,
        6.9002570e-02,
        5.6079733e-02,
        4.5525921e-02,
        3.7267881e-02,
        3.0880404e-02,
        2.5919366e-02,
        2.2024198e-02,
        1.8925481e-02,
        1.6427182e-02,
        1.4387142e-02,
        1.2701558e-02,
        1.1293783e-02,
    ]
)  # mGal
stations = np.linspace(0, 1000, len(gravity))  # where gravity was measured, m

x_edges = np.linspace(0, 1000, 11)  # the prisms' edges across, m
depth_edges = np.linspace(25, 250, 5)  # and down, m
x_centers = (x_edges[:-1] + x_edges[1:]) / 2
depth_centers = (depth_edges[:-1] + depth_edges[1:]) / 2

Labels and vectors#

fips keeps a label beside every number. Each prism is labeled by where it is, its x and its depth; each observation by its x. A Vector is values on such labels: the prior is no anomaly in any prism, in a block named density, and the observations are a block named gravity.

prisms = pd.MultiIndex.from_product([x_centers, depth_centers], names=["x", "depth"])
prior = fips.Vector.from_series(
    pd.Series(0.0, index=prisms, name="prior"), block="density"
)

points = pd.Index(stations, name="x")
obs = fips.Vector.from_series(
    pd.Series(gravity, index=points, name="observed"), block="gravity"
)
prior.space
Space(density: 40 x ['x', 'depth'])

The forward operator#

The gravity of a rectangular prism of unit density has a closed form. The matrix has one row per observation and one column per prism, in the order of the labels above (x outer, depth inner), so fips.Operator puts the observations’ labels on its rows and the prisms’ on its columns.

G = 6.67e-4  # the gravitational constant, in units that give mGal


def prism_integral(dx, dz):
    """Return the indefinite integral of the vertical gravity of a 2D prism."""
    return dz * np.arctan(dx / dz) + dx / 2 * np.log(dz**2 + dx**2)


def prism_gravity(stations, x_edges, depth_edges):
    """Return the gravity at each station of a unit density in each prism (stations x prisms)."""
    columns = []
    for x1, x2 in zip(x_edges[:-1], x_edges[1:], strict=True):
        for z1, z2 in zip(depth_edges[:-1], depth_edges[1:], strict=True):
            a, b = x1 - stations, x2 - stations
            columns.append(
                2
                * G
                * (
                    prism_integral(b, z2)
                    - prism_integral(b, z1)
                    - prism_integral(a, z2)
                    + prism_integral(a, z1)
                )
            )
    return np.column_stack(columns)


forward = fips.Operator(
    prism_gravity(stations, x_edges, depth_edges), obs.space, prior.space
)
forward.shape
(41, 40)

The covariances#

Neighboring prisms should be alike: the prior covariance correlates two prisms by their distance across (over 200 m) and in depth (over 100 m), with a standard deviation of 0.5 g/cm³. It is a Kronecker product of two exponential correlations, kept as its two small factors. The observations’ errors are independent, at 1% of the largest signal.

prior_cov = fips.Kronecker(
    fips.Exponential("x", 200.0), fips.Exponential("depth", 100.0)
).with_std(0.5)
obs_cov = fips.Independent().with_std(0.01 * gravity.max())

The problem and its solution#

A Problem puts the inputs together and checks that every label matches. fips.solve returns the Posterior: the estimate, its uncertainty and the diagnostics of the solve.

problem = fips.Problem(
    prior=prior, prior_cov=prior_cov, obs=obs, obs_cov=obs_cov, forward=forward
)
post = fips.solve(problem)
post.summary()
gamma n_state n_obs dofs information_content chi2 reduced_chi2 r2 rmse uncertainty_reduction
0 1.0 40 41 16.05898 53.145823 2.723385 0.066424 0.999982 0.000162 0.456763

The DOFS say how many independent pieces of information the 41 measurements give about the 40 prisms. The reduced chi-squared is near 1 when the covariances fit the data; here it is well below, because these synthetic observations have no noise at all.

The fit to the data#

plot_comparison draws the observations, what the prior predicts (problem.predict, the forward operator times the prior) and what the estimate predicts (post.modeled_obs) on one axes.

def named(vector, name):
    """Return a vector of one block as a Series on its own labels, named for the legend."""
    return vector.to_series().droplevel("block").rename(name)


fig, ax = plot_comparison(
    named(problem.obs, "observed"),
    named(problem.predict(problem.prior), "prior"),
    named(post.modeled_obs, "posterior"),
)
ax.set(xlabel="x (m)", ylabel="gravity (mGal)");
../../_images/78899f35263c86731615f9453251592381cc2ed6cd46a9c7fec8a2bb54047c41.png

The density below#

The estimate and its standard deviation, as maps of the prisms: to_xarray() puts a block on its levels, x and depth.

estimate = post.estimate["density"].to_xarray()
std = np.sqrt(post.variances["density"].to_xarray())

fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
estimate.plot(
    ax=axes[0],
    x="x",
    y="depth",
    yincrease=False,
    cmap="Greys",
    cbar_kwargs={"label": "density (g/cm³)"},
)
axes[0].set_title("Posterior estimate")
std.plot(
    ax=axes[1],
    x="x",
    y="depth",
    yincrease=False,
    cmap="viridis",
    cbar_kwargs={"label": "std (g/cm³)"},
)
axes[1].set_title("Posterior standard deviation");
../../_images/8049ee54896fd4198cce70f61206054a4940bc9069f3b7569a29b70bd4c8bfc2.png

How much to trust the data#

The regularization factor gamma divides the observation covariance: above 1 the solve trusts the data more, below 1 the prior. The posterior keeps its factorization, so with_gamma gives each gamma’s posterior without solving again, and the rows of summary() stack into a table.

pd.concat(
    [post.with_gamma(g).summary() for g in (0.1, 0.3, 1.0, 3.0, 10.0)],
    ignore_index=True,
)
gamma n_state n_obs dofs information_content chi2 reduced_chi2 r2 rmse uncertainty_reduction
0 0.1 40 41 12.410882 36.968706 1.221866 0.029802 0.999911 0.000363 0.391886
1 0.3 40 41 13.865537 44.159710 1.808107 0.044100 0.999946 0.000282 0.419185
2 1.0 40 41 16.058980 53.145823 2.723385 0.066424 0.999982 0.000162 0.456763
3 3.0 40 41 18.042533 62.528799 3.426285 0.083568 0.999996 0.000076 0.491897
4 10.0 40 41 19.627809 73.902069 3.867133 0.094320 0.999999 0.000030 0.522448