Note
Go to the end to download the full example code
Ex. Hydrology to Multi-Geophysics Responses (Single Snapshot, 2D Profile)#
This example uses real hydrological model outputs from examples/data and
builds one 2D profile (single snapshot). All geophysical methods are then
simulated on the same profile:
Extract one 2D hydro profile from MODFLOW outputs.
Build one 2D mesh and interpolate hydrologic properties.
Simulate ERT and SRT using
hydro_to_ertandhydro_to_srt.Simulate pseudo-2D TDEM, FDEM, and gravity using
hydro_to_tdem,hydro_to_fdem, andhydro_to_gravity.
## Step 1: Imports
import os
import sys
import numpy as np
import matplotlib.pyplot as plt
import pygimli as pg
from pygimli.physics import ert
import pygimli.physics.traveltime as tt
from scipy.interpolate import griddata
# Setup package path for development
try:
current_dir = os.path.dirname(os.path.abspath(__file__))
except NameError:
current_dir = os.getcwd()
parent_dir = os.path.dirname(current_dir)
if parent_dir not in sys.path:
sys.path.append(parent_dir)
from PyHydroGeophysX.core.interpolation import ProfileInterpolator, create_surface_lines
from PyHydroGeophysX.core.mesh_utils import MeshCreator
from PyHydroGeophysX.Hydro_modular import (
hydro_to_ert,
hydro_to_srt,
hydro_to_tdem,
hydro_to_fdem,
hydro_to_gravity,
)
output_dir = os.path.join(current_dir, "results", "hydro_to_multigeophys")
os.makedirs(output_dir, exist_ok=True)
rng_seed = 7
## Step 2: Helper Functions
def fill_profile_nans(values):
"""Fill NaNs along profile direction for each layer."""
arr = np.asarray(values, dtype=float).copy()
if arr.ndim != 2:
raise ValueError(f"Expected 2D array, got shape {arr.shape}.")
x = np.arange(arr.shape[1], dtype=float)
for i in range(arr.shape[0]):
row = arr[i, :]
valid = np.isfinite(row)
if np.any(valid):
if np.count_nonzero(valid) == 1:
row[~valid] = row[valid][0]
else:
row[~valid] = np.interp(x[~valid], x[valid], row[valid])
else:
raise RuntimeError("Profile interpolation failed: one layer is all NaN.")
arr[i, :] = row
return arr
def get_mesh_xy(mesh):
"""Return mesh cell-center x/y arrays."""
centers = np.asarray(mesh.cellCenters(), dtype=float)
if centers.ndim == 2 and centers.shape[1] >= 2:
return centers[:, 0], centers[:, 1]
x = np.array([float(c[0]) for c in mesh.cellCenters()], dtype=float)
y = np.array([float(c[1]) for c in mesh.cellCenters()], dtype=float)
return x, y
def assign_three_layer_markers(mesh, line1, line2, top_marker=0, mid_marker=3, bot_marker=2):
"""Assign top/middle/bottom markers from two interface lines."""
x_cell, y_cell = get_mesh_xy(mesh)
y_line1 = np.interp(x_cell, line1[:, 0], line1[:, 1])
y_line2 = np.interp(x_cell, line2[:, 0], line2[:, 1])
markers = np.full(mesh.cellCount(), bot_marker, dtype=int)
markers[y_cell >= y_line2] = mid_marker
markers[y_cell >= y_line1] = top_marker
mesh.setCellMarkers(markers)
return markers
def interpolate_profile_to_mesh(profile_values, layer_boundaries, x_profile, mesh):
"""Interpolate profile matrix (layers x distance) to mesh cells."""
values = np.asarray(profile_values, dtype=float)
bounds = np.asarray(layer_boundaries, dtype=float)
n_layers, n_profile = values.shape
if bounds.shape != (n_layers + 1, n_profile):
raise ValueError(
f"layer_boundaries shape must be {(n_layers + 1, n_profile)}, got {bounds.shape}."
)
layer_centers = 0.5 * (bounds[:-1, :] + bounds[1:, :])
x2d = np.repeat(np.asarray(x_profile, dtype=float)[np.newaxis, :], n_layers, axis=0)
points = np.column_stack((x2d.ravel(), layer_centers.ravel()))
vals = values.ravel()
x_cell, y_cell = get_mesh_xy(mesh)
query = np.column_stack((x_cell, y_cell))
interp_linear = griddata(points, vals, query, method="linear")
interp_nearest = griddata(points, vals, query, method="nearest")
out = np.asarray(interp_linear, dtype=float)
nan_mask = ~np.isfinite(out)
out[nan_mask] = interp_nearest[nan_mask]
return out
def relative_l2(noisy, clean):
noisy_arr = np.asarray(noisy)
clean_arr = np.asarray(clean)
denom = np.linalg.norm(clean_arr)
if denom <= 0:
return np.nan
return float(np.linalg.norm(noisy_arr - clean_arr) / denom)
## Step 3: Load Real Model Outputs and Build One 2D Profile
data_dir = os.path.join(current_dir, "data")
water_content_4d = np.load(os.path.join(data_dir, "Watercontent.npy"))
porosity_3d = np.load(os.path.join(data_dir, "Porosity.npy"))
top = np.loadtxt(os.path.join(data_dir, "top.txt"))
bot = np.load(os.path.join(data_dir, "bot.npy"))
snapshot_index = 5
water_content_3d = np.asarray(water_content_4d[snapshot_index], dtype=float)
point1 = [115, 70]
point2 = [95, 180]
interpolator = ProfileInterpolator(
point1=point1,
point2=point2,
surface_data=top,
origin_x=0.0,
origin_y=0.0,
pixel_width=1.0,
pixel_height=-1.0,
num_points=220,
)
structure = interpolator.interpolate_layer_data([top] + [bot[i] for i in range(bot.shape[0])])
water_content_profile = interpolator.interpolate_3d_data(water_content_3d)
porosity_profile = interpolator.interpolate_3d_data(porosity_3d)
structure = fill_profile_nans(structure)
water_content_profile = np.clip(fill_profile_nans(water_content_profile), 0.0, 0.8)
porosity_profile = np.clip(fill_profile_nans(porosity_profile), 0.01, 0.6)
L_profile = np.asarray(interpolator.L_profile, dtype=float)
n_layers, n_profile = water_content_profile.shape
n_bounds = structure.shape[0]
mid_idx = max(1, min(4, n_bounds // 3))
bot_idx = max(mid_idx + 1, min(12, n_bounds - 2))
surface, line1, line2 = create_surface_lines(
L_profile=L_profile,
structure=structure,
top_idx=0,
mid_idx=mid_idx,
bot_idx=bot_idx,
)
mesh_creator = MeshCreator(quality=32, area=1.0)
mesh, _ = mesh_creator.create_from_layers(
surface=surface,
layers=[line1, line2],
bottom_depth=float(np.min(line2[:, 1]) - 10.0),
)
mesh_markers = assign_three_layer_markers(mesh, line1, line2, top_marker=0, mid_marker=3, bot_marker=2)
wc_mesh = interpolate_profile_to_mesh(water_content_profile, structure, L_profile, mesh)
porosity_mesh = interpolate_profile_to_mesh(porosity_profile, structure, L_profile, mesh)
print(f"water_content_3d shape: {water_content_3d.shape}")
print(f"porosity_3d shape: {porosity_3d.shape}")
print(f"Profile points: {n_profile}")
print(f"Mesh cells: {mesh.cellCount()}")
print(f"Water content range: {np.nanmin(water_content_profile):.4f} - {np.nanmax(water_content_profile):.4f}")
print(f"Porosity range: {np.nanmin(porosity_profile):.4f} - {np.nanmax(porosity_profile):.4f}")
layer_centers = 0.5 * (structure[:-1, :] + structure[1:, :])
X2D = np.repeat(L_profile[np.newaxis, :], n_layers, axis=0)
fig, ax = plt.subplots(figsize=(10, 4.5))
cf = ax.contourf(X2D, layer_centers, water_content_profile, levels=25, cmap="YlGnBu")
ax.plot(L_profile, structure[0, :], "k-", lw=1.2)
ax.set_title("Hydrologic 2D profile (water content)")
ax.set_xlabel("Distance along profile (m)")
ax.set_ylabel("Elevation (m)")
cb = plt.colorbar(cf, ax=ax)
cb.set_label("Water content (-)")
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "Ex_hydro_to_multigeophys_fig_01.png"), dpi=220, bbox_inches="tight")
plt.show()
## Step 4: ERT and SRT from the Same 2D Profile (hydro_to_ert, hydro_to_srt)
rho_parameters = {
"rho_sat": [100.0, 500.0, 2400.0],
"n": [2.2, 1.8, 2.5],
"sigma_s": [1.0 / 500.0, 0.0, 0.0],
}
vel_parameters = {
"top": {
"bulk_modulus": 30.0,
"shear_modulus": 20.0,
"mineral_density": 2650,
"depth": 1.0,
},
"mid": {
"bulk_modulus": 50.0,
"shear_modulus": 35.0,
"mineral_density": 2670,
"aspect_ratio": 0.05,
},
"bot": {
"bulk_modulus": 55.0,
"shear_modulus": 50.0,
"mineral_density": 2680,
"aspect_ratio": 0.03,
},
}
layer_markers = [0, 3, 2]
srt_data, velocity_model = hydro_to_srt(
water_content=wc_mesh,
porosity=porosity_mesh,
mesh=mesh,
profile_interpolator=interpolator,
layer_idx=[0, mid_idx, bot_idx],
structure=structure,
marker_labels=layer_markers,
vel_parameters=vel_parameters,
sensor_spacing=1.0,
sensor_start=15.0,
num_sensors=72,
shot_distance=5,
noise_level=0.01,
noise_abs=1e-5,
mesh_markers=mesh_markers,
verbose=False,
seed=rng_seed,
)
ert_data, resistivity_model = hydro_to_ert(
water_content=wc_mesh,
porosity=porosity_mesh,
mesh=mesh,
profile_interpolator=interpolator,
layer_idx=[0, mid_idx, bot_idx],
structure=structure,
marker_labels=layer_markers,
rho_parameters=rho_parameters,
electrode_spacing=1.0,
electrode_start=15.0,
num_electrodes=72,
scheme_name="wa",
noise_level=0.03,
abs_error=0.0,
rel_error=0.03,
mesh_markers=mesh_markers,
verbose=False,
seed=rng_seed,
)
print(f"ERT data count: {ert_data.size()}")
print(f"SRT data count: {srt_data.size()}")
fig = plt.figure(figsize=(14, 8))
ax1 = fig.add_subplot(2, 2, 1)
pg.show(
mesh,
resistivity_model,
ax=ax1,
cMap="Spectral_r",
label="Resistivity (ohm m)",
)
ax1.set_title("2D resistivity model (hydro_to_ert)")
ax2 = fig.add_subplot(2, 2, 2)
pg.show(
mesh,
velocity_model,
ax=ax2,
cMap="turbo",
label="Velocity (m/s)",
)
ax2.set_title("2D velocity model (hydro_to_srt)")
ax3 = fig.add_subplot(2, 2, 3)
ert.show(ert_data, ax=ax3)
ax3.set_title("Synthetic ERT response")
ax4 = fig.add_subplot(2, 2, 4)
tt.drawFirstPicks(ax4, srt_data)
ax4.set_title("Synthetic SRT first arrivals")
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "ert_srt_2d_profile.png"), dpi=220, bbox_inches="tight")
plt.show()
## Step 5: Pseudo-2D TDEM / FDEM / Gravity from the Same Profile
step = max(1, n_profile // 24)
station_idx = np.unique(np.r_[np.arange(0, n_profile, step), n_profile - 1])
x_station = L_profile[station_idx]
wc_station = water_content_profile[:, station_idx]
por_station = porosity_profile[:, station_idx]
structure_station = structure[:, station_idx]
times = np.logspace(-5, -2, 28)
frequencies = np.logspace(1, 4, 18)
tdem_noisy, tdem_clean, tdem_unc, tdem_cond = hydro_to_tdem(
water_content=wc_station,
porosity=por_station,
layer_boundaries=structure_station,
times=times,
sigma_w=0.05,
m=1.5,
n=2.0,
sigma_s=0.0,
source_radius=10.0,
noise_level=0.03,
seed=rng_seed,
verbose=False,
)
fdem_noisy, fdem_clean, fdem_unc, fdem_cond = hydro_to_fdem(
water_content=wc_station,
porosity=por_station,
layer_boundaries=structure_station,
frequencies=frequencies,
sigma_w=0.05,
m=1.5,
n=2.0,
sigma_s=0.0,
source_location=np.array([0.0, 0.0, 0.0]),
receiver_location=np.array([12.0, 0.0, 0.0]),
receiver_component="secondary",
waveform_type="dipole",
noise_level=0.03,
seed=rng_seed,
verbose=False,
)
grav_noisy, grav_clean, grav_unc, density_contrast = hydro_to_gravity(
water_content=wc_station,
porosity=por_station,
layer_boundaries=structure_station,
station_positions=x_station,
rho_matrix=2650.0,
rho_water=1000.0,
rho_air=1.225,
sensor_height=1.0,
noise_level=0.02,
seed=rng_seed,
verbose=False,
)
print(f"TDEM matrix shape: {tdem_clean.shape}, relative L2 noise = {relative_l2(tdem_noisy, tdem_clean):.4f}")
print(f"FDEM matrix shape: {fdem_clean.shape}, relative L2 noise = {relative_l2(fdem_noisy, fdem_clean):.4f}")
print(f"Gravity range (mGal): {np.min(grav_clean):.5f} to {np.max(grav_clean):.5f}")
fig, axes = plt.subplots(1, 3, figsize=(16, 4.8))
im0 = axes[0].pcolormesh(
x_station,
times,
np.abs(tdem_clean).T,
shading="auto",
cmap="magma",
)
axes[0].set_yscale("log")
axes[0].set_xlabel("Distance along profile (m)")
axes[0].set_ylabel("Time (s)")
axes[0].set_title("Pseudo-2D TDEM |response|")
cb0 = plt.colorbar(im0, ax=axes[0])
cb0.set_label("|dB/dt| (arb.)")
im1 = axes[1].pcolormesh(
x_station,
frequencies,
np.abs(np.imag(fdem_clean)).T,
shading="auto",
cmap="viridis",
)
axes[1].set_yscale("log")
axes[1].set_xlabel("Distance along profile (m)")
axes[1].set_ylabel("Frequency (Hz)")
axes[1].set_title("Pseudo-2D FDEM |imag|")
cb1 = plt.colorbar(im1, ax=axes[1])
cb1.set_label("|H_imag| (arb.)")
axes[2].plot(x_station, grav_clean, "k-", lw=1.8, label="Gravity clean")
axes[2].plot(x_station, grav_noisy, "o", ms=3.8, alpha=0.75, label="Gravity noisy")
axes[2].set_xlabel("Distance along profile (m)")
axes[2].set_ylabel("Gravity anomaly (mGal)")
axes[2].set_title("Pseudo-2D gravity profile")
axes[2].grid(True, alpha=0.25)
axes[2].legend(loc="best")
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "em_gravity_2d_profile.png"), dpi=220, bbox_inches="tight")
plt.show()
Summary: This workflow uses one real MODFLOW snapshot and one 2D extracted profile. ERT and SRT are simulated directly from the same 2D profile. TDEM, FDEM, and gravity are simulated along the same profile in a pseudo-2D representation.
Total running time of the script: (0 minutes 0.000 seconds)