16.4. Solvers And Grids#
The forward package separates three responsibilities:
model containersObjects such as
pycsamt.forward.LayeredModel,pycsamt.forward.Grid2D, andpycsamt.forward.Grid3Dstore resistivity, geometry, station positions, and metadata.solversObjects such as
pycsamt.forward.MT1DForward,pycsamt.forward.TEM1DForward,pycsamt.forward.MT2DForward, andpycsamt.forward.MT3DForwardcompute synthetic responses from the model containers.response containersObjects such as
pycsamt.forward.ForwardResponse,pycsamt.forward.ForwardResponse2D, andpycsamt.forward.ForwardResponse3Dhold the predicted fields, apparent resistivities, phases, time-domain decays, station coordinates, and feature-array helpers.
This separation matters because survey design, synthetic dataset generation, machine-learning workflows, and inversion handoff all need the same response arrays but may use different model containers and different solver settings. The practical contract is:
where \(\mathbf{m}\) is the earth model, \(\mathbf{a}\) is the sampled axis such as frequency or time, \(\mathbf{s}\) is the survey geometry, and \(\mathbf{d}\) is the predicted forward response. pyCSAMT keeps these pieces separate so the same model can be plotted, solved, noised, converted to features, or handed to an inversion workflow without quietly changing the physical inputs.
16.4.1. Solver Map#
The currently documented forward solver families are:
Solver |
Model container |
Primary axis |
Main outputs |
|---|---|---|---|
|
|
Frequencies in Hz |
Impedance |
|
|
Frequencies in Hz |
MT-like response with optional controlled-source correction. |
|
|
Time gates in seconds |
Step-off |
|
|
Frequencies in Hz |
TE/TM impedances, apparent resistivities, phases at profile stations. |
|
|
Frequencies in Hz |
Approximate tensor components on a station grid. |
The forward solvers are deterministic for a fixed model, axis, and solver configuration. Noise models are applied later, which keeps the physical response and the observation model separate.
16.4.2. 1-D Layered Models#
pycsamt.forward.LayeredModel represents a stack of horizontal layers.
The final resistivity is the halfspace, so the thickness array has
one fewer entry than resistivity. If there are \(L\) layers, pyCSAMT
stores
with top-of-layer depths
The last layer \(\rho_{L-1}\) extends downward indefinitely. This is why a three-layer model has three resistivities but only two thicknesses.
1>>> import numpy as np
2
3>>> from pycsamt.forward import LayeredModel, MT1DForward
4
5>>> model = LayeredModel(
6... resistivity=[100.0, 10.0, 500.0],
7... thickness=[300.0, 800.0],
8... name="conductive_middle_layer",
9... )
10
11>>> freqs = np.logspace(-3, 4, 40)
12>>> response = MT1DForward(freqs=freqs).run(model)
13
14>>> print(response.rho_a.shape)
15(40,)
16>>> print(response.phase.shape)
17(40,)
18>>> print(model.depth)
19[ 0. 300. 1100.]
20>>> print(model.to_vector())
21[ 2. 1. 2.69897 300. 800. ]
Layered models are useful for:
quick sanity checks against textbook MT behaviour;
training small synthetic catalogues;
building starting models for inversion;
extending a 1-D model laterally into a simple 2-D grid;
testing whether the chosen frequency or time range can sense the expected target depth.
The important depth scale for frequency-domain EM is the skin depth. A common engineering estimate is:
where \(\delta\) is in metres, \(\rho\) is resistivity in \(\Omega\,m\), and \(f\) is frequency in Hz. This is not a substitute for a forward model, but it is a useful first check: high frequencies see shallower structure, while low frequencies sample deeper structure.
16.4.3. 1-D MT And CSAMT Solvers#
MT1DForward computes a plane-wave field natural-source response
using layered-earth impedance recursion. CSAMT1DForward uses the same
layered-earth base and can apply a controlled-source correction when
source geometry is supplied.
For one angular frequency \(\omega = 2\pi f\), the implementation starts from the bottom halfspace and recursively moves upward. In layer \(j\),
where \(k_j\) is the complex propagation constant and \(Z^0_j\) is the intrinsic layer impedance. If \(Z_{j+1}\) is the effective impedance below layer \(j\), then the upward recursion used by the code is
At the surface, \(Z_0\) becomes the predicted impedance. Apparent resistivity and phase are then derived consistently as
When CSAMT1DForward receives source_offset, it applies a first-order
near-field factor based on the first-layer skin depth
\(\delta=\sqrt{2\rho_0/(\omega\mu_0)}\):
As \(r/\delta\) becomes large, the correction approaches one and the CSAMT response approaches the MT plane-wave response.
1>>> import numpy as np
2
3>>> from pycsamt.forward import CSAMT1DForward, LayeredModel, MT1DForward
4
5>>> model = LayeredModel(
6... resistivity=[80.0, 25.0, 600.0],
7... thickness=[250.0, 900.0],
8... )
9
10>>> freqs = np.logspace(-1, 4, 32)
11
12>>> mt_response = MT1DForward(freqs=freqs).run(model)
13
14>>> csamt_response = CSAMT1DForward(
15... freqs=freqs,
16... source_offset=5000.0,
17... dipole_length=1000.0,
18... ).run(model)
19
20>>> mt_features = mt_response.to_array(log_rho=True, include_phase=True)
21>>> csamt_features = csamt_response.to_array(log_rho=True, include_phase=True)
22
23>>> print(mt_response.z.shape, mt_response.rho_a.shape, mt_response.phase.shape)
24(32,) (32,) (32,)
25>>> print(mt_features.shape)
26(64,)
27>>> print(csamt_features.shape)
28(64,)
29>>> print(csamt_response.rho_a[0] / mt_response.rho_a[0])
300.1098213847369481
31>>> print(csamt_response.rho_a[-1] / mt_response.rho_a[-1])
320.9999189496227823
The CSAMT correction is strongest where \(r/\delta\) is small. At frequencies where the source is effectively far field, the corrected CSAMT curve returns to the MT-like response.#
For MT and CSAMT 1-D responses:
response.freqshas shape(n_freqs,);response.zhas shape(n_freqs,)and is complex;response.rho_ahas shape(n_freqs,);response.phasehas shape(n_freqs,)in degrees;response.to_array()returns a one-dimensional feature vector.
When the model is one-dimensional, every surface station would see the same response. Use 2-D or 3-D grids when lateral geometry is part of the experiment.
16.4.4. 1-D TDEM Solver#
TEM1DForward computes a central-loop step-off response for a
layered earth. It uses time gates rather than frequencies as
the primary output axis, but the underlying physics is still built
frequency-first: for horizontal wavenumber \(\lambda\), the TE admittance
recursion uses
then integrates the reflected field with a Hankel-type kernel involving \(J_1(\lambda a)\), where \(a\) is the loop radius. The step-off decay is obtained with a cosine transform:
pyCSAMT delegates that Hankel-then-Fourier evaluation to empymod’s
digital linear filters (Werthmüller, 2017) rather than a hand-rolled
quadrature – an earlier from-scratch attempt at this exact integral did not
converge reliably at realistic time ranges (its own frequency-domain kernel
grew with frequency instead of decaying), which is a large part of why
validated, peer-reviewed EM-transform libraries are worth depending on rather
than re-deriving. The loop itself is represented internally as a small
tangential electric-dipole segment at the loop radius, scaled by the loop’s
circumference – exact by the axisymmetry of a horizontal circular loop, and
the same construction empymod’s own gallery uses to reproduce Ward &
Hohmann (1988)’s central-loop figures. TEM examples are still usually a
little slower than MT1D ones simply because a full digital-filter transform
does more work per time gate than a closed-form impedance recursion.
1>>> import numpy as np
2
3>>> from pycsamt.forward import LayeredModel, TEM1DForward
4
5>>> model = LayeredModel(
6... resistivity=[60.0, 250.0, 900.0],
7... thickness=[120.0, 700.0],
8... )
9
10>>> times = np.logspace(-6, -3, 30)
11
12>>> response = TEM1DForward(times=times, loop_radius=50.0).run(model)
13
14>>> print(response.times.shape)
15(30,)
16>>> print(response.dBz_dt.shape)
17(30,)
18>>> print(response.to_array().shape)
19(30,)
20>>> print(np.all(response.dBz_dt > 0))
21True
For TDEM responses:
response.timeshas shape(n_times,);response.dBz_dthas shape(n_times,);response.to_array()returns a log-scaled decay feature vector by default.
A clean step-off response should stay single-signed and decay smoothly across the whole gate range – a real, physical sanity check worth plotting rather than only checking shapes:
1>>> import matplotlib.pyplot as plt
2
3>>> fig, ax = plt.subplots(figsize=(7, 5))
4>>> _ = ax.loglog(times * 1e3, response.dBz_dt, "o-", ms=4, color="#1f77b4", label="dBz/dt")
5
6>>> t_ref = times[-6:]
7>>> ref = response.dBz_dt[-6] * (t_ref / times[-6]) ** (-2.5)
8>>> _ = ax.loglog(t_ref * 1e3, ref, "--", color="0.4", lw=1.3, label=r"$t^{-5/2}$ reference")
9
10>>> _ = ax.set_xlabel("time (ms)")
11>>> _ = ax.set_ylabel(r"$dB_z/dt$ (arb. units)")
12>>> _ = ax.set_title("TEM1DForward step-off decay -- 3-layer model")
13>>> _ = ax.legend()
14>>> _ = ax.grid(True, which="both", alpha=0.3)
The dashed line is the classic conductive-halfspace late-time asymptote \(dB_z/dt \propto t^{-5/2}\) (Nabighian, 1979), anchored to the curve’s last point for comparison, not fitted to it.#
The curve’s own late-time slope (a least-squares fit through the last third of the gates, in log-log space) comes out to about \(-2.70\) – close to but steeper than the ideal halfspace value of \(-2.5\), because the deep, resistive third layer (900 Ω·m below 820 m) is still shaping the decay rather than the response having settled into the true asymptotic regime. Early gates, by contrast, have a much shallower slope (about \(-0.30\) over the first eight points): the induced-current “smoke ring” has barely started diffusing outward and downward, so the field is still dominated by the near loop geometry rather than the earth’s conductivity structure. Use early gates to test shallow sensitivity and later gates to test deeper sensitivity; if the time range is too narrow, the inversion may fit the decay curve but remain insensitive to the target interval.
16.4.5. 2-D Profile Grid Concepts#
pycsamt.forward.Grid2D stores a finite-difference grid for a
profile. It contains:
horizontal cell widths
dx;vertical cell heights
dz;cell resistivity matrix
resistivitywith shape(nz, nx);surface station x-positions
x_stations;padding count
n_pad;node and cell-centre coordinate helpers.
The grid uses depth-positive z coordinates. Resistivity is stored
top-to-bottom and left-to-right. Stations must lie inside the grid extent. If
n_pad is non-zero, the constructor arguments nx and nz describe the
core model; the stored arrays are larger because padding cells are
added on the left, right, and bottom. Thus a core grid with
\(n_x^\mathrm{core}\) columns, \(n_z^\mathrm{core}\) rows, and
\(p\) padding cells is stored approximately as
The exact physical width also grows because padded cell sizes are expanded by
pad_factor.
The core region is the scientific model of interest. Padding cells are added on
the left, right, and bottom to reduce boundary influence. The n_pad value
records how many padding cells were added so plotting and interpretation tools
can distinguish core cells from numerical buffer cells.
1>>> import numpy as np
2
3>>> from pycsamt.forward import Grid2D
4
5>>> grid = Grid2D.halfspace(
6... rho=100.0,
7... nx=40,
8... nz=28,
9... x_max=8000.0,
10... z_max=5000.0,
11... n_pad=8,
12... pad_factor=1.3,
13... n_stations=12,
14... )
15
16>>> print(grid.resistivity.shape)
17(36, 56)
18>>> print(np.round(grid.x_stations, 1))
19[ 6203. 6930.3 7657.5 8384.8 9112.1 9839.4 10566.6 11293.9 12021.2
20 12748.5 13475.7 14203. ]
21>>> print(grid.x_nodes[0], grid.x_nodes[-1])
220.0 20405.999163999997
23>>> print(grid.core_resistivity.shape)
24(28, 40)
16.4.6. 2-D Profile Grid Constructors#
Use the constructor that matches the experiment you want to run.
Constructor |
Purpose |
Typical use |
|---|---|---|
|
Uniform background model. |
Baseline checks, boundary tests, and solver sanity checks. |
|
Background with one rectangular resistive or conductive block. |
Target detectability, station-spacing tests, and pseudosection training examples. |
|
Laterally extends a layered model across a profile. |
Compare 1-D and 2-D solvers, or build a simple starting model. |
|
Randomized 2-D resistivity model. |
Synthetic catalogues and stress tests. |
A compact block-anomaly grid looks like this:
1>>> from pycsamt.forward import Grid2D, MT2DForward
2
3>>> grid = Grid2D.with_anomaly(
4... bg_rho=500.0,
5... anomaly_rho=5.0,
6... anomaly_bounds=(2000.0, 6000.0, 300.0, 1500.0),
7... nx=50,
8... nz=35,
9... x_max=10000.0,
10... z_max=6000.0,
11... n_pad=8,
12... n_stations=16,
13... )
14
15>>> response = MT2DForward(
16... freqs=[1.0, 10.0, 100.0],
17... grid=grid,
18... verbose=False,
19... ).run()
anomaly_bounds are specified in core model coordinates as
(x_lo, x_hi, z_lo, z_hi). The constructor internally accounts for padding
when placing the anomaly into the full numerical grid.
The full numerical grid includes padding cells. This view is useful for debugging boundary influence, not for geological interpretation.#
The clipped core view shows the scientific model: a conductive block inside a resistive background, sampled by surface stations.#
16.4.7. 2-D MT Solver#
MT2DForward solves the 2-D MT finite-difference problem for
TE mode and TM mode. It returns a
pycsamt.forward.ForwardResponse2D.
The code solves scalar frequency-domain problems on the grid nodes. For a conductivity field \(\sigma(x,z)=1/\rho(x,z)\), the TE unknown is \(E_y\); the TM unknown is \(H_y\). In compact continuous notation the operators can be read as
and
The implementation discretises these equations with finite differences, enforces boundary values from 1-D edge-column responses, solves the sparse linear systems, then estimates surface impedance at the stations. The response conversion is the same impedance-to-observable relation used in 1-D: \(\rho_a=|Z|^2/(\omega\mu_0)\) and \(\phi=\arg Z\).
1>>> station_0 = response.station_response(0)
2
3>>> features_te = response.to_feature_array(
4... mode="te",
5... log_rho=True,
6... include_phase=True,
7... )
8
9>>> features_both = response.to_feature_array(
10... mode="both",
11... log_rho=True,
12... include_phase=True,
13... )
14
15>>> print(response.rho_a_te.shape)
16(3, 16)
17>>> print(response.phase_te.shape)
18(3, 16)
19>>> print(features_te.shape)
20(16, 6)
21>>> print(features_both.shape)
22(16, 12)
23>>> print(np.round(station_0["rho_a_te"], 2))
24[156.17 424.41 607.92]
25>>> print(np.round(station_0["phase_te"], 2))
26[47.38 59.66 50. ]
The pseudo-section is frequency-response geometry, not geology. It shows how the anomaly perturbs apparent resistivity across stations and periods.#
Response array shapes are always frequency first:
Attribute |
Shape |
Meaning |
|---|---|---|
|
|
Frequency axis in Hz. |
|
|
Surface station positions. |
|
|
TE impedance component. |
|
|
TM impedance component. |
|
|
TE apparent resistivity. |
|
|
TE phase in degrees. |
|
|
TM apparent resistivity. |
|
|
TM phase in degrees. |
|
|
Station-first feature matrix for ML or downstream processing. |
This difference is deliberate: physical response arrays are frequency-first, while feature matrices are station-first because each row is one training or analysis sample.
When passing a 2-D forward response to the inversion profile API, transpose the selected response arrays:
1>>> inversion_data = {
2... "freqs": response.freqs,
3... "rho_a": response.rho_a_te.T,
4... "phase": response.phase_te.T,
5... "station_x": response.stations_x,
6... }
7>>> print(inversion_data["rho_a"].shape)
8(16, 3)
16.4.8. 3-D Volume Grid Concepts#
pycsamt.forward.Grid3D stores a 3-D resistivity volume and a 2-D
station layout. It contains:
cell widths
dx,dy, anddz;resistivity with shape
(nz, ny, nx);station coordinates
stations_xywith shape(n_stations, 2);padding in x, y, and z;
helpers that extract XZ and YZ slices for the quasi-3-D solver.
1>>> from pycsamt.forward import Grid3D
2
3>>> grid = Grid3D.halfspace(
4... rho=100.0,
5... nx=20,
6... ny=20,
7... nz=15,
8... x_max=8000.0,
9... y_max=8000.0,
10... z_max=4000.0,
11... n_pad=6,
12... nx_stations=5,
13... ny_stations=5,
14... )
15
16>>> print(grid.resistivity.shape)
17(21, 32, 32)
18>>> print(grid.stations_xy.shape)
19(25, 2)
The regular station grid is created over the core model and shifted into the
padded numerical coordinate system. As in 2-D, stations must lie inside the full
grid extent. The stored resistivity array follows (nz, ny, nx) order, so
depth is the first axis even though map coordinates are usually discussed as
x and y first.
16.4.9. 3-D Volume Grid Constructors#
The main constructors are:
Constructor |
Purpose |
Typical use |
|---|---|---|
|
Uniform 3-D background. |
Baseline tensor and station-grid checks. |
|
Background with one 3-D rectangular block. |
Survey design and quasi-3-D anomaly tests. |
|
Random laterally variable model. |
Synthetic training data and robustness tests. |
1>>> from pycsamt.forward import Grid3D, MT3DForward
2
3>>> grid = Grid3D.block_anomaly(
4... bg_rho=500.0,
5... anomaly_rho=20.0,
6... bounds=(2000.0, 6000.0, 2000.0, 6000.0, 300.0, 1500.0),
7... nx=20,
8... ny=20,
9... nz=15,
10... x_max=8000.0,
11... y_max=8000.0,
12... z_max=4000.0,
13... n_pad=6,
14... nx_stations=5,
15... ny_stations=5,
16... )
17
18>>> response = MT3DForward(
19... freqs=[1.0, 10.0, 100.0],
20... grid=grid,
21... verbose=False,
22... ).run()
The 3-D grid is inspected through XZ, YZ, and XY slices. This is the fastest way to check whether anomaly bounds, depth range, and station coverage agree.#
16.4.10. Quasi-3-D Solver#
MT3DForward is a quasi-3-D MT solver. It does not claim to replace a full
production 3-D modelling engine. Instead, it approximates tensor responses by
extracting orthogonal 2-D slices from the 3-D grid and running MT2DForward
on those slices.
Conceptually:
XZ slices are grouped by station y-row;
YZ slices are grouped by station x-column;
TE and TM responses from both slice families are combined;
off-diagonal tensor components
Z_xyandZ_yxcarry the main response;diagonal components
Z_xxandZ_yyare represented as approximate tensor outputs and should be interpreted carefully.
The assembly can be read as a survey-design approximation. For station \(s=(x_s,y_s)\), the XZ slice through \(y_s\) gives one estimate of the profile response along \(x\); the YZ slice through \(x_s\) gives another estimate along \(y\). pyCSAMT stores the resulting tensor-like components as
then computes \(\rho_a\) and \(\phi\) component by component. The off-diagonal terms usually carry the most stable MT-style information; the diagonal terms are useful diagnostics but should not be over-interpreted as a validated full-3-D solution.
The output is pycsamt.forward.ForwardResponse3D.
1>>> features = response.to_feature_array(
2... components="xy_yx",
3... log_rho=True,
4... include_phase=True,
5... )
6
7>>> all_components = response.to_feature_array(
8... components="all",
9... log_rho=True,
10... include_phase=False,
11... )
12
13>>> print(response.rho_a_xy.shape)
14(3, 25)
15>>> print(response.phase_xy.shape)
16(3, 25)
17>>> print(response.stations_xy.shape)
18(25, 2)
19>>> print(features.shape)
20(25, 12)
21>>> print(all_components.shape)
22(25, 12)
Tensor-component maps are best read comparatively: look for stable off-diagonal structure first, then treat diagonal panels as diagnostics of dimensionality and approximation limits.#
As with the 2-D response, physical arrays are frequency-first:
(n_freqs, n_stations). Feature arrays are station-first:
(n_stations, n_features).
16.4.11. Grid Design Rules#
Forward results are only as useful as the grid that produced them. Before trusting a response, check these points.
Positive resistivityEvery model cell must have strictly positive resistivity. The grid constructors validate this, but it is still worth checking after manual edits.
Station coverageStations must sit inside the grid. For profile experiments, station spacing should be small enough to sample the expected lateral anomaly signature.
PaddingPadding cells reduce boundary effects. Increase
n_padorpad_factorif the response changes noticeably when the model extent is increased.Near-surface resolutionShallow cells should resolve high-frequency or early-time sensitivity. A very coarse top layer can hide near-surface targets or create numerical artefacts.
Depth extentThe bottom of the grid should be deeper than the target and deeper than the main sensitivity range of the lowest frequencies or latest gates.
Frequency or time rangeDo not rely on the solver to compensate for missing physics in the survey design. If the chosen axis cannot see the target, the inversion will not recover it reliably.
DimensionalityUse 1-D models for layered checks, 2-D grids for profile structure, and quasi-3-D grids for survey design or synthetic AI catalogues. Move to Maxwell Adapter Layer’s validated adapters when the result needs a checkable accuracy claim, and further to native external backends through Overview when production 2-D or 3-D inversion files are required.
A useful numerical rule is to compare the target depth with the skin-depth range implied by the frequency axis – the same estimate from (4), evaluated once at each end of the swept band:
The grid bottom should usually extend beyond the deeper end of the sensitivity range, while the near-surface cells should be fine enough for the shallow end. This estimate is only a screening tool, but it catches many poor frequency-grid and depth-extent choices before a solver run.
A compact grid-check helper can be useful in notebooks. Re-building the earlier 2-D anomaly grid keeps this section runnable on its own, independent of the 3-D grids built in between:
1>>> import numpy as np
2
3>>> from pycsamt.forward import Grid2D
4
5>>> grid2d = Grid2D.with_anomaly(
6... bg_rho=500.0,
7... anomaly_rho=5.0,
8... anomaly_bounds=(2000.0, 6000.0, 300.0, 1500.0),
9... nx=50,
10... nz=35,
11... x_max=10000.0,
12... z_max=6000.0,
13... n_pad=8,
14... n_stations=16,
15... )
16
17>>> def describe_grid2d(grid):
18... print(f"cells: nz={grid.nz}, nx={grid.nx}")
19... print(f"stations: {grid.n_stations}")
20... print(f"x extent: {grid.x_nodes[0]:.1f} to {grid.x_nodes[-1]:.1f} m")
21... print(f"z extent: {grid.z_nodes[0]:.1f} to {grid.z_nodes[-1]:.1f} m")
22... print(f"rho range: {np.nanmin(grid.resistivity):.3g} to "
23... f"{np.nanmax(grid.resistivity):.3g} ohm m")
24... print(f"padding cells: {grid.n_pad}")
25...
26
27>>> describe_grid2d(grid2d)
28cells: nz=43, nx=66
29stations: 16
30x extent: 0.0 to 22406.0 m
31z extent: 0.0 to 11316.9 m
32rho range: 5 to 500 ohm m
33padding cells: 8
16.4.12. Response Containers And Feature Arrays#
Response containers preserve the physical arrays and also expose feature helpers for machine-learning and batch processing.
Response object |
Physical shape |
Feature shape |
|---|---|---|
|
1-D arrays over frequency or time. |
|
|
|
|
|
|
|
Use physical arrays when preparing inversion data, plotting scientific responses, or checking units. Use feature arrays when training models, comparing many station responses, or building tabular downstream datasets.
16.4.13. Debugging Solver Experiments#
When a forward response looks wrong, debug in this order:
Run a halfspace model first. A halfspace should produce smooth, stable responses.
Reduce the number of frequencies and stations until the experiment is easy to inspect.
Plot the grid with stations visible and confirm that the target is where you think it is.
Check whether the anomaly is inside the sensitivity range of the selected frequencies or time gates.
Increase padding and model extent. If the response changes strongly, the original grid was too small.
Compare a 2-D halfspace result with a 1-D MT response for the same resistivity.
Check array orientation before passing data to inversion or ML routines.
16.4.14. Next Pages#
Forward Configuration explains how configuration objects create grids and solvers.
Synthetic Datasets And Noise explains how to generate many forward responses.
Forward Plotting shows how to inspect models and responses.
From Forward Modelling To Inversion explains how to pass synthetic responses to inversion workflows.