Keywords: DC Resistivity, forward simulation, 3D, apparent resistivity, tree mesh.
Summary: Here we use the module simpeg
Learning Objectives:
- Demonstrating 3D forward simulation of DC resistivity data with SimPEG.
- Specific aspects of designing 3D DC resistivity surveys.
- Generating and plotting models on 3D meshes.
- How to plot 3D data in pseudosection.
- Computational resource considerations for 3D DC resistivity simulations.
Import Modules¶
Here, we import all of the functionality required to run the notebook for the tutorial exercise. All of the functionality specific to DC resistivity is imported from simpeg
# SimPEG functionality
from simpeg import maps, data
from simpeg.utils import model_builder
from simpeg.utils.io_utils.io_utils_electromagnetics import write_dcip_xyz
from simpeg.electromagnetics.static import resistivity as dc
from simpeg.electromagnetics.static.utils.static_utils import (
generate_dcip_sources_line,
pseudo_locations,
plot_pseudosection,
apparent_resistivity_from_voltage,
convert_survey_3d_to_2d_lines,
)
try:
import plotly
from simpeg.electromagnetics.static.utils.static_utils import plot_3d_pseudosection
from IPython.core.display import display, HTML
has_plotly = True
except ImportError:
has_plotly = False
pass
# discretize functionality
from discretize import TreeMesh
from discretize.utils import mkvc, active_from_xyz
# Common Python functionality
import os
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
mpl.rcParams.update({"font.size": 14})
write_output = False # Optional
Define the Topography¶
Surface topography is defined as an (N, 3) numpy.ndarray for 3D simulations. Here, we create basic topography for the forward simulation. For user-specific simulations, you may load topography from an XYZ file.
# Generate some topography
x_topo, y_topo = np.meshgrid(
np.linspace(-2100, 2100, 141), np.linspace(-2100, 2100, 141)
)
z_topo = 410.0 + 140.0 * (1 / np.pi) * (
np.arctan((x_topo - 500 * np.sin(np.pi * y_topo / 2800) - 400.0) / 200.0)
- np.arctan((x_topo - 500 * np.sin(np.pi * y_topo / 2800) + 400.0) / 200.0)
)
# Turn into a (N, 3) numpy.ndarray
x_topo, y_topo, z_topo = mkvc(x_topo), mkvc(y_topo), mkvc(z_topo)
topo_xyz = np.c_[mkvc(x_topo), mkvc(y_topo), mkvc(z_topo)]
# Plot the topography
fig = plt.figure(figsize=(6, 6))
ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], projection="3d")
ax.set_zlim([-400, 400])
ax.scatter3D(topo_xyz[:, 0], topo_xyz[:, 1], topo_xyz[:, 2], s=0.25, c="b")
ax.set_box_aspect(aspect=None, zoom=0.85)
ax.set_xlabel("X (m)", labelpad=10)
ax.set_ylabel("Y (m)", labelpad=10)
ax.set_zlabel("Z (m)", labelpad=10)
ax.set_title("Topography (Exaggerated z-axis)", fontsize=16, pad=-20)
ax.view_init(elev=45.0, azim=-125)
Define the Survey¶
Descriptions of the elements required to construct DC resistivity surveys were covered in the 2.5D Forward Simulation tutorial. The only difference is that for 3D surveys, XYZ locations are required for each electrode location. Once again, there are three approaches one might use to generate the a DC resistivity survey. The final approach will be used to simulate the data for this tutorial.
Option A: Define each source and its associated receivers directly
We used this approach in our 2.5D Forward Simulation tutorial. For a 3D simulation however, current electrode locations are defined as (3,) numpy.array and the associated set of potential electrode locations are defined as (N, 3) numpy.ndarray. This approach is somewhat cumbersome for generating synthetic 3D examples, so we will use a survey generation utility instead (Option C).
Option B: Survey from ABMN electrode locations
If we have (N, 3) numpy.ndarray containing the A, B, M and N electrode locations for each datum (loaded or created), we can use the generate
Option C: Survey from a set of survey lines
If the survey is comprised of a set of DC resistivity lines, we can use the generate
# Define the parameters for each survey line
survey_type = "dipole-dipole"
data_type = "volt"
dimension_type = "3D"
end_locations_list = [
np.r_[-1000.0, 1000.0, 0.0, 0.0],
np.r_[-600.0, -600.0, -1000.0, 1000.0],
np.r_[-300.0, -300.0, -1000.0, 1000.0],
np.r_[0.0, 0.0, -1000.0, 1000.0],
np.r_[300.0, 300.0, -1000.0, 1000.0],
np.r_[600.0, 600.0, -1000.0, 1000.0],
] # [x0, x1, y0, y1]
station_separation = 100.0
num_rx_per_src = 8
# The source lists for each line can be appended to create the source
# list for the whole survey.
source_list = []
for ii in range(0, len(end_locations_list)):
source_list += generate_dcip_sources_line(
survey_type,
data_type,
dimension_type,
end_locations_list[ii],
topo_xyz,
num_rx_per_src,
station_separation,
)
# Define the survey
survey = dc.survey.Survey(source_list)
Here we plot the electrode locations. We use the unique
unique_locations = survey.unique_electrode_locations
fig = plt.figure(figsize=(12, 2.75))
ax1 = fig.add_axes([0.1, 0.1, 0.2, 0.8])
ax1.scatter(unique_locations[:, 0], unique_locations[:, 1], 8, "r")
ax1.set_xlabel("x (m)")
ax1.set_ylabel("y (m)")
ax1.set_title("Horizontal locations")
pseudo_locations_xyz = pseudo_locations(survey)
inds = pseudo_locations_xyz[:, 1] == 0.0
ax2 = fig.add_axes([0.4, 0.1, 0.55, 0.8])
ax2.scatter(pseudo_locations_xyz[inds, 0], pseudo_locations_xyz[inds, -1], 8, "r")
ax2.set_xlabel("x (m)")
ax2.set_ylabel("z (m)")
ax2.set_title("Pseudo-locations (EW line)")
plt.show()
Design a (Tree) Mesh¶
Standard approach for DC/IP: The standard approach presented in the 2.5D Forward Simulation tutorial also applies to forward simulations in 3D. And the same methods are used to refine the tree mesh around electrodes, topography, etc... For 3D simulations however, we may need to consider limitations in computational resources which are not encountered in 2.5D simulations. Because 3D simulations require discretization along a third direction, the number of cells in the mesh is proportional ; where is the minimum cell edge length. Therefore the minimum cell size used for 3D simulations is generally coarser than the minimum cell size used for 2.5D simulations.
Tutorial mesh: Here, a minimum cell width of 25 m (or 1/4 the minimum electrode spacing) is used within our survey region. The largest electrode spacing was 1000 m, so a the padding was extended at least 3000 m from the survey region. Using the refine_surface method, we refine the tree mesh where there is significant topography. And using the refine_points methods, we refine based on electrodes locations. Visit the tree mesh API to see additional refinement methods.
# Defining domain size and minimum cell size
dh = 25.0 # base cell width
dom_width_x = 8000.0 # domain width x
dom_width_y = 8000.0 # domain width y
dom_width_z = 4000.0 # domain width z
# Number of base mesh cells in each direction. Must be a power of 2
nbcx = 2 ** int(np.round(np.log(dom_width_x / dh) / np.log(2.0))) # num. base cells x
nbcy = 2 ** int(np.round(np.log(dom_width_y / dh) / np.log(2.0))) # num. base cells y
nbcz = 2 ** int(np.round(np.log(dom_width_z / dh) / np.log(2.0))) # num. base cells z
# Define the base mesh
hx = [(dh, nbcx)]
hy = [(dh, nbcy)]
hz = [(dh, nbcz)]
mesh = TreeMesh([hx, hy, hz], x0="CCN", diagonal_balance=True)
# Shift top to maximum topography
mesh.origin = mesh.origin + np.r_[0.0, 0.0, z_topo.max()]
# Mesh refinement based on surface topography
k = np.sqrt(np.sum(topo_xyz[:, 0:2] ** 2, axis=1)) < 1200
mesh.refine_surface(topo_xyz[k, :], padding_cells_by_level=[0, 4, 4], finalize=False)
# Mesh refinement near electrodes.
mesh.refine_points(unique_locations, padding_cells_by_level=[6, 6, 4], finalize=False)
# Finalize the mesh
mesh.finalize()
Define the Active Cells¶
Simulated geophysical data are dependent on the subsurface distribution of physical property values. As a result, the cells lying below the surface topography are commonly referred to as ‘active cells’. And air cells, whose physical property values are fixed, are commonly referred to as ‘inactive cells’. Here, the discretize active_from_xyz utility function is used to find the indices of the active cells using the mesh and surface topography. The output quantity is a bool
array.
# Indices of the active mesh cells from topography (e.g. cells below surface)
active_cells = active_from_xyz(mesh, topo_xyz)
# number of active cells
n_active = np.sum(active_cells)
Models and Mappings¶
In SimPEG, the term ‘model’ is not necessarily synonymous with the set of physical property values defined on the mesh. For example, the model may be defined as the logarithms of the physical property values, or be the parameters defining a layered Earth geometry. Models in SimPEG are 1D numpy.ndarray whose lengths are equal to the number of model parameters.
Classes within the simpeg.maps module are used to define the mapping that connects the model to the physical property values used in the DC resistivity simulation. Sophisticated mappings can be defined by combining multiple mapping objects. But in the simplest case, the mapping is an identity map and the model consists of the conductivity/resistivity values for all mesh cells (including air).
When simulating DC resistivity data, we have the choice of using resistivity or conductivity to define the Earth’s electrical properties. Here, we define the model and its associate mapping for two cases:
- The model consists of the conductivity values for all active cells
- The model consists of the log-resistivity values for all active cells
Define the Model¶
The units for resistivity are and the units for conductivity are .
# Define conductivity values in S/m (take reciprocal for resistivities in Ohm m)
air_conductivity = 1e-8
background_conductivity = 1e-2
conductor_conductivity = 1e-1
resistor_conductivity = 1e-3
# Define conductivity model
conductivity_model = background_conductivity * np.ones(n_active)
ind_conductor = model_builder.get_indices_sphere(
np.r_[-300.0, 0.0, 100.0], 165.0, mesh.cell_centers[active_cells, :]
)
conductivity_model[ind_conductor] = conductor_conductivity
ind_resistor = model_builder.get_indices_sphere(
np.r_[300.0, 0.0, 100.0], 165.0, mesh.cell_centers[active_cells, :]
)
conductivity_model[ind_resistor] = resistor_conductivity
# Define log-resistivity model
log_resistivity_model = np.log(1 / conductivity_model)
Mapping from the Model to the Mesh¶
For our first case, we use the simpeg
For the second case, we use both the simpeg
# Conductivity map. Model parameters are conductivities for all active cells.
conductivity_map = maps.InjectActiveCells(mesh, active_cells, air_conductivity)
# Resistivity map. Model parameters are log-resistivities for all active cells.
log_resistivity_map = maps.InjectActiveCells(
mesh, active_cells, 1 / air_conductivity
) * maps.ExpMap(nP=n_active)
Plot the Model¶
To show the geometry of the problem, we plot the conductivity model using the plot_slice method.
# Generate a mapping to ignore inactice cells in plot
plotting_map = maps.InjectActiveCells(mesh, active_cells, np.nan)
fig = plt.figure(figsize=(10, 4.5))
norm = LogNorm(vmin=1e-3, vmax=1e-1)
ax1 = fig.add_axes([0.15, 0.15, 0.68, 0.75])
mesh.plot_slice(
plotting_map * conductivity_model,
ax=ax1,
normal="Y",
ind=int(len(mesh.h[1]) / 2),
grid=True,
pcolor_opts={"cmap": mpl.cm.RdYlBu_r, "norm": norm},
)
ax1.set_title("Conductivity Model")
ax1.set_xlabel("x (m)")
ax1.set_ylabel("z (m)")
ax1.set_xlim([-1500, 1500])
ax1.set_ylim([z_topo.max() - 1500, z_topo.max()])
ax2 = fig.add_axes([0.84, 0.15, 0.03, 0.75])
cbar = mpl.colorbar.ColorbarBase(
ax2, cmap=mpl.cm.RdYlBu_r, norm=norm, orientation="vertical"
)
cbar.set_label("$\sigma$ [S/m]", rotation=270, labelpad=15, size=16)
/t40array/ssoler/miniforge3/envs/simpeg-user-tutorials/lib/python3.10/site-packages/discretize/mixins/mpl_mod.py:2152: FutureWarning: In discretize v1.0 the TreeMesh will change the default value of diagonal_balance to True, which will likely slightly change meshes you havepreviously created. If you need to keep the current behavoir, explicitly set diagonal_balance=False.
temp_mesh = discretize.TreeMesh(h2d, x2d)
Project Electrodes to Discretized Topography¶
Surface DC resistivity data will not be modeled accurately if the electrodes are modeled as living above or below the surface. It is especially problematic when electrodes are modeled as living in the air. Prior to simulating surface DC resistivity data, we must project the electrodes from their true elevation to the surface of the discretized topography. This is done using the drape
survey.drape_electrodes_on_topography(mesh, active_cells, option="top")
Define the Forward Simulation¶
In SimPEG, the physics of the forward simulation is defined by creating an instance of an appropriate simulation class. There are two simulation classes which may be used to simulate 3D DC resistivity data:
- Simulation3DNodel, which defines the discrete electric potentials on mesh nodes.
- Simulation3DCellCentered, which defines the discrete electric potentials at cell centers.
For surface DC resistivity data, the nodal formulation is more well-suited and will be used here. The cell-centered formulation works well for simulating borehole DC resistivity data. Here, we define two simulation objects, one where the model defines the subsurface conductivities, and one where the model defines subsurface log-resistivities. When our model is used to define subsurface electric conductivity, the mapping is set using the sigmaMap
keyword argument. However when our model is used to define subsurface electric resistivity, the mapping must be set using the rhoMap
keyword argument
# DC simulation for a conductivity model
simulation_con = dc.simulation.Simulation3DNodal(
mesh, survey=survey, sigmaMap=conductivity_map
)
# DC simulation for a log-resistivity model
simulation_res = dc.simulation.Simulation3DNodal(
mesh, survey=survey, rhoMap=log_resistivity_map
)
Predict DC Resistivity Data¶
Once any simulation within SimPEG has been properly constructed, simulated data for a given model vector can be computed using the dpred method. Note that despite the difference in how we defined the models representing the Earth’s electrical properties, the data predicted by both simulations is equivalent.
dpred_con = simulation_con.dpred(conductivity_model)
/t40array/ssoler/miniforge3/envs/simpeg-user-tutorials/lib/python3.10/site-packages/simpeg/simulation.py:197: DefaultSolverWarning: Using the default solver: Pardiso.
If you would like to suppress this notification, add
warnings.filterwarnings('ignore', simpeg.utils.solver_utils.DefaultSolverWarning)
to your script.
return get_default_solver(warn=True)
dpred_res = simulation_res.dpred(log_resistivity_model)
print("MAX ABSOLUTE ERROR = {}".format(np.max(np.abs(dpred_con - dpred_res))))
MAX ABSOLUTE ERROR = 4.440892098500626e-16
Convert Normalized Voltages to Apparent Conductivities¶
In this tutorial, the receivers were defined to simulation normalized voltage data. Here, we use the apparent
apparent_conductivity = apparent_resistivity_from_voltage(survey, dpred_con) ** -1
Plot Data in Pseudosection¶
Plot 3D Pseudosection¶
For general 3D survey configurations, we can use the plot
# Empty list for plane points
plane_points = []
# 3-points defining the plane for EW survey line
p1, p2, p3 = np.array([-1000, 0, 0]), np.array([1000, 0, 0]), np.array([0, 0, -1000])
plane_points.append([p1, p2, p3])
# NS at x = -300 m
p1, p2, p3 = (
np.array([-300, -1000, 0]),
np.array([-300, 1000, 0]),
np.array([-300, 0, -1000]),
)
plane_points.append([p1, p2, p3])
# NS at x = 300 m
p1, p2, p3 = (
np.array([300, -1000, 0]),
np.array([300, 1000, 0]),
np.array([300, 0, -1000]),
)
plane_points.append([p1, p2, p3])
if has_plotly:
fig = plot_3d_pseudosection(
survey,
apparent_conductivity,
scale="log",
units="S/m",
plane_points=plane_points,
plane_distance=15,
marker_opts={"colorscale": "RdYlBu_r"},
)
fig.update_layout(
title_text="Apparent Conductivity",
title_x=0.5,
title_font_size=24,
width=650,
height=500,
scene_camera=dict(center=dict(x=0.05, y=0, z=-0.4)),
)
# plotly.io.show(fig)
html_str = plotly.io.to_html(fig)
display(HTML(html_str))
else:
print("INSTALL 'PLOTLY' TO VISUALIZE 3D PSEUDOSECTIONS")
INSTALL 'PLOTLY' TO VISUALIZE 3D PSEUDOSECTIONS
Plot Individual Lines in 2D Pseudosection¶
For conventional DC resistivity surveys, the electrodes are located along a set of survey lines. If we know which the survey line associated with each datum, we can parse the 3D survey into a set of 2D survey lines. Then we can plot individual pseudosections for each survey line. Here, we have 6 survey lines, each of which has the same number of data. So assigning a line ID to each datum is easy. You may need to do something more sophisticated in other cases.
# line IDs
n_lines = len(end_locations_list)
n_data_per_line = int(survey.nD / n_lines)
lineID = np.hstack([(ii + 1) * np.ones(n_data_per_line) for ii in range(n_lines)])
Here, we use the convert
survey_2d_list, index_list = convert_survey_3d_to_2d_lines(
survey, lineID, data_type="volt", output_indexing=True
)
Next, we create list of 2D apparent conductivities. Note that if you converted observed data then computed apparent conductivities, you would be doing so assuming 2D survey geometry and the values would not match those on the 3D pseudosection plot!
dobs_2d_list = []
apparent_conductivities_2d = []
for ind in index_list:
dobs_2d_list.append(dpred_con[ind])
apparent_conductivities_2d.append(apparent_conductivity[ind])
Now we can use the plot
# Line index
line_ind = 0
# Plot apparent conductivity pseudo-section
fig = plt.figure(figsize=(8, 2.75))
ax1 = fig.add_axes([0.1, 0.15, 0.75, 0.78])
plot_pseudosection(
survey_2d_list[line_ind],
dobs=apparent_conductivities_2d[line_ind],
plot_type="contourf",
ax=ax1,
scale="log",
cbar_label="S/m",
mask_topography=True,
contourf_opts={"levels": 20, "cmap": mpl.cm.RdYlBu_r},
)
ax1.set_title("Apparent Conductivity")
plt.show()
Optional: Write DC resistivity data and topography.
if write_output:
dir_path = os.path.sep.join([".", "fwd_dcr_3d_outputs"]) + os.path.sep
if not os.path.exists(dir_path):
os.mkdir(dir_path)
# Add 10% Gaussian noise to each datum
rng = np.random.default_rng(seed=433)
std = 0.1 * np.abs(dpred_con)
noise = rng.normal(scale=std, size=len(dpred_con))
dobs = dpred_con + noise
# Create dictionary that stores line IDs
out_dict = {"LINEID": lineID}
# Create a survey with the original electrode locations
# and not the shifted ones
source_list = []
for ii in range(n_lines):
source_list += generate_dcip_sources_line(
survey_type,
data_type,
dimension_type,
end_locations_list[ii],
topo_xyz,
num_rx_per_src,
station_separation,
)
survey_original = dc.survey.Survey(source_list)
# Write out data at their original electrode locations (not shifted)
data_obj = data.Data(survey_original, dobs=dobs, standard_deviation=std)
fname = dir_path + "dc_data.xyz"
write_dcip_xyz(
fname,
data_obj,
data_header="V/A",
uncertainties_header="UNCERT",
out_dict=out_dict,
)
fname = dir_path + "topo_xyz.txt"
np.savetxt(fname, topo_xyz, fmt="%.4e")