November 7, 2024
- Recap on gravity and magnetic fields.
- Analytic solutions for rectangular prisms.
- Software implementation.
- Optimizations to the code.
- Forward modelling the fields of a regular mesh.
- Demo: forward modelling with SimPEG.
- Demo: running inversion with SimPEG.
- Magnetic data is the most available geophysical data.
- Gravity data is usually available.
- Higher data quality requires accurate forward modelling.
- Higher computation power allows us to work on larger problems:
- Larger areas.
- Data with higher resolution.
- Inversion of gravity and magnetic data are the most common.
- Efficient code helps to reduce the required computational power.
- Saves time and energy.
- 3D gravity and magnetic forward modelling codes available since the 90s.
- In order to get code that is:
- Accurate,
- Efficient,
- Easy to use,
- Easy to extend,
- Easy to parallelize and distribute.
- Data gathered on ground or airbone.
- Observed fields reflect the presence of anomalous bodies.
- In order to invert for those bodies:
- Compute the gravity and mangetic fields they generate.
Gravity potential:
Assume no currents: $ \nabla_\mathbf{p} \times \mathbf{B} = 0 $
Magnetic field:
- Discretize the subsurface using regular meshes with rectangular cells.
- Each cell: homogeneous physical property.
- Need to compute the fields generated by the mesh.
- Analytic solutions of gravity and magnetic fields for rectangular prisms.
- Gravity potential: $$ \begin{align*} V(\mathbf{p}) &= G \int\limits_\Gamma \frac{\rho}{|\mathbf{p} - \mathbf{q}|} \text{d}\mathbf{q} \newline & = G\rho \int\limits_{x_1}^{x_2} \int\limits_{y_1}^{y_2} \int\limits_{z_1}^{z_2} \frac{ 1 }{ |\mathbf{p} - \mathbf{q}| } \text{d}\mathbf{q} \end{align*} $$
- Gravity acceleration: $$ \mathbf{g}(\mathbf{p}) = \nabla_\mathbf{p} V(\mathbf{p}) $$ $$ g_x = \frac{\partial V}{\partial x_p}, \, g_y = \frac{\partial V}{\partial y_p}, \, g_z = \frac{\partial V}{\partial z_p} $$
Solve the gravity potential, then derive the acceleration components.
Coordinate system located on
Gravity potential: $$ V(\mathbf{p}) = G \int\limits_\Gamma \frac{\rho}{|\mathbf{p} - \mathbf{q}|} \text{d}\mathbf{q} $$ $$ V(\mathbf{p}) = G\rho \int_{x_1}^{x_2} \int_{y_1}^{y_2} \int_{z_1}^{z_2} \frac{ \text{d}x \, \text{d}y \, \text{d}z }{ \sqrt{x^2 + y^2 + z^2} } $$
Define
The
Analytic solutions for
where
We refer to
Gravity acceleration components:
where:
The first-order kernels are given by:
Let's write some code that computes the vertical acceleration due to a prism on an observation point.
Define prism:
west, east = -10.0, 10.0
south, north = -12.0, 12.0
bottom, top = -15.0, -5.0
prism = [west, east, south, north, bottom, top]
Define observation point:
coordinates = (0.0, 0.0, 2.0)
Implement a kernel function:
import numpy as np
def kernel_z(x, y, z):
r = np.sqrt(x**2 + y**2 + z**2)
result = -(x * np.log(y + r) + y * np.log(x + r) - z * np.arctan(x * y / z / r))
return result
Define a function to evaluate the kernel on the nodes of the prism:
def evaluate_kernel(coordinates, prism, kernel):
# Extract the coordinates of the observation point
easting, northing, upward = coordinates
# Initialize a result value equal to zero
result = 0.0
# Iterate over the vertices of the prism
for i in range(2):
for j in range(2):
for k in range(2):
# Compute shifted coordinates
shift_east = prism[1 - i] - easting
shift_north = prism[3 - j] - northing
shift_upward = prism[5 - k] - upward
# Evaluate kernel. Use the right sign.
result += (-1) ** (i + j + k) * kernel(
shift_east, shift_north, shift_upward
)
return result
Finally, define a function to compute the gravity acceleration of the prism:
G = 6.6743e-11 # gravitational constant
def gravity_z(coordinates, prism, density):
u_z = evaluate_kernel(coordinates, prism, kernel_z)
return G * density * u_z
Now we can use this function to compute the vertical acceleration due to the prism on the observation point:
# Define the prism
prism = [-10.0, 10.0, -12.0, 12.0, -15.0, -5.0]
# Define the density contrast for the prism
density = 200.0 # kg/m3
# Define the coordinates of the observation point
coordinates = (0.0, 0.0, 2.0)
# Compute the vertical acceleration
gz = gravity_z(coordinates, prism, density)
print(f"{gz} m/s2")
-2.635142309670108e-07 m/s2
The result is negative because this is the upward acceleration component.
Use it to compute
The gravitational potential
But the solutions
Evaluate the gravity_z
function on a grid located on the top face of the
prism.
For the potential
One of the singular
One of the singular
We need to evaluate these limits in our implementation.
One possible solution: define custom functions for the
def safe_log(x, r):
if r == 0.0:
return 0.0
if x == -r:
return 0.0
return np.log(x + r)
def safe_arctan(num, den):
if den == 0.0:
return 0.0
return np.arctan(num / den)
Use these instead of np.log
and np.arctan
in our implementation of the
kernel function:
def kernel_z(x, y, z):
r = np.sqrt(x**2 + y**2 + z**2)
result = -(
x * safe_log(y, r)
+ y * safe_log(x, r)
- z * safe_arctan(x * y , z * r)
)
return result
Let's consider the case in which the observation point is almost in one of the lines along the horizontal edges.
The shifted coordinates of the two vertices in that line are going to be:
where
The distance from observation point to each vertex:
In code:
x1, x2 = -70, -50
y = 1e-6
z = 0
r1 = np.sqrt(x1**2 + y**2 + z**2)
r2 = np.sqrt(x2**2 + y**2 + z**2)
print(f"{r1=}")
print(f"{r2=}")
r1=70.0
r2=50.00000000000001
So r2 != -x2
,
but r1 == -x1
.
We fall under machine precision when computing r1
.
We defined the safe_log
function as follows:
def safe_log(x, r):
if r == 0.0:
return 0.0
if x == -r:
return 0.0
return np.log(x + r)
Evaluating safe_log
on these two points:
- will return 0.0
- will return the
np.log(x2 + r2)
Will lead to invalid evaluation of the fields.
def safe_log(x, y, z, r):
if r == 0.0:
return 0.0
if x < 0 and y == 0.0 and z == 0.0:
return 0.0
return np.log(x + r)
Regardless of how small y
and z
are with respect to x
, this function
will not return 0.0
if the observation point is not in the line of the
edges.
If $x < 0$ and $|x| \gg |y|, |z|$, issues evaluating $\ln(x + r)$:
- The
x + r
addition could fall under machine precision. -
x + r
will be a small number: errors get greatly amplified by the $\ln$.
Numerical instabilities that will produce inaccurate fields.
When $x<0$, Fukushima (2020) proposes to replace the $\ln(x + r)$ for a better expression.
Since:
We can express it as:
Magnetic field:
One component:
We can write these expressions as a dot product between a matrix and the magnetization vector:
The
They have analytic solutions:
Second-order kernels:
Second-order kernels also contain nuances:
-
$\mathbf{B}$ field is not defined on:- Prism vertices
- Prism edges
- Prism faces
-
$\mathbf{B}$ field is defined everywhere outside the prism, but:- The non-diagonal
$u_{\alpha\beta}$ are not defined on lines along edges. - Limits approaching to the faces from outside vs from inside are not equal.
- The non-diagonal
For the purpose of today, we'll leave this here.
More details in fatiando.org/choclo.
Should we add some slides explaining how to get forward model TMI? Should we add some slides about assuming induced magnetization only?
So far we implemented functions to compute the gravity field of prisms:
G = 6.6743e-11 # gravitational constant
def kernel_z(x, y, z):
...
def evaluate_kernel(coordinates, prism, kernel):
...
def gravity_z(coordinates, prism, density):
u_z = evaluate_kernel(coordinates, prism, kernel_z)
return G * density * u_z
To compute the effect of several prisms on several observation points, we
would need to use for
loops.
Because Python for loops are slow, this implementation is not efficient.
Numba allows to write code that gets compiled at runtime (JIT: Just
in-time compilation).
Leads to fast executions.
Slow matrix-vector dot product:
def dot_product_slow(matrix, x):
nrows, ncols = matrix.shape
result = np.zeros(nrows, dtype=np.float64)
for i in range(nrows):
for j in range(ncols):
result[i] += matrix[i][j] * x[j]
return result
Numba implementation:
import numba
@numba.jit(nopython=True)
def dot_product(matrix, x):
nrows, ncols = matrix.shape
result = np.zeros(nrows, dtype=np.float64)
for i in range(nrows):
for j in range(ncols):
result[i] += matrix[i][j] * x[j]
return result
Let's benchmark them:
import numpy as np
size = 300
rng = np.random.default_rng(seed=42)
a = rng.random(size=(size, size))
x = rng.random(size=size)
Benchmark slow implementation:
%timeit dot_product_slow(a, x)
39.4 ms ± 1.1 ms per loop
Benchmark Numba implementation:
%timeit dot_product(a, x)
94 μs ± 1.72 μs per loop
~400 times faster!
Includes:
- Gravity and magnetic forward modelling functions.
- For point masses, dipoles and rectangular prisms.
- All functions are Numba-based.
- Compute the fields for single source and single observation point.
- Also offers kernel functions.
Compute the upward acceleration due to a single prism:
import numpy as np
from choclo.prism import gravity_u
# Define a single computation point
easting, northing, upward = 0.0, 0.0, 10.0
# Define the boundaries of the prism as a list
prism = [-10.0, 10.0, -7.0, 7.0, -15.0, -5.0]
# And its density contrast
density = 400.0
# Compute the upward component of the grav. acceleration
g_u = gravity_u(easting, northing, upward, *prism, density)
print(f"{g_u} m/s2")
-1.6539652880538094e-07 m/s2
Compute the upward acceleration due to multiple prism on multiple observation points.
We need to:
- Iterate over the observation points.
- Iterate over the prisms.
For every observation point, compute the gravity effect of every prism.
import numba
@numba.jit(nopython=True, parallel=True)
def gravity_upward(coordinates, prisms, densities):
# Unpack coordinates of the observation points
easting, northing, upward = coordinates[:]
# Initialize a result array full of zeros
result = np.zeros_like(easting, dtype=np.float64)
# On each observation point, forward each prism
for i in numba.prange(len(easting)):
for j in range(prisms.shape[0]):
result[i] += gravity_u(
easting[i], northing[i], upward[i],
prisms[j, 0], prisms[j, 1],
prisms[j, 2], prisms[j, 3],
prisms[j, 4], prisms[j, 5],
densities[j],
)
return result
In SimPEG we need to forward model the gravity and magnetic effect of
regular meshes with rectangular prism cells.
One way: iterate over each observation point and each cell.
# in pseudo-code
for p in range(observation_points):
for c in range(mesh.n_cells):
results[p] += forward(
observation_point[p], mesh.cell_bounds[c], model[c]
)
We will evaluate each kernel function on each node of each cell.
But since neighbouring cells share 4 nodes, we'll evaluate the kernels repeatedly on each node.
Kernels evaluations are expensive due to np.log
and np.arctan
.
Iterating over cells is not efficient.
- Evaluate the kernel functions on every node in the mesh.
- For each cell:
- Gather the kernel values for its nodes.
- Add and subtract the kernel values for that cell.
# in pseudo-code
for p in range(observation_points):
# Initialize array for kernel values on each node
kernel_values = np.zeros(mesh.nodes.size)
# Iterate over the nodes
for n, node in enumerate(mesh.nodes):
kernel_values[n] = kernel(
observation_point[p], node
)
# Iterate over the cells
for c, cell in enumerate(mesh.cell):
results[p] += model[c] * add_effect(cell, kernel_values)
For example, if we have a 3D TensorMesh
of
50 x 50 x 50
cells:
Total nodes | Boundary | Internal | |
---|---|---|---|
Number of nodes | 132,651 | 15,002 | 117,649 |
Each internal node is shared by 8 cells.
Iterate over cells | Iterate over nodes | |
---|---|---|
Kernel evaluations | 956,194 | 132,651 |
We need to perform ~8 times less kernel evaluations.