Differentiable 2.5D ERT and tetrahedral 3D DC forward tooling on Torch.
The repository contains:
adtlert/: the core numerical implementation.example/single_time/: ADTLERT/pyGIMLi forward examples and ADTLERT/pyGIMLi/ResIPy single-time inversion examples.example/window_363/: ADTLERT/pyGIMLi/ResIPy inversion examples for 365 time steps using 3-step sliding windows (363 windows).parflow_models/andresistivity_models_2d/: input data used by the examples.
See example/README.md for commands and backend
requirements.
Install the CPU/SciPy build from PyPI:
python -m pip install adtlertCUDA 12 acceleration through NVIDIA cuDSS is an optional Linux extra:
python -m pip install "adtlert[cuda12]"The CUDA driver must support CUDA 12, and only one CuPy CUDA variant may be installed in an environment. CPU-only installations do not install CuPy, cuDSS, or nvmath-python.
For development from a checkout, use uv. The terrain examples read ParFlow
PFB files and build Triangle inversion meshes:
uv sync --extra cuda12 --extra examplesimport torch
from adtlert.forward import ERTForward2p5D
forward = ERTForward2p5D.from_mesh_survey(mesh, survey)
conductivity = torch.as_tensor(1.0 / resistivity, dtype=torch.float32)
response = forward.solve(conductivity)
rhoa = response.apparent_resistivity
jacobian = forward.jacobian(conductivity)For matrix-free differentiation, use the PyTorch autograd bridge. Its reverse
pass calls forward.vjp(...), while PyTorch forward AD calls
forward.jvp(...); no explicit Jacobian is materialized:
from adtlert.forward import apparent_resistivity_autograd
conductivity = conductivity.requires_grad_()
rhoa = apparent_resistivity_autograd(conductivity, forward, currents=1.0)
loss = data_misfit(rhoa, observed_rhoa)
loss.backward()
conductivity_gradient = conductivity.gradTrue 3D DC forward modelling uses a tetrahedral mesh and three-coordinate electrodes. It follows pyGIMLi's 3D singularity-removal formulation, with an analytic half-space primary field and a mixed far-field boundary:
from adtlert.forward import ERTForward3D
from adtlert.mesh import Mesh3D
from adtlert.survey import Survey
mesh3d = Mesh3D.from_file("mesh3d.vtu")
survey3d = Survey.from_arrays(electrode_xyz, abmn)
forward3d = ERTForward3D.from_mesh_survey(mesh3d, survey3d)
response3d, jacobian3d = forward3d.solve_with_jacobian(conductivity3d)For topography, use quadratic tetrahedra and numerical geometric factors. The
surface is inferred from upward-facing exterior triangles; pass
surface_face_mask= to Mesh3D.from_arrays when boundary classification must
be explicit:
forward3d = ERTForward3D.from_mesh_survey(
mesh3d,
survey3d,
element_order=2,
geometric_factor_mode="auto", # numerical for terrain, analytic when flat
linear_solver_backend="cudss", # or "scipy"; "auto" selects an available GPU
)For large explicit sensitivities, jacobian(..., batch_size=32, cell_batch_size=20000) bounds temporary field memory. Assembly routing,
active AB/MN electrode sets, numerical geometric factors, sparse plans, and
fixed GPU right-hand sides are cached across inversion iterations.
The initial 3D implementation supports flat-surface half-space models, four-node tetrahedral geometry, P1 or ten-node P2 basis functions, SciPy or NVIDIA cuDSS sparse factorization, analytic or terrain-aware numerical geometric factors, exact adjoint cell sensitivities, first-order face-neighbor regularization, and the existing single-time inversion API. Large-scale matrix-free time-lapse inversion remains on the 3D roadmap.
ERTForwardModeling provides a small wrapper for mesh/data-like objects:
from adtlert.forward import ERTForwardModeling
modeling = ERTForwardModeling(mesh=mesh, data=scheme)
log_rhoa, jac = modeling.forward_and_jacobian(log_resistivity, log_transform=True)import numpy as np
from adtlert.workflows import (
build_terrain_forward_case,
discover_resistivity_slices,
parse_pftcl,
read_slope_x,
run_terrain_forward,
run_terrain_forward_series,
save_terrain_forward_dat,
save_terrain_forward_npz,
)
grid = parse_pftcl("parflow_models/sc2d_6.out.pftcl")
slope_x = read_slope_x("parflow_models/sc2d_6.out.slope_x.pfb", y_index=2)
rho_2d = np.load("resistivity_models_2d/resistivity2d_y2_t04536.npy")
case = build_terrain_forward_case(rho_2d, grid, slope_x, y_index=2)
rhoa = run_terrain_forward(case)
save_terrain_forward_dat("synthetic_ert_terrain_vardz_t04536.dat", case, rhoa)
save_terrain_forward_npz("synthetic_ert_terrain_vardz_t04536.npz", case, rhoa)
pairs = discover_resistivity_slices("resistivity_models_2d", y_index=2)
manifest, failures = run_terrain_forward_series(
pairs,
grid,
slope_x,
"result/timelapsedERT_forward",
y_index=2,
)import numpy as np
from adtlert.inversion import ERTInversion, InversionConfig
config = InversionConfig(
max_iterations=8,
data_std=0.05,
regularization=1.0e-2,
spatial_regularization="first_order",
model_bounds=(10.0, 20000.0),
)
initial_model = np.full(forward.mesh.cell_count, np.median(observed_rhoa))
result = ERTInversion(
forward=forward,
observed_data=observed_rhoa,
config=config,
).setup().run(initial_model)
final_model = result.final_model
predicted_rhoa = result.predicted_data
coverage = result.coverage
chi2_history = result.iteration_chi2Windowed time-lapse inversion:
from adtlert.inversion import WindowedTimeLapseERTInversion
config = InversionConfig(
max_iterations=6,
data_std=np.log1p(relative_error_by_time),
regularization=50.0,
temporal_regularization=10.0,
temporal_regularization_mode="separate",
spatial_regularization="first_order",
model_bounds=(0.001, 1.0e4),
max_log_step=None,
line_search=True,
)
result = WindowedTimeLapseERTInversion(
forward=forward,
observed_data=observed_rhoa_by_time,
config=config,
window_size=3,
window_step=1,
).setup().run(initial_model)
final_models = result.final_modelsuv run pytest
uv run python -m compileall adtlert
uv build