18.16. Map Porphyry Mineralization From Noisy AMT#

The WILLY_DATA survey sits close to a railway corridor and mining infrastructure, and it shows: high skew, station-to-station static shift, and spatially incoherent noise that a clean textbook AMT line would not have. It is also, per the citation below, real exploration data over a known Cu-Mo porphyry target, which makes it a fair test of whether pyCSAMT’s correction chain can recover something geologically usable from data this rough.

This tutorial works two lines instead of the usual single-line examples: L26PLT and L30PLT from data/AMT/WILLY_DATA. Every other WILLY tutorial in this documentation uses L18PLT/L22PLT, the two lines tracked in git; L26PLT and L30PLT are excluded from the repository to keep it small (see data/AMT/WILLY_DATA/README.md). Both are present on the machine this page was written on, so every number and figure below is real, but a fresh checkout of the repository will not have them – either substitute L18PLT/L22PLT, or your own two-line survey, or contact the corresponding author of the cited paper for the full delivery.

Kouabena, K.A.W., Zhou, J., Chen, R., Yin, L., Cai, H., Lu, Z., Gu, J.,
Yu, W. (2025). Enhanced prediction of deep-seated Cu-Mo porphyry
mineralization: A comprehensive interpretation based on 2D inversion of
audio-magnetotelluric data. Ore Geology Reviews, 185, 106798.

See also [Kouabena2025] in References.

WILLY_DATA is AMT, a natural-source method – there is no controlled transmitter anywhere near this survey, so the near-field, source-offset, and source-overprint diagnostics built for CSAMT and other active-source methods have no place in this tutorial: they assume a real, known distance to a grounded dipole or loop transmitter, an assumption WILLY_DATA does not satisfy on physical grounds. Map Groundwater Geology From CSAMT exercises that same machinery for real, against a real ten-station CSAMT line where a controlled source genuinely matters and roughly two-thirds of the band turns out to sit in the near field – read that tutorial if your own survey has a real transmitter offset to account for.

18.16.1. Starting From Non-EDI Data#

Every step below assumes EDI input, because L26PLT/L30PLT already ship as EDI. A real two-line project rarely starts there. Zonge AVG files, J-files, raw cross-power-spectra, and time-domain (TEM) soundings all need to become EDI first, through pycsamt.transformers and, for transient time-domain data specifically, pycsamt.tdem.transform.TEMtoEDITransformers covers the full converter contract, and TDEM Basics derives the late-time apparent-resistivity mapping TEMtoEDI relies on. For a TEM survey the conversion is one class, not a rewrite of this tutorial’s correction chain:

>>> from pycsamt.tdem import TEMtoEDI
>>> converter = TEMtoEDI(method="late_time")
>>> tem_collection = converter.transform(tem_sounding)
>>> edi_file = tem_collection[0]

tem_sounding there is one TEMSounding read by pycsamt.tdem.read_temavg_soundings(), exactly as in Transformers’s own worked example against the bundled data/TEMAVG/JIANGSU survey. Once every station in a project is an EDIFile, whether it started as AVG, J-file, or a TEM decay curve, it enters this tutorial’s pipeline the same way L26PLT and L30PLT do below – the QC, correction, and inversion steps that follow have no memory of which format the data originally arrived in. “Adapting This Tutorial” at the end of this page covers the remaining, survey-specific changes (input folders, tipper availability) once every station is EDI.

18.16.2. What You Will Learn#

After this tutorial you should be able to:

  • run a full noise-diagnosis chain on a genuinely difficult AMT line: powerline harmonics, a learned dimensionality dictionary, Groom-Bailey decomposition, conditional static shift correction, and an EMAP spatial filter;

  • classify stations as clean, static, near-surface, or mixed from real diagnostics rather than a blanket rule;

  • estimate strike and rotate two lines that disagree with each other about how well a single rotation angle actually works;

  • prepare both a classical 2-D (Occam2D) and a classical 3-D (ModEM) inversion input set from the same corrected survey;

  • construct a separate geological prior and terrain-aware Maxwell mesh for each line, then train the 2-D network with genuine Maxwell responses;

  • distinguish a successful software run from a model that passes response- and structure-recovery gates, and prepare an external 3-D continuation.

18.16.4. Load Both Lines#

>>> from pycsamt.api import read_edis
>>> from pycsamt.site import Sites
>>> survey26 = read_edis("data/AMT/WILLY_DATA/L26PLT", recursive=False, strict=False, progress=False)
>>> survey30 = read_edis("data/AMT/WILLY_DATA/L30PLT", recursive=False, strict=False, progress=False)
>>> sites26 = Sites(survey26.collection).ordered("chainage")
>>> sites30 = Sites(survey30.collection).ordered("chainage")
>>> len(list(sites26)), len(list(sites30))
(25, 25)
>>> sites26.ordering["applied"], sites30.ordering["applied"]
('chainage', 'chainage')
>>> round(sites26.ordering["span_m"], 1), round(sites30.ordering["span_m"], 1)
(2403.2, 2399.6)
>>> raw26_names = [site.name for site in Sites(survey26.collection)]
>>> raw30_names = [site.name for site in Sites(survey30.collection)]
>>> ordered26_names = [site.name for site in sites26]
>>> ordered30_names = [site.name for site in sites30]
>>> raw26_names == ordered26_names, raw30_names == ordered30_names
(True, True)

Both lines carry 53 frequencies from 1.008 Hz to 10.4 kHz – ordinary AMT band coverage, no tipper (confirmed in the WILLY README), and real station elevation, which the closing section relies on. Chainage is established here, before any neighbour-based filter or station-column plot. Consequently every later pseudosection, static-shift window, inversion data row, and topographic section inherits physical profile order rather than filesystem or station-name order. Renaming would not provide that guarantee because it changes identity, not geometry or sequence; see Location And Profiles.

For each line, the geographic coordinates are converted to local east/north positions \(\mathbf{r}_i\), a principal line direction \(\hat{\mathbf{u}}\) is estimated from all valid stations, and the Chainage used for sorting is

(1)#\[c_i = (\mathbf{r}_i-\mathbf{r}_0)\mathbin{\cdot} \hat{\mathbf{u}}, \qquad \pi = \operatorname{argsort}_{i}(c_i).\]

The permutation \(\pi\) in (1) is applied to the complete station objects, so coordinates, impedance tensors, errors, and metadata remain attached to the same station. The reported spans and the successful applied values make this ordering decision explicit and reproducible before the scientific workflow begins.

For this particular L26PLT/L30PLT delivery, the final comparison returns True for both lines: loader order already happens to agree with chainage. That is why deterministic QC and correction numbers remain unchanged in this rerun. The explicit ordering is still essential—it removes that accidental dependence on filenames or directory enumeration, and protects the same tutorial when the EDI files are copied, renamed, or supplied in another input order. The regenerated AI figures differ because the models were retrained; they should not be interpreted as evidence that chainage moved these stations.

18.16.5. Baseline Quality Check#

pycsamt.emtools.build_qc_table() and pycsamt.emtools.frequency_confidence_table() give the first honest look at how rough this data actually is, before any correction has a chance to flatter it.

>>> from pycsamt.emtools import build_qc_table, frequency_confidence_table
>>> qc26 = build_qc_table(sites26, include_skew=True, recursive=False, api=True).to_pandas()
>>> qc30 = build_qc_table(sites30, include_skew=True, recursive=False, api=True).to_pandas()
>>> qc26[["station", "snr_med", "skew_med"]].head(3).round(2)
   station  snr_med  skew_med
0  26-001A    13.00     15.85
1  26-002A    15.59     12.63
2  26-003U    19.88      9.40
>>> round(qc26["skew_med"].min(), 2), round(qc26["skew_med"].max(), 2)
(4.3, 61.36)
>>> round(qc30["skew_med"].min(), 2), round(qc30["skew_med"].max(), 2)
(4.5, 61.38)

Per-station median phase-tensor skew spans a wide 4-61 degree range on both lines, not a uniformly high one. That spread is itself informative: a handful of stations sit in the single digits, well inside a clean 2-D range, while others exceed 60 degrees – squarely 3-D or distortion-affected territory. On its own this does not say why any one station is disturbed – that is what the next several sections screen for – but it means the line cannot be treated as uniformly good or uniformly bad; the per-station and per-period detail matters more than a single summary number here.

>>> fci26 = frequency_confidence_table(sites26, method="composite", ci_hi=0.9, ci_lo=0.5, recursive=False, api=True).to_pandas()
>>> fci30 = frequency_confidence_table(sites30, method="composite", ci_hi=0.9, ci_lo=0.5, recursive=False, api=True).to_pandas()
>>> round(fci26["confidence"].mean(), 3), round(fci30["confidence"].mean(), 3)
(0.676, 0.64)

Turn that single mean number into a full per-station, per-period picture before deciding where the low-confidence rows actually sit:

1import matplotlib.pyplot as plt
2from pycsamt.emtools import plot_frequency_confidence_psection
3
4fig, axes = plt.subplots(1, 2, figsize=(13.5, 4.6), constrained_layout=True)
5plot_frequency_confidence_psection(sites26, recursive=False, ax=axes[0])
6axes[0].set_title("L26PLT raw frequency confidence")
7plot_frequency_confidence_psection(sites30, recursive=False, ax=axes[1])
8axes[1].set_title("L30PLT raw frequency confidence")
9fig.savefig("willy_raw_frequency_confidence.png", dpi=170)
Raw frequency confidence pseudosection for L26PLT and L30PLT

A mean composite confidence around 0.64-0.68 is mediocre, not catastrophic: most station-period rows are usable, but neither line has the uniformly green pseudosection a quiet AMT survey would show. The patchy low-confidence bands sit mostly at short period (high frequency), which is where powerline harmonics live.#

18.16.6. Remove Powerline Harmonics#

pycsamt.emtools.notch_powerline() targets the 50 Hz mains frequency used at this site (Jiangsu Province, mainland China grid) and its harmonics. The default mode="interp" replaces the affected bins in place by interpolation rather than dropping them, so the row count is unchanged – only the values at the harmonic bins move. The tolerance must reflect the sampled frequency grid: these EDIs contain 50.29 Hz rather than exactly 50.00 Hz, so the former tol_hz=0.06 matched no row and performed no correction. A 0.30 Hz tolerance includes 50.29 Hz without reaching the next logarithmically spaced frequency.

For sampled frequency \(f_i\), mains frequency \(f_m\), harmonic index \(k\), and absolute tolerance \(\epsilon\), the implementation changes a row only when

(2)#\[M_i = \mathbf{1}\!\left[ \min_{1\leq k\leq K}|f_i-kf_m|\leq\epsilon \right].\]

With \(\epsilon=0.06\) Hz, equation (2) gives \(M_i=0\) for all 53 rows; with \(\epsilon=0.30\) Hz, it gives \(M_i=1\) at 50.29 Hz and zero elsewhere. This makes the selected correction narrow, explicit, and reproducible rather than visually widening the notch until a difference appears.

>>> from pycsamt.emtools import notch_powerline
>>> import numpy as np
>>> notched26 = notch_powerline(sites26, mains_hz=50.0, n_harm=20, tol_hz=0.30, recursive=False)
>>> notched30 = notch_powerline(sites30, mains_hz=50.0, n_harm=20, tol_hz=0.30, recursive=False)
>>> from pycsamt.emtools._core import _get_z_block
>>> def largest_notch_change(before, after):
...     records = []
...     for raw, corrected in zip(before, after):
...         _, z_raw, freq = _get_z_block(raw)
...         _, z_corrected, _ = _get_z_block(corrected)
...         relative = np.abs(z_corrected[:, 0, 1] - z_raw[:, 0, 1]) / np.maximum(np.abs(z_raw[:, 0, 1]), 1e-30)
...         row = int(np.nanargmax(relative))
...         records.append((float(relative[row]), raw.name, float(freq[row])))
...     change, station, frequency = max(records)
...     return station, round(frequency, 2), round(100.0 * change, 1)
>>> largest_notch_change(sites26, notched26)
('26-001A', 50.29, 68.2)
>>> largest_notch_change(sites30, notched30)
('30-023U', 50.29, 82.1)

Plot that largest single-station change in context – both the local spectrum around 50 Hz and how it compares against every other station on the line – rather than trusting one number in isolation:

 1import numpy as np
 2import matplotlib.pyplot as plt
 3from pycsamt.emtools._core import _get_z_block
 4
 5fig, axes = plt.subplots(2, 2, figsize=(13.0, 7.2), constrained_layout=True)
 6for col, (name, before, after) in enumerate(zip(
 7    ["L26PLT", "L30PLT"], [sites26, sites30], [notched26, notched30]
 8)):
 9    records = []
10    for ed_before, ed_after in zip(before, after):
11        _, zb, fr = _get_z_block(ed_before)
12        _, za, _ = _get_z_block(ed_after)
13        rel = np.abs(za[:, 0, 1] - zb[:, 0, 1]) / np.maximum(np.abs(zb[:, 0, 1]), 1e-30)
14        records.append((float(np.nanmax(rel)), ed_before, ed_after, rel))
15
16    max_rel, ed_before, ed_after, rel = max(records, key=lambda item: item[0])
17    _, zb, fr = _get_z_block(ed_before)
18    _, za, _ = _get_z_block(ed_after)
19    local = (fr >= 20.0) & (fr <= 130.0)
20    changed = int(np.nanargmax(rel))
21
22    ax = axes[0, col]
23    ax.loglog(fr[local], np.abs(zb[local, 0, 1]), "o-", ms=5, label="raw", color="0.45")
24    ax.loglog(fr[local], np.abs(za[local, 0, 1]), "s--", ms=5, label="interpolated", color="crimson")
25    ax.axvline(fr[changed], color="#2471a3", linestyle=":", linewidth=1.2)
26    ax.set_ylabel("|Zxy|")
27    ax.set_title(f"{name} {ed_before.name}: {100 * max_rel:.1f}% at {fr[changed]:.2f} Hz")
28    ax.legend(fontsize=8)
29
30    ax = axes[1, col]
31    station_change = [100.0 * record[0] for record in records]
32    ax.bar(np.arange(len(station_change)), station_change, color="#2471a3")
33    ax.set_xlabel("Chainage-ordered station index")
34    ax.set_ylabel("max |delta Zxy| / |Zxy| (%)")
35    ax.set_title(f"{name}: correction across all stations")
36fig.savefig("willy_powerline_notch_before_after.png", dpi=170)
Powerline notch before and after on a representative station from each line

The upper panels deliberately zoom around the 50 Hz fundamental and select the station with the largest change on each line; the vertical marker makes the interpolated 50.29 Hz row visible instead of compressing it into the full 1 Hz–10.4 kHz range. The lower panels show that this is not merely a cosmetic change at two chosen stations: every bar is computed independently from one chainage-ordered station. Large peaks identify stations for which the raw 50.29 Hz impedance departed most strongly from its log-frequency neighbours, while small bars show where interpolation leaves an already smooth local spectrum nearly unchanged.#

18.16.7. Dimensionality Dictionary#

pycsamt.emtools.learn_dim_dictionary() learns a small set of atoms from phase-tensor features (skew, ellipticity, determinant resistivity, and tipper amplitude where available) by sparse coding, then pycsamt.emtools.encode_dimensionality() labels every station-period row against that learned dictionary rather than a fixed skew/ellipticity threshold.

>>> from pycsamt.emtools import learn_dim_dictionary, encode_dimensionality
>>> model26 = learn_dim_dictionary(notched26, n_atoms=6, lam=0.05, n_iter=40, code_iter=50, recursive=False)
>>> enc26 = encode_dimensionality(notched26, model26, recursive=False, api=True).to_pandas()
>>> enc26["dim_pred"].value_counts().to_dict()
{2: 927, 1: 398}
>>> model30 = learn_dim_dictionary(notched30, n_atoms=6, lam=0.05, n_iter=40, code_iter=50, recursive=False)
>>> enc30 = encode_dimensionality(notched30, model30, recursive=False, api=True).to_pandas()
>>> enc30["dim_pred"].value_counts().to_dict()
{2: 965, 1: 360}

Label 1 (moderate skew, low ellipticity) makes up roughly a quarter to a third of each line (30 percent of L26PLT, 27 percent of L30PLT) and label 2 (the higher-skew/higher-ellipticity atom) the rest; no row on either line lands in the cleanest label 0. That is consistent with the wide but real skew range already seen – most rows still carry enough skew or ellipticity to read as 3-D-flavoured or noisy, but a genuine minority sit in the calmer label 1 rather than every row looking equally disturbed.

1import matplotlib.pyplot as plt
2from pycsamt.emtools import plot_dim_confidence_grid
3
4fig, axes = plt.subplots(1, 2, figsize=(13.5, 4.8), constrained_layout=True)
5for ax, name, sites in zip(axes, ["L26PLT", "L30PLT"], [notched26, notched30]):
6    plot_dim_confidence_grid(sites, recursive=False, ax=ax)
7    ax.set_title(f"{name} dimensionality confidence")
8fig.savefig("willy_dim_confidence_grid.png", dpi=170)
Dimensionality confidence grid for both lines

Both lines look broadly similar here, which matters for the next section: neither line has an obvious quiet sub-domain that a simple station filter could isolate. Distortion correction has to be applied line-wide.#

18.16.8. Remove Galvanic Distortion#

pycsamt.emtools.groom_bailey_table() fits the classic Groom-Bailey decomposition

(3)#\[\mathbf{Z}_{\mathrm{obs}}(f) \approx \mathbf{D}\,\mathbf{Z}_{2D}(f),\]

where \(\mathbf{D}\) is a real, frequency-independent distortion matrix and \(\mathbf{Z}_{2D}\) is anti-diagonal at every frequency. The fit reports twist and shear directly; gain is normalized to 1 by construction, because gain and static shift are not separable from the tensor shape alone – resolving the scalar amplitude is exactly what the dedicated static-shift step further below is for.

>>> from pycsamt.emtools import groom_bailey_table, apply_groom_bailey
>>> gb26 = groom_bailey_table(notched26, min_freq=4, robust=True, recursive=False, api=True).to_pandas()
>>> gb_corr26 = apply_groom_bailey(notched26, table=gb26, inplace=False, recursive=False)
>>> ok26 = gb26[gb26["status"] == "ok"]
>>> round(ok26["twist_deg"].median(), 2), round(ok26["shear"].median(), 4)
(2.8, -0.0028)
>>> round(ok26["diagonal_ratio_before"].median(), 3), round(ok26["diagonal_ratio_after"].median(), 3)
(0.345, 0.359)
>>> gb30 = groom_bailey_table(notched30, min_freq=4, robust=True, recursive=False, api=True).to_pandas()
>>> gb_corr30 = apply_groom_bailey(notched30, table=gb30, inplace=False, recursive=False)
>>> ok30 = gb30[gb30["status"] == "ok"]
>>> round(ok30["twist_deg"].median(), 2), round(ok30["shear"].median(), 4)
(8.73, -0.2585)
>>> round(ok30["diagonal_ratio_before"].median(), 3), round(ok30["diagonal_ratio_after"].median(), 3)
(0.487, 0.333)

The line-median twist is mild on both lines, but medians hide the stations that matter. Six stations reach a twist beyond 40 degrees on L26PLT alone (26-020A at 60.3, 26-023A at -49.8, 26-019U at -49.5, 26-025U at 42.2, 26-002A at 25.2, 26-001A at 19.7), and L30PLT has an even larger cluster (30-025A 60.7, 30-013A 55.7, 30-002A 48.8). L30PLT’s larger median diagonal-ratio drop (0.487 to 0.333, versus L26PLT’s 0.345 to 0.359) says the correction is doing more real work there.

 1from pycsamt.emtools.advanced import plot_distortion_radar
 2
 3for name, sites, gb in [("L26PLT", notched26, gb26), ("L30PLT", notched30, gb30)]:
 4    worst = (
 5        gb.reindex(gb["twist_deg"].abs().sort_values(ascending=False).index)
 6        .head(6)["station"]
 7        .tolist()
 8    )
 9    fig = plot_distortion_radar(
10        sites, stations=worst, max_stations=6, recursive=False,
11        title=f"{name}: 6 largest-twist stations",
12    )
13    fig.savefig(f"willy_{name.lower()}_distortion_radar.png", dpi=170, bbox_inches="tight")
Galvanic distortion radar for the six largest-twist L26PLT stations
Galvanic distortion radar for the six largest-twist L30PLT stations

Each polygon is one station’s six-axis distortion fingerprint (Swift skew, Bahr eta, phase asymmetry, PT beta, ellipticity, and dimensionality proxy). Stations that stretch out along several axes at once, not just one, are the ones where a purely scalar static-shift correction would under-describe what is actually happening to the tensor.#

18.16.9. Correct Static Shift#

pycsamt.emtools.detect_near_surface() separates a frequency-independent scalar shift (the classic static shift model, \(\rho_a'=\rho_a/g\)) from a frequency-dependent near-surface effect that static-shift correction cannot fix. The default f_split=1.0 Hz boundary assumes a broadband MT-style survey reaching well below 1 Hz; WILLY’s audio-band floor sits at 1.008 Hz, so almost nothing would fall below the default split. f_split=50.0 instead splits the band near its geometric centre, and max_skew=None keeps every row – the default 6-degree skew ceiling would reject nearly all of this survey outright, given the skew range already seen.

>>> from pycsamt.emtools import detect_near_surface
>>> ns26 = detect_near_surface(gb_corr26, f_split=50.0, max_skew=None, recursive=False, api=True).to_pandas()
>>> ns26["distortion_type"].value_counts().to_dict()
{'mixed': 17, 'static': 8}
>>> ns30 = detect_near_surface(gb_corr30, f_split=50.0, max_skew=None, recursive=False, api=True).to_pandas()
>>> ns30["distortion_type"].value_counts().to_dict()
{'static': 13, 'mixed': 12}

Neither line has a single clean station by this classification – consistent with everything found so far – but the split between static, near_surface, and mixed matters: it decides how much correction each station gets next.

>>> from pycsamt.emtools import estimate_ss_ama, apply_ss_factors
>>> import numpy as np
>>> factors26 = estimate_ss_ama(gb_corr26, sort_by="name", half_window=3, max_skew=None, recursive=False, api=True).to_pandas()
>>> factors26 = factors26.merge(ns26[["station", "distortion_type"]], on="station", how="left")
>>> factors26["fac_z_reviewed"] = np.where(
...     factors26["distortion_type"].isin(["clean"]), 1.0,
...     factors26["fac_z"].clip(lower=0.3, upper=3.0),
... )
>>> factors26[["station", "fac_z", "distortion_type", "fac_z_reviewed"]].iloc[[0, 15, 19, 20]].round(3)
    station  fac_z distortion_type  fac_z_reviewed
0   26-001A  0.565          static           0.565
15  26-016A  0.840          static           0.840
19  26-020A  0.464           mixed           0.464
20  26-021U  2.107          static           2.107

>>> reviewed26 = factors26[["station", "fac_z_reviewed"]].rename(columns={"fac_z_reviewed": "fac_z"})
>>> ss_corr26 = apply_ss_factors(gb_corr26, reviewed26, key="fac_z", inplace=False, recursive=False)

The clip at [0.3, 3.0] is a review guard, not blanket correction – every station here already qualified as static, mixed, or near_surface, so every factor is applied; a fully clean station would instead be pinned at 1.0 (no change), which is what the np.where branch is for. 26-021U still gets a large 2.11x scaler even after clipping, which is exactly the kind of factor that deserves a note in a production project rather than silent acceptance.

>>> factors30 = estimate_ss_ama(gb_corr30, sort_by="name", half_window=3, max_skew=None, recursive=False, api=True).to_pandas()
>>> factors30 = factors30.merge(ns30[["station", "distortion_type"]], on="station", how="left")
>>> factors30["fac_z_reviewed"] = np.where(
...     factors30["distortion_type"].isin(["clean"]), 1.0,
...     factors30["fac_z"].clip(lower=0.3, upper=3.0),
... )
>>> reviewed30 = factors30[["station", "fac_z_reviewed"]].rename(columns={"fac_z_reviewed": "fac_z"})
>>> ss_corr30 = apply_ss_factors(gb_corr30, reviewed30, key="fac_z", inplace=False, recursive=False)
>>> round(factors30["fac_z_reviewed"].median(), 3)
0.535

With static-shift factors computed and applied for both lines, visualize the resulting log-resistivity shift as a pseudosection instead of reading it off a table:

1import matplotlib.pyplot as plt
2from pycsamt.emtools import plot_ss_delta_psection
3
4fig, axes = plt.subplots(1, 2, figsize=(13.5, 5.0), constrained_layout=True)
5plot_ss_delta_psection(gb_corr26, ss_corr26, ax=axes[0])
6axes[0].set_title("L26PLT static-shift delta log10(rho)")
7plot_ss_delta_psection(gb_corr30, ss_corr30, ax=axes[1])
8axes[1].set_title("L30PLT static-shift delta log10(rho)")
9fig.savefig("willy_static_shift_delta.png", dpi=170)
Static-shift delta pseudosection for both lines

The delta is constant with period at any one station – the visual signature of a genuinely scalar correction – and its sign and size vary station to station in the same patchy way the QC table already showed. That patchiness, not a smooth trend, is what a static-shift-dominated line looks like.#

18.16.10. EMAP Spatial Filter#

Static shift and Groom-Bailey both work station by station. What is left after both is spatially incoherent noise that only shows up when neighbouring stations are compared directly – pycsamt.emtools.apply_emap_filter() implements two profile-wide options, AMA and FLMA, worth comparing rather than picking blindly.

>>> from pycsamt.emtools import apply_emap_filter
>>> ama26 = apply_emap_filter(ss_corr26, method="ama", window_m=1500.0, spacing_m=200.0, comp="det", inplace=False, recursive=False)
>>> flma26 = apply_emap_filter(ss_corr26, method="flma", window=5, component="all", inplace=False, recursive=False)

>>> def rho_xy_all(ss):
...     from pycsamt.emtools._core import _iter_items, _get_z_block
...     out = []
...     for ed in _iter_items(ss):
...         _, z, fr = _get_z_block(ed)
...         if z is None:
...             continue
...         rho = 0.2 * np.abs(z[:, 0, 1]) ** 2 / np.maximum(fr, 1e-30)
...         out.append(rho[np.isfinite(rho) & (rho > 0)])
...     return np.concatenate(out)
...
>>> r0, ra, rf = rho_xy_all(ss_corr26), rho_xy_all(ama26), rho_xy_all(flma26)
>>> round(np.std(np.log10(r0)), 4), round(np.std(np.log10(ra)), 4), round(np.std(np.log10(rf)), 4)
(1.0364, 0.9598, 0.8924)

Both filters reduce the log-resistivity spread; FLMA reduces it further on L26PLT (0.892 versus AMA’s 0.960, down from an unfiltered 1.036). The same comparison on L30PLT gives 0.954 (FLMA) versus 0.967 (AMA) against an unfiltered 1.066 – FLMA wins on both lines, so it is the production choice below.

>>> flma30 = apply_emap_filter(ss_corr30, method="flma", window=5, component="all", inplace=False, recursive=False)

With FLMA now applied to both lines, plot the before/after/delta pseudosection that the comparison above was based on:

1from pycsamt.emtools import plot_emap_filter_psection
2
3for name, before, after in [("L26PLT", ss_corr26, flma26), ("L30PLT", ss_corr30, flma30)]:
4    fig = plot_emap_filter_psection(before, after, method="flma", component="xy")
5    fig.savefig(f"willy_{name.lower()}_emap_flma_psection.png", dpi=170, bbox_inches="tight")
L26PLT EMAP FLMA before/after/delta pseudosection
L30PLT EMAP FLMA before/after/delta pseudosection

The delta panel is where the filter’s actual footprint lives: it is small and scattered, not a broad smooth wash across the whole line, which is the desired behaviour for a filter meant to suppress incoherent noise without also erasing genuine lateral structure.#

18.16.11. Drop Weak Frequencies#

The QC pass at the start flagged individual station-period rows as weak; pycsamt.emtools.drop_low_confidence_frequencies() removes them now, after every tensor-level correction, so the confidence scores reflect the corrected data rather than the raw one.

>>> from pycsamt.emtools import drop_low_confidence_frequencies
>>> dropped26 = drop_low_confidence_frequencies(flma26, method="composite", threshold=0.5, also="both", inplace=False, recursive=False)
>>> dropped30 = drop_low_confidence_frequencies(flma30, method="composite", threshold=0.5, also="both", inplace=False, recursive=False)

Visualize exactly which station-period rows each line lost to this threshold, rather than trusting the aggregate percentage alone:

1import matplotlib.pyplot as plt
2from pycsamt.emtools import plot_frequency_edit_summary
3
4fig, axes = plt.subplots(1, 2, figsize=(13.5, 4.6), constrained_layout=True)
5plot_frequency_edit_summary(flma26, dropped26, ax=axes[0])
6axes[0].set_title("L26PLT bad-frequency drop (threshold=0.5)")
7plot_frequency_edit_summary(flma30, dropped30, ax=axes[1])
8axes[1].set_title("L30PLT bad-frequency drop (threshold=0.5)")
9fig.savefig("willy_bad_frequency_drop_summary.png", dpi=170)
Bad-frequency drop summary for both lines

L26PLT loses 79 of 1325 station-frequency rows (about 6.0 percent); L30PLT loses 154 (about 11.6 percent). One L30PLT station, 30-011A, drops from 53 usable frequencies to only 5 – a station worth flagging for manual review or exclusion in a production project, not silently carried forward at face value.#

The corrected, sanitized survey is exported to disk here, ready to be reloaded independently by every remaining section:

>>> from pycsamt.agents.edi_export import EDIExportAgent
>>> r26 = EDIExportAgent(overwrite=True).execute({"sites": dropped26, "output_dir": "runs/L26PLT_corrected"})
>>> r26.status, r26.data["n_written"]
('success', 25)
>>> r30 = EDIExportAgent(overwrite=True).execute({"sites": dropped30, "output_dir": "runs/L30PLT_corrected"})
>>> r30.status, r30.data["n_written"]
('success', 25)

18.16.12. Skew And Dimensionality#

Reload the corrected EDIs and check whether the correction chain actually changed the skew and dimensionality picture, rather than assuming it.

>>> from pycsamt.api import read_edis
>>> corr26 = Sites(read_edis("runs/L26PLT_corrected", recursive=False, strict=False, progress=False).collection).ordered("chainage")
>>> corr30 = Sites(read_edis("runs/L30PLT_corrected", recursive=False, strict=False, progress=False).collection).ordered("chainage")
>>> from pycsamt.emtools import build_phase_tensor_table
>>> pt26 = build_phase_tensor_table(corr26, recursive=False)
>>> len(pt26), round(pt26["beta"].abs().median(), 2), round(pt26["beta"].abs().quantile(0.9), 2)
(1246, 13.05, 45.44)
>>> pt30 = build_phase_tensor_table(corr30, recursive=False)
>>> len(pt30), round(pt30["beta"].abs().median(), 2), round(pt30["beta"].abs().quantile(0.9), 2)
(1171, 15.15, 53.25)

The correction chain removes powerline spikes, galvanic distortion, static shift, incoherent noise, and weak rows – it does not, and should not, manufacture a low-skew line out of genuinely 3-D or noisy data. A median skew around 13-15 degrees after correction is a real improvement over the uncorrected, per-station picture in the Baseline Quality Check above (which ranged as high as 61 degrees): most of the corrected data now sits close to the classical few-degree “clean 2-D” range. The 90th percentile still reaching 45-53 degrees is the honest remainder – correction removes identifiable distortion and noise, it does not manufacture 2-D structure where the earth is genuinely 3-D, and roughly a tenth of the corrected data still reads that way.

 1import matplotlib.pyplot as plt
 2from pycsamt.emtools import plot_phase_tensor_skewmap, plot_dimensionality_psection
 3
 4fig, axes = plt.subplots(2, 2, figsize=(13.5, 8.6), constrained_layout=True)
 5for col, (name, sites) in enumerate([("L26PLT", corr26), ("L30PLT", corr30)]):
 6    plot_phase_tensor_skewmap(sites, recursive=False, ax=axes[0, col])
 7    axes[0, col].set_title(f"{name} phase-tensor skew")
 8    plot_dimensionality_psection(sites, recursive=False, ax=axes[1, col])
 9    axes[1, col].set_title(f"{name} dimensionality")
10fig.savefig("willy_skew_dimensionality_grid.png", dpi=170)
Skew and dimensionality grid for both corrected lines

Dimensionality now shows real period-dependent structure rather than a uniform wash: both lines carry a genuine mix of 1-D (dark) and 2-D (teal) labels at short period, concentrated roughly between \(\log_{10}(T)=-4\) and \(-2.3\), before the classification settles into predominantly 3-D (yellow) at longer periods on most stations. Shallower structure reading closer to 1-D/2-D and deeper structure reading more 3-D is exactly the pattern expected approaching a genuinely 3-D porphyry alteration system at depth, rather than a uniform artefact of one leftover processing issue at every period.#

18.16.13. Estimate Strike#

pycsamt.emtools.estimate_strike_consensus() blends an impedance-tensor rotation sweep with a phase-tensor azimuth estimate into one angle per station; combine the per-station angles into a circular mean.

>>> from pycsamt.emtools import estimate_strike_consensus
>>> consensus26 = estimate_strike_consensus(corr26, recursive=False)
>>> ang26 = consensus26["ang"].dropna().to_numpy()
>>> doubled = np.deg2rad(2.0 * ang26)
>>> dominant26 = 0.5 * np.rad2deg(np.arctan2(np.sin(doubled).mean(), np.cos(doubled).mean()))
>>> round(dominant26, 2)
-36.42
>>> consensus30 = estimate_strike_consensus(corr30, recursive=False)
>>> ang30 = consensus30["ang"].dropna().to_numpy()
>>> doubled = np.deg2rad(2.0 * ang30)
>>> dominant30 = 0.5 * np.rad2deg(np.arctan2(np.sin(doubled).mean(), np.cos(doubled).mean()))
>>> round(dominant30, 2)
-45.84

The circular spread behind each of these means is close to 50 degrees on both lines – broad, not a tight single-domain result. That is worth carrying forward explicitly rather than hiding behind a clean-looking mean angle.

1from pycsamt.emtools import plot_strike_analysis
2
3for name, sites in [("L26PLT", corr26), ("L30PLT", corr30)]:
4    fig = plot_strike_analysis(
5        sites, recursive=False,
6        suptitle=f"{name} strike / phase-tensor analysis",
7    )
8    fig.savefig(f"willy_{name.lower()}_strike_analysis.png", dpi=170, bbox_inches="tight")
L26PLT strike and phase-tensor azimuth rose analysis
L30PLT strike and phase-tensor azimuth rose analysis

WILLY_DATA carries no vertical-field channel, so plot_strike_analysis() detects that and renders only the two panels it can actually support here – Strike (Z) and PT Azimuth – rather than a third, permanently empty tipper panel. The impedance-sweep and phase-tensor roses agree only loosely with each other on both lines, which matches the broad circular spread already measured and argues against treating either line’s rotation as a precision result.#

18.16.14. Rotate To Strike#

pycsamt.agents.tensor_rotation.TensorRotationAgent rotates impedance to the estimated strike and writes rotated EDI files, and reports a diagonal-suppression check: whether rotating actually reduced \(|Z_{xx}|/|Z_{xy}|\), the signature of successful 2-D alignment.

>>> from pycsamt.agents.tensor_rotation import TensorRotationAgent
>>> agent26 = TensorRotationAgent(strike_deg=-36.42)
>>> rot26 = agent26.execute({"sites": corr26, "output_dir": "runs/L26PLT_rotated", "overwrite": True})
>>> rot26.status, rot26.data["n_written"], round(rot26.data["z_diag_reduction"], 4)
('success', 25, -0.1864)
>>> agent30 = TensorRotationAgent(strike_deg=-45.84)
>>> rot30 = agent30.execute({"sites": corr30, "output_dir": "runs/L30PLT_rotated", "overwrite": True})
>>> rot30.status, rot30.data["n_written"], round(rot30.data["z_diag_reduction"], 4)
('success', 25, 0.2749)

This is the honest outcome of a broad, noisy strike estimate: rotation helps L30PLT (diagonal suppression improves by 0.275) but does not help L26PLT (it gets 0.186 worse, consistently across several period bands and consensus methods tried while preparing this tutorial). Rotating L26PLT anyway keeps the two lines on a comparable classical-inversion footing, but its 2-D/TE-TM split should be trusted less than L30PLT’s; the rotation-free AI 3-D inversion further below is the more appropriate cross-check specifically for L26PLT.

L26PLT diagonal-suppression check before and after rotation
L30PLT diagonal-suppression check before and after rotation

18.16.15. Prepare Occam2D Inputs#

With rotated EDIs on disk, pycsamt.models.occam2d.InputBuilder builds a native 2-D input set exactly as in Prepare an Occam2D Inversion, one line at a time.

>>> from pycsamt.api import read_edis
>>> from pycsamt.models.occam2d import OccamConfig, InputBuilder
>>> cfg = OccamConfig(
...     modes=["TE", "TM"], freq_min=1.0, freq_max=10400.0,
...     error_floor_rho=0.05, error_floor_phase=0.5,
...     n_layers=30, n_airlayers=4, cell_size_horizontal=60.0,
...     cell_size_vertical_top=15.0, depth_scale=1.15,
...     target_misfit=1.0, max_iterations=80, initial_rho=100.0,
... )
>>> rot26_sites = Sites(read_edis("runs/L26PLT_rotated", recursive=False, strict=False, progress=False).collection).ordered("chainage")
>>> builder26 = InputBuilder(rot26_sites, workdir="runs/L26PLT_occam2d", config=cfg, verbose=0)
>>> _ = builder26.build(title="L26PLT pyCSAMT porphyry Occam2D preparation")
>>> print(builder26.summary())
InputBuilder summary
  workdir   : runs\L26PLT_occam2d
  sites     : 25
  freqs     : 53
  data pts  : 4984
  mesh      : 62 x 34 cells
  params    : 780
  modes     : ['TE', 'TM']

>>> rot30_sites = Sites(read_edis("runs/L30PLT_rotated", recursive=False, strict=False, progress=False).collection).ordered("chainage")
>>> builder30 = InputBuilder(rot30_sites, workdir="runs/L30PLT_occam2d", config=cfg, verbose=0)
>>> _ = builder30.build(title="L30PLT pyCSAMT porphyry Occam2D preparation")
>>> builder30.data.n_data
4684

Compare how the QC-driven frequency drop shows up in each line’s own Occam2D data-row count before moving on to the mesh those rows share:

 1import numpy as np
 2import matplotlib.pyplot as plt
 3
 4fig, axes = plt.subplots(1, 2, figsize=(13.5, 4.6), constrained_layout=True)
 5for ax, name, b in zip(axes, ["L26PLT", "L30PLT"], [builder26, builder30]):
 6    db = b.data.data_blocks
 7    site_idx = db[:, 1].astype(int)
 8    comp_codes = db[:, 2].astype(int)
 9    for code, label in {1: "RhoTE", 2: "PhsTE", 5: "RhoTM", 6: "PhsTM"}.items():
10        m = comp_codes == code
11        counts = np.bincount(site_idx[m], minlength=len(b.data.sites))
12        ax.plot(np.arange(len(counts)), counts, label=label, marker=".", ms=3)
13    ax.set_xlabel("Station index")
14    ax.set_ylabel("Data rows")
15    ax.set_title(f"{name} Occam2D data rows by station")
16    ax.legend(fontsize=7)
17fig.savefig("willy_occam2d_data_rows.png", dpi=170)
Occam2D data rows by station for both lines

L26PLT carries more data points than L30PLT (4984 versus 4684) purely from the earlier frequency-drop step – L30PLT lost more rows at the QC stage, and Occam2D simply reflects that in its row count.#

Both lines share the same mesh design regardless of that row-count difference; plot its cell skeleton to confirm the padding grows smoothly away from the station footprint before treating it as ready for either solver:

 1fig, axes = plt.subplots(1, 2, figsize=(13.5, 5.0), constrained_layout=True)
 2for ax, name, b in zip(axes, ["L26PLT", "L30PLT"], [builder26, builder30]):
 3    mesh = b.mesh
 4    for xv in mesh.x_nodes:
 5        ax.axvline(xv, color="0.75", lw=0.4)
 6    for zv in mesh.z_nodes:
 7        ax.axhline(zv, color="0.75", lw=0.4)
 8    ax.set_ylim(mesh.z_nodes[-1], 0)
 9    ax.set_xlim(mesh.x_nodes[0], mesh.x_nodes[-1])
10    ax.set_xlabel("Horizontal (m)")
11    ax.set_ylabel("Depth (m)")
12    ax.set_title(f"{name} Occam2D mesh ({mesh.n_xcells} x {mesh.n_zcells})")
13fig.savefig("willy_occam2d_mesh_skeleton.png", dpi=170)
Occam2D mesh skeleton for both lines

Both lines share the same 62 x 34 cell mesh and 780 parameters, since they use the same OccamConfig. As in Prepare an Occam2D Inversion, this tutorial stops at file preparation and validation; build or locate the occam2d binary first (Occam2D), then run it against runs/L26PLT_occam2d and runs/L30PLT_occam2d separately, and reload each with pycsamt.models.occam2d.InversionResult when a completed run is available.

18.16.16. Prepare ModEM 3-D Inputs#

For 3-D, keep the corrected data unrotated – ModEM works directly with a 3-D impedance tensor and does not need TE/TM separation – and combine both lines into a single 3-D survey with pycsamt.models.modem.builder.InputBuilder.

>>> from pycsamt.models.modem import ModEmConfig
>>> from pycsamt.models.modem.builder import InputBuilder as ModEmBuilder
>>> combined = corr26.to_edis() + corr30.to_edis()
>>> len(combined)
50
>>> cfg3d = ModEmConfig(mode="3d", initial_rho=100.0, freq_min=1.0, freq_max=10400.0)
>>> builder3d = ModEmBuilder(config=cfg3d)
>>> files = builder3d.build(combined, workdir="runs/willy_modem_3d")
>>> sorted(files)
['control', 'covariance', 'data', 'model']
>>> builder3d.model.shape
(35, 45, 59)
>>> len(builder3d.data.site_names), len(builder3d.data.periods)
(50, 53)

Inspect how far that starting mesh grows away from the station footprint in each of the three directions before treating it as ready to solve:

 1import numpy as np
 2import matplotlib.pyplot as plt
 3
 4model = builder3d.model
 5fig, axes = plt.subplots(1, 3, figsize=(13.0, 3.6), constrained_layout=True)
 6labels = ["x (north-south, m)", "y (east-west, m)", "z (depth, m)"]
 7widths = [np.diff(model.x_nodes), np.diff(model.y_nodes), np.diff(model.z_nodes)]
 8for ax, w, lab in zip(axes, widths, labels):
 9    ax.plot(w, marker=".", ms=3)
10    ax.set_title(lab)
11    ax.set_xlabel("cell index")
12    ax.set_ylabel("width (m)")
13fig.suptitle(
14    f"ModEM 3-D starting mesh: {model.shape[0]} x {model.shape[1]} x "
15    f"{model.shape[2]} cells, {len(builder3d.data.site_names)} stations"
16)
17fig.savefig("willy_modem3d_mesh_widths.png", dpi=170)
ModEM 3-D starting mesh cell widths

Cell widths grow geometrically away from the station footprint in all three directions, which is the expected padding behaviour for a 3-D inversion mesh – fine cells where the data actually constrain the model, coarse cells further out to satisfy the boundary conditions cheaply.#

data.dat, m0.ws, covariance.cov, and control.inv are now in runs/willy_modem_3d. As with the Occam2D lines above, this tutorial stops at file preparation; the actual solve runs outside Python, against a locally compiled Mod3DMT (ModEM covers building it, including the MPI and Intel-build variants). pycsamt.models.modem.ModEmRunner builds that command from the same cfg3d used above, so the launch stays tied to the configuration that generated the files rather than retyped by hand:

>>> from pycsamt.models.modem import ModEmRunner
>>> runner = ModEmRunner("runs/willy_modem_3d", config=cfg3d)
>>> command = runner.command(
...     "m0.ws", "data.dat", "control.inv", covariance="covariance.cov",
... )
>>> print(command)
Mod3DMT -I NLCG m0.ws data.dat control.inv covariance.cov

Set cfg3d.use_mpi = True and cfg3d.n_procs beforehand to get an mpirun -np N ... form instead. Run that command externally, then reload the finished run with pycsamt.models.modem.InversionResult and plot it with pycsamt.models.modem.PlotModel3D. ModEM covers the full ModEM workflow – configuration, native files, the runner, and diagnostics – against a bundled, already-converged sample run.

18.16.17. Maxwell-Trained 2-D AI Inversion of Both Lines#

The corrected EDI folders are now the input boundary for AI inversion. Keep the two profiles separate: a 2-D forward operator assumes invariance normal to one profile, and concatenating L26 and L30 would create a fictitious connection between their end stations. Each line therefore receives its own geological hypothesis, topographic surface, padded Maxwell mesh, training dataset, model, and validation record.

The geology is a seeded prior, not an interpretation of these data. For line \(l\), its cell model is

(4)#\[\log_{10}\rho_l(x,z)= \log_{10}\bar{\rho}_{k_l(x,z)}+ \sigma_{k_l(x,z)}g_l(x,z),\]

where \(k_l\) is the stratigraphic unit, \(g_l\) is a correlated Gaussian field, and an optional ellipsoid represents a conductive target hypothesis. Seeds 2601 and 3001 make the two realizations repeatable without forcing them to be identical. In a real study, train over an ensemble of plausible interfaces, correlations, bodies, and resistivities rather than selecting the realization that most resembles the expected target.

The following short excerpt shows the public objects. The complete two-line implementation—including loading, model construction, inversion, validation, and plotting—is exposed as a copyable accordion below.

>>> from pycsamt.ai.geology import (
...     ElectricalLayer, EllipsoidalLens, GaussianCorrelation,
...     GeologyGrid, generate_layered_geology, insert_lenses,
...     topography_from_sites,
... )
>>> from pycsamt.forward.maxwell import MeshDesign, build_solver_mesh
>>> grid26 = GeologyGrid.regular_2d(
...     nx=30, nz=24, dx_m=100, dz_m=75, x_origin_m=-250,
... )
>>> topography26 = topography_from_sites(
...     corr26, grid26, profile_origin_m=-250,
... )
>>> grid26.shape, round(topography26.relief_m, 1)
((24, 30), 162.0)

The terrain object is supplied to pycsamt.forward.maxwell.build_solver_mesh(), which classifies earth and air before solving. Topography is therefore part of the numerical domain; it is not painted onto a flat result afterward. Padding keeps artificial boundaries away from the receiver footprint, while the quality record checks cell ratios and resolution against skin depth.

Seeded geological priors and terrain-aware Maxwell meshes for L26 and L30

The left column contains two different, explicitly hypothetical geology realizations. The right column shows how each becomes a solver model: resistive numerical air occupies the cells above terrain and geometrically growing padding surrounds the 2.4 km receiver footprint. Both resulting meshes have 33 x 38 = 1254 cells. Similar geometry does not imply the same geology; it only reflects the shared discretization policy.#

18.16.17.1. Run genuine 2-D training physics#

Set physics="mt2d" explicitly. Omitting it selects the legacy mt1d mode, which tiles independent 1-D responses and cannot validate lateral Maxwell physics. Matching n_stations_per_profile to all 25 field stations also prevents silent truncation.

>>> import numpy as np
>>> from pycsamt.agents import Inv2DAgent
>>> frequencies_hz = np.geomspace(1.0, 1000.0, 8)
>>> agent26 = Inv2DAgent(
...     physics="mt2d", n_depth=24, depth_max=1800,
...     n_freqs=8, freqs=frequencies_hz,
...     n_train_profiles=100, n_stations_per_profile=25,
...     station_spacing_m=101.5, epochs=100,
...     correlation_length_x_m=(350, 1000),
...     correlation_length_z_m=(90, 300),
...     lambda_x=.02, lambda_z=.01, lambda_tv=.005,
... )
>>> result26 = agent26.execute({
...     "sites": corr26,
...     "topography": True,
...     "output_dir": "runs/L26PLT_ai2d_maxwell",
... })
>>> result26.status, result26.data["physics"]
('success', 'mt2d')
>>> result26.data["pred_section"].shape
(24, 25)

Instantiate a second agent with the L30 median station spacing and write to a different output directory. Do not reuse a trained L26 network as though it were an independent L30 inversion: that would couple experiments without recording the dependency.

The training objective combines supervised model recovery with declared spatial penalties,

(5)#\[\mathcal{J}(\theta)= \frac{1}{N}\sum_{i=1}^{N} \left\|f_\theta(\mathbf d_i)-\mathbf m_i\right\|_2^2 +\lambda_x\|D_x\hat{\mathbf m}_i\|_2^2 +\lambda_z\|D_z\hat{\mathbf m}_i\|_2^2 +\lambda_{TV}\operatorname{TV}(\hat{\mathbf m}_i),\]

where every \(\mathbf d_i\) is generated by the verified 2-D Maxwell adapter from known model \(\mathbf m_i\). Regularization discourages unsupported oscillation; it does not prove that a recovered feature is real.

Direct Maxwell-trained AI predictions for L26 and L30 on real topography

These are the direct pred_section arrays on the agent-returned 1.8 km depth axis—no pseudosection blending, trend injection, smoothing, or invented depth conversion is used. At this larger training scale, L26 develops a coherent, near-continuous resistive cap (\(\log_{10}\rho\) up to about 3.5) spanning most of the profile in the top 0.3 km, transitioning to a mid-range background (\(\log_{10}\rho\simeq2.0\)) at depth, plus a localized low-resistivity patch (\(\log_{10}\rho\) below 1) near the deep right edge of the section (2.1--2.4 km chainage). L30 stays comparatively featureless, hovering close to the mean prediction near \(\log_{10}\rho\simeq2.0\)2.3 with only mild local texture. The contrast demonstrates what the model actually learned, but—as the gate below still confirms—it is not sufficient evidence to label either pattern as alteration or mineralization.#

18.16.17.2. Gate the result before interpretation#

Global RMS tests response consistency, whereas held-out recovery tests whether the network reconstructs known geological models outside its fitting subset. They answer different questions and both must pass a threshold chosen before viewing the field model.

>>> round(result26.data["rms_global"], 3)
1.31
>>> recovery26 = result26.data["mt2d_recovery"]
>>> round(recovery26["rmse"], 3), recovery26["n_samples"]
(0.482, 10)
Observed-response RMS and held-out geological recovery for both lines

L26 and L30 reach response RMS 1.310 and 1.077, respectively, while recovery RMSE is 0.482 and 0.488 log10 ohm m, evaluated over 10 held-out samples per line rather than one. The larger held-out set makes these error estimates noticeably more trustworthy than the earlier ten- realization run, but recovery \(R^2\) is still barely positive (0.063 and 0.045)—far below the 0.60 acceptance threshold—and 10 samples remains short of the 20-sample floor set below. L26’s added structure makes the figure more useful pedagogically, whereas L30’s near-uniform prediction exposes the remaining limitation. Both still fail the structural-recovery gate and therefore do not support a drilling interpretation.#

Raising n_train_profiles from 10 to 100 and epochs from 30 to 100 improved recovery \(R^2\) from effectively zero to a small positive value and grew the held-out set from 1 to 10 samples, but it did not clear the gate—confirming that more of the same eight-frequency, correlated-field training recipe is not enough on its own. The next correct step is to strengthen the training dataset and rerun both lines, publishing replacement sections only after structural recovery passes. For this survey, a meaningful rerun should use 200--500+ Maxwell geology realizations, 16--32 retained frequencies, and an upper limit of 30--100 epochs with validation-based early stopping. It should also cover broader layered, lens, fault/contact, and correlated-field priors; repeat multiple seeds; evaluate recovery by depth and target; and verify that the synthetic response distribution overlaps the L26/L30 observations.

18.16.17.3. Tune this configuration for a real deployment#

Everything on this page, including the 100-realization rerun above, is deliberately sized for documentation-build time, not for a defensible field interpretation. If you are adapting this workflow for your own survey, treat the table below as a starting checklist of what to widen and why, then read the detailed reasoning that follows it before committing CPU time.

What to change before trusting a result#

Parameter

This page (demo)

Widen toward

Why

n_train_profiles

100

200--500+

Populates train/val/test splits and grows the held-out set past the 20-sample gate floor; this is the dominant cost driver (see below).

epochs (ceiling)

100

30--100

Already near the useful range; early stopping (patience = max(5, epochs // 5)) restores the best checkpoint on its own, so raising this further rarely helps once patience is already tuned.

frequencies

8 (11000 Hz)

16--32

Match the field survey’s own QC-surviving band; four isolated decades under-samples the response compared to the field data.

n_depth / depth_max

24 / 1800 m

32 / 2200 m

More vertical degrees of freedom, kept within the survey’s real investigation depth—do not extend past what the frequency band resolves.

correlation lengths / resistivity priors

narrow, single family

wider ranges; add layered/lens/fault families explicitly

The convenience agent only samples one correlated-field family; broader priors and explicit geology objects (below) prevent the network from overfitting to an unrealistically narrow training distribution.

mesh_safety_factor / max_mesh_cells

6.0 / 200,000

8.0 / 300,000

Only matters if the quality diagnostics show resolution against skin depth is marginal; verify before spending the extra mesh cells.

root seed

one (17)

repeat [17, 29, 43, 71, 101]

A feature that moves between seeds is unstable, not real.

promotion thresholds

fixed below

fix before viewing the field result

rmse <= 0.25, r2 >= 0.60, n_samples >= 20 is an example policy; choose your own in advance and do not adjust it after looking.

Budget the compute before choosing these numbers. Fitting the checkpointed loss-curve lengths against the elapsed times measured on this page shows that generating each Maxwell realization costs roughly 32 CPU-seconds on this 25-station, 8-frequency, 1254-cell mesh, while training costs well under 0.1 CPU-seconds per realization-epoch once early stopping is active. Concretely, that means raising n_train_profiles is what makes a rerun expensive, not raising epochs—a 500-realization run at similar mesh/frequency complexity costs on the order of 500 * 32 s 4.4 CPU-hours per line just to generate the training data, before any of the [17, 29, 43, 71, 101] seed repeats multiply that further. The practical levers are: parallelize realization generation across cores (each Maxwell solve is independent), cache a generated dataset and reuse it across epoch/regularization sweeps instead of regenerating it every run, and only pay for a denser frequency grid or larger mesh once the quality diagnostics show they are actually the resolution bottleneck.

The following project-scale agent configuration makes the changes that the current convenience API exposes directly:

>>> project_frequencies = np.geomspace(1.0, 10_000.0, 24)
>>> production_agent = Inv2DAgent(
...     physics="mt2d",
...     n_depth=32,
...     depth_max=2200.0,
...     n_freqs=len(project_frequencies),
...     freqs=project_frequencies,
...     n_train_profiles=256,
...     n_stations_per_profile=25,
...     station_spacing_m=101.5,
...     epochs=80,
...     correlation_length_x_m=(250.0, 1600.0),
...     correlation_length_z_m=(60.0, 450.0),
...     log_resistivity_mean=2.1,
...     log_resistivity_std=0.75,
...     lambda_x=0.01,
...     lambda_z=0.005,
...     lambda_tv=0.002,
...     mesh_safety_factor=8.0,
...     max_mesh_cells=300_000,
... )

n_train_profiles=256 replaces ten examples by enough realizations to populate training, validation, and test partitions. It does not guarantee coverage, so inspect the split counts and response distributions. The 24 frequencies sample four decades instead of four isolated values; for another survey, derive this vector from the frequencies that survived QC rather than copying the bounds blindly. Increasing n_depth to 32 gives the network more vertical degrees of freedom, while depth_max=2200 keeps them within the survey’s intended investigation range.

epochs=80 is a ceiling, not a requirement to train for all 80 epochs. pycsamt.agents.Inv2DAgent automatically uses validation patience max(5, epochs // 5)—16 epochs here—and restores the best state. The wider correlation-length and log-resistivity distributions expose the network to compact and regional structures and a substantially broader resistivity range. Because the standardized Gaussian field is not hard bounded, inspect empirical quantiles and impose scientifically justified limits in a custom dataset when extreme resistivities would be implausible. The smaller regularization weights still suppress isolated pixels but are less likely to erase a recovered body; choose them from validation sweeps, not from the field image.

The convenience Inv2DAgent currently generates correlated-field geology. It does not yet turn the separately constructed LayeredGeology and EllipsoidalLens objects above into its training ensemble. To include layered, lens, fault/contact, and multiple-body families, generate those realizations explicitly with pycsamt.ai.geology, solve every one through pycsamt.forward.maxwell, preserve their models/responses in the dataset contract, and fit pycsamt.ai.inversion.EMInverter2D directly. Merely raising n_train_profiles repeats the configured correlated-field family; it does not broaden geological support.

Repeat the complete experiment with independent root seeds, for example [17, 29, 43, 71, 101]. A feature is unstable when its position or amplitude changes materially between accepted seeds. Do not average failed runs into an apparently smooth final section.

Before promotion, require all predeclared checks—not just low field RMS:

>>> recovery = result26.data["mt2d_recovery"]
>>> response_pass = result26.data["rms_global"] <= 1.2
>>> recovery_pass = recovery["rmse"] <= 0.25 and recovery["r2"] >= 0.60
>>> enough_test_models = recovery["n_samples"] >= 20
>>> promote = response_pass and recovery_pass and enough_test_models
>>> promote
False

The numerical thresholds are an example acceptance policy and must be fixed before inspecting the field result. Add depth-resolved RMSE, conductor-boundary overlap, anomaly-centroid error, predicted-versus-observed response panels, and out-of-distribution tests. Better agreement between training and observations means overlap in frequency/component availability, apparent-resistivity and phase ranges, noise/error distributions, station spacing, topographic relief, and expected geological scales—not merely similar global means.

The configuration above is no longer a quick integration test at this scale. On the documentation machine, training both lines at 100 realizations with an epochs=100 ceiling took 3,554.7 s (L26PLT) and 3,473.4 s (L30PLT)—7,028 s combined, or about 117 minutes. Validation-based early stopping (patience max(5, epochs // 5) = 20) did trigger in both runs, well short of the 100-epoch ceiling: L26PLT stopped after 38 epochs (best validation loss 0.968), L30PLT after 28 (0.970). Wall-clock time is nevertheless dominated by generating the 100 Maxwell realizations themselves, not by training on them—fitting the checkpointed loss-curve lengths against elapsed time gives roughly 32 CPU-seconds per realization generated versus well under 0.1 CPU-seconds per realization-epoch trained. The earlier 10-realization, 30-epoch run took only 813.8 s combined for both lines, consistent with that same per-realization cost. Practically, this means raising n_train_profiles is what makes a rerun expensive here, not raising epochs. The aspirational 256-realizations-per-line production configuration below, with three times the frequencies and a larger mesh, is a substantially bigger job again and was not run for this page. Record the dataset configuration, split manifest, seeds, mesh diagnostics, loss curves, held-out metrics, and observed-response residuals for each line.

View and copy the complete L26/L30 Maxwell-AI workflowClick to inspect and copy the complete code
  1"""Run and plot the two-line Maxwell-AI workflow used by the porphyry tutorial."""
  2
  3from __future__ import annotations
  4
  5import argparse
  6import json
  7import random
  8import sys
  9import time
 10from pathlib import Path
 11
 12import matplotlib
 13
 14matplotlib.use("Agg")
 15import matplotlib.pyplot as plt
 16import numpy as np
 17
 18ROOT = Path(__file__).resolve().parents[2]
 19sys.path.insert(0, str(ROOT))
 20
 21from pycsamt.agents import Inv2DAgent  # noqa: E402
 22from pycsamt.ai.geology import (  # noqa: E402
 23    ElectricalLayer,
 24    EllipsoidalLens,
 25    GaussianCorrelation,
 26    GeologyGrid,
 27    generate_layered_geology,
 28    insert_lenses,
 29    topography_from_sites,
 30)
 31from pycsamt.emtools import ensure_sites  # noqa: E402
 32from pycsamt.forward.maxwell import MeshDesign, build_solver_mesh  # noqa: E402
 33from pycsamt.topo import extract_chainage, extract_elevation  # noqa: E402
 34
 35IMAGE_DIR = ROOT / "docs/source/images/tutorials/map_porphyry_mineralization_from_noisy_amt"
 36RUN_DIR = ROOT / "runs"
 37FREQUENCIES_HZ = np.geomspace(1.0, 1000.0, 8)
 38PRODUCTION_FREQUENCIES_HZ = np.geomspace(1.0, 10_000.0, 24)
 39
 40
 41def save(fig: plt.Figure, name: str) -> None:
 42    IMAGE_DIR.mkdir(parents=True, exist_ok=True)
 43    fig.savefig(IMAGE_DIR / name, dpi=190, bbox_inches="tight")
 44    plt.close(fig)
 45
 46
 47def load_lines():
 48    lines = {}
 49    for name in ("L26PLT", "L30PLT"):
 50        corrected = RUN_DIR / f"{name}_corrected"
 51        if not corrected.exists():
 52            raise FileNotFoundError(
 53                f"{corrected} is missing; run the tutorial's correction/export steps first."
 54            )
 55        lines[name] = ensure_sites(corrected, recursive=False, verbose=0).ordered()
 56    return lines
 57
 58
 59def build_line_problem(name, sites, *, seed, lens_x_m):
 60    chain_m = extract_chainage(sites) * 1000.0
 61    dx = 100.0
 62    x_origin = -250.0
 63    nx = int(np.ceil((chain_m[-1] + 500.0) / dx))
 64    grid = GeologyGrid.regular_2d(
 65        nx=nx, nz=24, dx_m=dx, dz_m=75.0, x_origin_m=x_origin
 66    )
 67    correlation = GaussianCorrelation(700.0, 160.0)
 68    layers = [
 69        ElectricalLayer("weathered cover", 45.0, 0.10, correlation),
 70        ElectricalLayer("altered intrusive", 350.0, 0.16, correlation),
 71        ElectricalLayer("fresh intrusive", 1600.0, 0.10, correlation),
 72    ]
 73    base = generate_layered_geology(
 74        grid,
 75        layers,
 76        [320.0, 1050.0],
 77        seed=seed,
 78        interface_relief_std_m=[45.0, 100.0],
 79        interface_correlation=correlation,
 80        minimum_thickness_m=120.0,
 81    )
 82    target = EllipsoidalLens(
 83        f"{name} conductive target",
 84        center_x_m=lens_x_m,
 85        center_z_m=760.0,
 86        radius_x_m=480.0,
 87        radius_z_m=210.0,
 88        resistivity_ohm_m=12.0,
 89        dip_deg=18.0 if name == "L26PLT" else -12.0,
 90        transition_fraction=0.20,
 91    )
 92    geology = insert_lenses(base, [target])
 93    topography = topography_from_sites(
 94        sites, grid, profile_origin_m=x_origin, interpolation_method="linear"
 95    )
 96    solver_model = build_solver_mesh(
 97        grid,
 98        resistivity_ohm_m=geology.resistivity_ohm_m,
 99        frequencies_hz=FREQUENCIES_HZ,
100        topography=topography,
101        design=MeshDesign(
102            horizontal_padding_cells=4,
103            bottom_padding_cells=5,
104            air_layers=4,
105            padding_expansion=1.3,
106        ),
107    )
108    return {
109        "name": name,
110        "sites": sites,
111        "chain_m": chain_m,
112        "grid": grid,
113        "geology": geology,
114        "topography": topography,
115        "solver_model": solver_model,
116    }
117
118
119def plot_priors_and_meshes(problems) -> None:
120    fig, axes = plt.subplots(2, 2, figsize=(13.6, 8.2), constrained_layout=True)
121    for row, problem in enumerate(problems):
122        grid = problem["grid"]
123        topo = problem["topography"]
124        geology = problem["geology"]
125        mesh_model = problem["solver_model"]
126        im = axes[row, 0].pcolormesh(
127            grid.x_m / 1000.0,
128            grid.z_m / 1000.0,
129            np.log10(geology.resistivity_ohm_m),
130            cmap="turbo", vmin=0.7, vmax=3.5, shading="auto",
131        )
132        axes[row, 0].plot(
133            grid.x_m / 1000.0, topo.surface_depth_m / 1000.0, "k", lw=1.4
134        )
135        axes[row, 0].invert_yaxis()
136        axes[row, 0].set(
137            title=f"{problem['name']}: seeded geology prior",
138            xlabel="profile distance (km)", ylabel="depth below datum (km)",
139        )
140        x = mesh_model.mesh.cell_centres_m["x"] / 1000.0
141        z = mesh_model.mesh.cell_centres_m["z"] / 1000.0
142        axes[row, 1].pcolormesh(
143            x, z, np.log10(1.0 / mesh_model.conductivity_s_m),
144            cmap="turbo", vmin=0.7, vmax=8.0, shading="auto",
145        )
146        axes[row, 1].invert_yaxis()
147        axes[row, 1].set(
148            title=f"{problem['name']}: padded Maxwell mesh",
149            xlabel="profile distance (km)", ylabel="depth below datum (km)",
150        )
151    fig.colorbar(im, ax=axes[:, 0], shrink=.82, label=r"$\log_{10}\rho$ (ohm m)")
152    save(fig, "willy_ai2d_geology_maxwell_both_lines.png")
153
154
155def run_inversions(
156    problems,
157    *,
158    production: bool = False,
159    n_train_profiles: int | None = None,
160    epochs: int | None = None,
161):
162    np.random.seed(17)
163    random.seed(17)
164    try:
165        import torch
166
167        torch.manual_seed(17)
168    except ImportError:
169        pass
170    results = []
171    for problem in problems:
172        chain = problem["chain_m"]
173        spacing = float(np.median(np.diff(chain)))
174        frequencies = (
175            PRODUCTION_FREQUENCIES_HZ if production else FREQUENCIES_HZ
176        )
177        profiles = int(
178            n_train_profiles
179            if n_train_profiles is not None
180            else (256 if production else 10)
181        )
182        epoch_budget = int(
183            epochs if epochs is not None else (80 if production else 30)
184        )
185        line_output = RUN_DIR / (
186            f"{problem['name']}_ai2d_maxwell_production"
187            if production
188            else f"{problem['name']}_ai2d_maxwell"
189        )
190        line_output.mkdir(parents=True, exist_ok=True)
191        started = time.time()
192        agent = Inv2DAgent(
193            physics="mt2d",
194            n_depth=32 if production else 24,
195            n_freqs=len(frequencies),
196            freqs=frequencies,
197            depth_max=2200.0 if production else 1800.0,
198            n_train_profiles=profiles,
199            n_stations_per_profile=len(problem["sites"]),
200            station_spacing_m=spacing,
201            epochs=epoch_budget,
202            correlation_length_x_m=(250.0, 1600.0) if production else (350.0, 1000.0),
203            correlation_length_z_m=(60.0, 450.0) if production else (90.0, 300.0),
204            log_resistivity_mean=2.1,
205            log_resistivity_std=0.75 if production else 0.5,
206            lambda_x=0.01 if production else 0.02,
207            lambda_z=0.005 if production else 0.01,
208            lambda_tv=0.002 if production else 0.005,
209            mesh_safety_factor=8.0 if production else 6.0,
210            max_mesh_cells=300_000 if production else 200_000,
211        )
212        result = agent.execute(
213            {
214                "sites": problem["sites"],
215                "output_dir": str(line_output),
216                "topography": True,
217            }
218        )
219        if result.status != "success":
220            raise RuntimeError(f"{problem['name']}: {result.summary}")
221        checkpoint = line_output / "em_inverter_2d_maxwell.npz"
222        result.data["inverter"].save(checkpoint)
223        np.savez_compressed(
224            line_output / "field_prediction.npz",
225            pred_section=np.asarray(result.data["pred_section"]),
226            depths_km=np.asarray(result.data["depths_km"]),
227            frequency_grid_hz=np.asarray(result.data["frequency_grid_hz"]),
228            station_names=np.asarray(result.data["station_names"]),
229            chainage_km=np.asarray(problem["chain_m"]) / 1000.0,
230            elevation_m=extract_elevation(problem["sites"]),
231        )
232        recovery = result.data.get("mt2d_recovery") or {}
233        manifest = {
234            "line": problem["name"],
235            "status": result.status,
236            "summary": result.summary,
237            "production": production,
238            "seed": 17,
239            "n_train_profiles": profiles,
240            "epochs_requested": epoch_budget,
241            "n_depth": 32 if production else 24,
242            "depth_max_m": 2200.0 if production else 1800.0,
243            "frequencies_hz": frequencies.tolist(),
244            "rms_global": float(result.data["rms_global"]),
245            "mt2d_recovery": recovery,
246            "elapsed_seconds": time.time() - started,
247            "checkpoint": checkpoint.name,
248        }
249        (line_output / "run_manifest.json").write_text(
250            json.dumps(manifest, indent=2, default=float), encoding="utf-8"
251        )
252        print(json.dumps(manifest, default=float), flush=True)
253        results.append(result)
254    return results
255
256
257def plot_inversions(problems, results) -> None:
258    fig, axes = plt.subplots(2, 1, figsize=(13.8, 8.4), constrained_layout=True)
259    images = []
260    for ax, problem, result in zip(axes, problems, results):
261        section = np.asarray(result.data["pred_section"], dtype=float)
262        depth_km = np.asarray(result.data["depths_km"], dtype=float)
263        chain_km = problem["chain_m"] / 1000.0
264        elevation_km = extract_elevation(problem["sites"]) / 1000.0
265        reference = float(np.max(elevation_km))
266        z = reference - depth_km[:, None] + (elevation_km - reference)[None, :]
267        x = np.broadcast_to(chain_km[None, :], section.shape)
268        image = ax.contourf(
269            x, z, section, levels=np.linspace(.7, 3.5, 29),
270            cmap="turbo", vmin=.7, vmax=3.5, extend="both",
271        )
272        images.append(image)
273        ax.plot(chain_km, elevation_km, "k", lw=1.5)
274        ax.scatter(chain_km, elevation_km + .025, marker="v", c="k", s=24)
275        ax.set(
276            title=f"{problem['name']}: direct Maxwell-trained AI prediction",
277            xlabel="profile distance (km)", ylabel="elevation (km)",
278        )
279    fig.colorbar(images[0], ax=axes, shrink=.86, label=r"predicted $\log_{10}\rho$ (ohm m)")
280    save(fig, "willy_ai2d_maxwell_predictions_both_lines.png")
281
282
283def plot_validation(problems, results) -> None:
284    names = [p["name"] for p in problems]
285    rms = [float(r.data["rms_global"]) for r in results]
286    recovery = [r.data.get("mt2d_recovery") or {} for r in results]
287    rmse = [float(r.get("rmse", np.nan)) for r in recovery]
288    mae = [float(r.get("mae", np.nan)) for r in recovery]
289    fig, axes = plt.subplots(1, 2, figsize=(10.8, 4.2), constrained_layout=True)
290    axes[0].bar(names, rms, color=["#2f6f8f", "#c85745"])
291    axes[0].axhline(1, color="0.25", ls="--", lw=1)
292    axes[0].set(title="Observed-response diagnostic", ylabel="global RMS")
293    x = np.arange(2)
294    axes[1].bar(x - .18, rmse, .36, label="RMSE")
295    axes[1].bar(x + .18, mae, .36, label="MAE")
296    axes[1].set_xticks(x, names)
297    axes[1].set(title="Held-out synthetic recovery", ylabel=r"error in $\log_{10}\rho$")
298    axes[1].legend()
299    save(fig, "willy_ai2d_maxwell_validation_both_lines.png")
300
301
302def main(argv: list[str] | None = None) -> int:
303    parser = argparse.ArgumentParser()
304    parser.add_argument("--production", action="store_true")
305    parser.add_argument("--n-train-profiles", type=int)
306    parser.add_argument("--epochs", type=int)
307    args = parser.parse_args(argv)
308    lines = load_lines()
309    problems = [
310        build_line_problem("L26PLT", lines["L26PLT"], seed=2601, lens_x_m=1550.0),
311        build_line_problem("L30PLT", lines["L30PLT"], seed=3001, lens_x_m=1450.0),
312    ]
313    plot_priors_and_meshes(problems)
314    results = run_inversions(
315        problems,
316        production=args.production,
317        n_train_profiles=args.n_train_profiles,
318        epochs=args.epochs,
319    )
320    plot_inversions(problems, results)
321    plot_validation(problems, results)
322    for problem, result in zip(problems, results):
323        quality = problem["solver_model"].quality
324        recovery = result.data.get("mt2d_recovery") or {}
325        print(
326            problem["name"],
327            "stations", len(problem["sites"]),
328            "geology", problem["grid"].shape,
329            "mesh", problem["solver_model"].mesh.shape,
330            "cells", quality.cell_count,
331            "RMS", f"{result.data['rms_global']:.3f}",
332            "recovery_RMSE", f"{recovery.get('rmse', float('nan')):.3f}",
333        )
334    return 0
335
336
337if __name__ == "__main__":
338    raise SystemExit(main())

Run it after the corrected EDI export step:

python docs/scripts/generate_tutorial_porphyry_ai_workflow.py \
    --n-train-profiles 100 --epochs 100

Executed output:

L26PLT stations 25 geology (24, 30) mesh (33, 38) cells 1254 RMS 1.310 recovery_RMSE 0.482
L30PLT stations 25 geology (24, 30) mesh (33, 38) cells 1254 RMS 1.077 recovery_RMSE 0.488

18.16.18. Maxwell-Trained 3-D AI Inversion of Both Lines#

Inv3DAgent(physics="mt3d") now trains against genuine 3-D Maxwell responses on a non-uniform, padded mesh – the graph-only tiled path this tutorial used to fall back on for 3-D is no longer the only option. Both lines combine into one 3-D volume rather than being inverted independently, matching the spirit of the ModEM problem prepared above. No real cross-line coordinate was surveyed between L26PLT and L30PLT, so, exactly as with the ModEM combination earlier, this section states its line-to-line offset as an explicit assumption – 500 m, unrelated to any measured position – rather than implying it was recovered from data.

Build a display geological volume and its padded Maxwell mesh spanning both lines’ combined footprint. This construction is independent of what Inv3DAgent builds internally for training – no agent constructor accepts an externally supplied grid, the same separation already used for the 2-D geology/mesh figure above.

>>> from pycsamt.ai.geology import (
...     ElectricalLayer, EllipsoidalLens, GaussianCorrelation,
...     GeologyGrid, generate_layered_geology, insert_lenses,
... )
>>> from pycsamt.forward.maxwell import MeshDesign, build_solver_mesh
>>> from pycsamt.topo import extract_chainage
>>> chain26, chain30 = extract_chainage(corr26), extract_chainage(corr30)
>>> coords = np.column_stack([
...     np.concatenate([chain26 * 1000.0, chain30 * 1000.0]),
...     np.concatenate([np.zeros_like(chain26), np.full_like(chain30, 500.0)]),
... ])
>>> combined = corr26.to_edis() + corr30.to_edis()
>>> grid3d = GeologyGrid.regular_3d(
...     nx=24, ny=6, nz=18, dx_m=120, dy_m=133, dz_m=100,
...     x_origin_m=-150, y_origin_m=-150,
... )
>>> grid3d.shape
(18, 6, 24)

The full construction below adds a layered prior and a dipping conductive lens the same way the earlier 2-D geology does, then pads it into a solver mesh with pycsamt.forward.maxwell.build_solver_mesh().

Along-line, horizontal, and cross-line slices of the combined 3-D geological prior and padded Maxwell mesh

Mesh cell boundaries are drawn with pycsamt.api.mesh.draw_mesh() (preset="review") on every panel – the same reusable overlay used throughout this documentation, not something specific to this tutorial. The along-line slice (left) shows numerical air above the real terrain, the layered prior below it, and the seeded conductive lens as the dark patch near 1.3–1.7 km profile distance. The mesh stayed at 9,000 padded cells here, comfortably inside what the in-process solver can attempt – this section’s point is that a small 3-D problem runs at all, not a replay of a deliberately oversized mesh.#

Run the fast integration training. This executes for real during this documentation build, on a training budget small enough to finish in a few minutes rather than hours:

>>> from pycsamt.agents import Inv3DAgent
>>> agent3d = Inv3DAgent(
...     physics="mt3d", n_layers=6,
...     freqs=np.geomspace(1.0, 1000.0, 8), depth_max=1800.0,
...     n_train_profiles=10, epochs=20, radius=450.0,
...     hidden=(64, 32), dropout=0.1, n_mc=0,
...     geology_grid_nx_ny=4, geology_grid_nz=4, max_mesh_cells=60_000,
... )
>>> res3d = agent3d.execute({
...     "sites": combined, "coords": coords, "topography": True,
...     "output_dir": "runs/willy_ai3d_maxwell",
... })
>>> res3d.status, res3d.data["pred_rho"].shape
('success', (50, 6))

Gate the result the same way as the 2-D section, before looking at the figure it produces:

>>> recovery3d = res3d.data["mt3d_recovery"]
>>> response_pass = res3d.data["rms_global"] <= 1.2
>>> recovery_pass = recovery3d["rmse"] <= 0.25 and recovery3d["r2"] >= 0.60
>>> enough_test_models = recovery3d["n_samples"] >= 20
>>> promote = response_pass and recovery_pass and enough_test_models
>>> promote
False

For the run captured on this page, rms_global was 2.244 and mt3d_recovery was {'rmse': 0.536, 'mae': 0.444, 'r2': -0.147, 'n_samples': 1} ((9) in AI inversion agents defines these). Both gates fail, as expected at this budget, and one held-out volume is far too few to trust r2’s sign. Re-running this exact call has already produced rms_global values from 1.5 to 6.5 across otherwise-identical seeded sessions on this machine – the same open reproducibility gap already documented for physics="mt1d" in AI inversion agents extends to the mt3d combined-line path too, since dataset realizations are seed-controlled but network initialization and dropout still consume PyTorch’s global random state. Treat any single captured RMS here as one draw, not a reproducible constant.

Maxwell-trained 3-D AI inversion sections for L26PLT and L30PLT with mesh, real topography, and station labels

Each line’s own slice is cut from the single combined prediction – not two independent inversions – with the same station-index caution as before: never stitch L26PLT’s last station to L30PLT’s first as if adjacent. The mesh overlay makes the display grid’s actual cell resolution visible under the color, the black line is real station topography from pycsamt.topo.drape_section(), and triangles with rotated labels mark every station on both lines.#

Observed-response RMS and held-out geological recovery for the combined 3-D run

Both panels read the same way as the 2-D validation figure: the dashed line at RMS 1 is a rough response-fit reference, not a pass threshold on its own, and RMSE/MAE come from the single held-out volume discussed above.#

Scale this up the same way the 2-D production configuration does – more realizations, more epochs, a finer geology grid – but budget accordingly. This project-scale configuration is deliberately not executed on this page; unlike the 2-D case, a realistic 3-D realization count on this survey is expected to take several hours to tens of hours on a single CPU core, not minutes:

>>> production_agent3d = Inv3DAgent(
...     physics="mt3d", n_layers=10,
...     freqs=np.geomspace(1.0, 10_000.0, 24), depth_max=2200.0,
...     n_train_profiles=200, epochs=60, radius=450.0,
...     hidden=(128, 64, 32), dropout=0.1, n_mc=0,
...     correlation_length_x_m=(300.0, 1200.0),
...     correlation_length_y_m=(300.0, 900.0),
...     correlation_length_z_m=(80.0, 400.0),
...     log_resistivity_mean=2.1, log_resistivity_std=0.75,
...     geology_grid_nx_ny=8, geology_grid_nz=8,
...     mesh_safety_factor=8.0, max_mesh_cells=300_000,
... )
>>> production_agent3d.physics, production_agent3d.n_train_profiles
('mt3d', 200)

Copy the complete pipeline – geology and mesh construction, the fast run, the production configuration, and every figure above – from the accordion below, and run it yourself for the full realization count:

View and copy the complete L26/L30 Maxwell 3-D AI workflowClick to inspect and copy the complete code
  1"""Run and plot the two-line Maxwell 3-D AI workflow used by the porphyry tutorial."""
  2
  3from __future__ import annotations
  4
  5import argparse
  6import json
  7import sys
  8import time
  9from pathlib import Path
 10
 11import matplotlib
 12
 13matplotlib.use("Agg")
 14import matplotlib.pyplot as plt
 15import numpy as np
 16
 17ROOT = Path(__file__).resolve().parents[2]
 18sys.path.insert(0, str(ROOT))
 19
 20from pycsamt.agents import Inv3DAgent  # noqa: E402
 21from pycsamt.ai.geology import (  # noqa: E402
 22    ElectricalLayer,
 23    EllipsoidalLens,
 24    GaussianCorrelation,
 25    GeologyGrid,
 26    generate_layered_geology,
 27    insert_lenses,
 28)
 29from pycsamt.api.mesh import PYCSAMT_MESH, cell_edges_from_centres, draw_mesh  # noqa: E402
 30from pycsamt.emtools import ensure_sites  # noqa: E402
 31from pycsamt.forward.maxwell import MeshDesign, build_solver_mesh  # noqa: E402
 32from pycsamt.topo import (  # noqa: E402
 33    drape_section,
 34    extract_chainage,
 35    extract_elevation,
 36    extract_station_names,
 37    interp_elev,
 38)
 39
 40IMAGE_DIR = ROOT / "docs/source/images/tutorials/map_porphyry_mineralization_from_noisy_amt"
 41RUN_DIR = ROOT / "runs"
 42
 43FAST_FREQUENCIES_HZ = np.geomspace(1.0, 1000.0, 8)
 44DEPTH_MAX_M = 1800.0
 45LINE_OFFSET_M = 500.0  # stated assumption: no real cross-line coordinate exists
 46
 47
 48def save(fig: plt.Figure, name: str) -> None:
 49    IMAGE_DIR.mkdir(parents=True, exist_ok=True)
 50    fig.savefig(IMAGE_DIR / name, dpi=190, bbox_inches="tight")
 51    plt.close(fig)
 52
 53
 54def load_lines():
 55    lines = {}
 56    for name in ("L26PLT", "L30PLT"):
 57        corrected = RUN_DIR / f"{name}_corrected"
 58        if not corrected.exists():
 59            raise FileNotFoundError(
 60                f"{corrected} is missing; run the tutorial's correction/export steps first."
 61            )
 62        lines[name] = ensure_sites(corrected, recursive=False, verbose=0).ordered()
 63    return lines
 64
 65
 66def build_combined_problem(lines):
 67    sites26, sites30 = lines["L26PLT"], lines["L30PLT"]
 68    chain26_m = extract_chainage(sites26) * 1000.0
 69    chain30_m = extract_chainage(sites30) * 1000.0
 70    coords = np.column_stack(
 71        [
 72            np.concatenate([chain26_m, chain30_m]),
 73            np.concatenate(
 74                [np.zeros_like(chain26_m), np.full_like(chain30_m, LINE_OFFSET_M)]
 75            ),
 76        ]
 77    )
 78    combined_sites = sites26.to_edis() + sites30.to_edis()
 79
 80    x_span = float(coords[:, 0].max() - coords[:, 0].min())
 81    grid = GeologyGrid.regular_3d(
 82        nx=24,
 83        ny=6,
 84        nz=18,
 85        dx_m=x_span / 24.0,
 86        dy_m=(LINE_OFFSET_M + 300.0) / 6.0,
 87        dz_m=DEPTH_MAX_M / 18.0,
 88        x_origin_m=-150.0,
 89        y_origin_m=-150.0,
 90    )
 91    # A seeded display prior: this step is a mesh/geometry illustration only,
 92    # not the training distribution -- Inv3DAgent(physics="mt3d") builds its
 93    # own internal GeologyGrid from correlated fields; no agent constructor
 94    # accepts an externally-built grid.
 95    correlation = GaussianCorrelation(700.0, 160.0, length_y_m=500.0)
 96    layers = [
 97        ElectricalLayer("weathered cover", 45.0, 0.10, correlation),
 98        ElectricalLayer("altered intrusive", 350.0, 0.16, correlation),
 99        ElectricalLayer("fresh intrusive", 1600.0, 0.10, correlation),
100    ]
101    base = generate_layered_geology(
102        grid, layers, [320.0, 1050.0], seed=2601,
103        interface_relief_std_m=[45.0, 100.0],
104        interface_correlation=correlation,
105        minimum_thickness_m=120.0,
106    )
107    target = EllipsoidalLens(
108        "combined-line conductive target",
109        center_x_m=1500.0, center_y_m=LINE_OFFSET_M / 2.0, center_z_m=760.0,
110        radius_x_m=480.0, radius_y_m=350.0, radius_z_m=210.0,
111        resistivity_ohm_m=12.0, dip_deg=15.0, transition_fraction=0.20,
112    )
113    geology = insert_lenses(base, [target])
114    solver_model = build_solver_mesh(
115        grid,
116        resistivity_ohm_m=geology.resistivity_ohm_m,
117        frequencies_hz=FAST_FREQUENCIES_HZ,
118        design=MeshDesign(
119            horizontal_padding_cells=3,
120            bottom_padding_cells=4,
121            air_layers=3,
122            padding_expansion=1.3,
123        ),
124    )
125    return {
126        "lines": lines,
127        "sites26": sites26,
128        "sites30": sites30,
129        "chain26_m": chain26_m,
130        "chain30_m": chain30_m,
131        "coords": coords,
132        "combined_sites": combined_sites,
133        "grid": grid,
134        "solver_model": solver_model,
135    }
136
137
138def plot_geology_and_mesh(problem) -> None:
139    mesh_model = problem["solver_model"]
140    centres = mesh_model.mesh.cell_centres_m
141    x_km = centres["x"] / 1000.0
142    y_km = centres["y"] / 1000.0
143    z_km = centres["z"] / 1000.0
144    x_edges = cell_edges_from_centres(x_km)
145    y_edges = cell_edges_from_centres(y_km)
146    z_edges = cell_edges_from_centres(z_km)
147    log_rho = np.log10(1.0 / mesh_model.conductivity_s_m)
148    mesh_style = PYCSAMT_MESH.style_for("review")
149    fig, axes = plt.subplots(1, 3, figsize=(14.5, 4.0), constrained_layout=True)
150
151    xz, _ = draw_mesh(
152        axes[0], x_edges, z_edges, log_rho[:, log_rho.shape[1] // 2, :],
153        style=mesh_style, cmap="turbo",
154    )
155    axes[0].invert_yaxis()
156    axes[0].set(
157        title="Along-line slice (padded mesh)",
158        xlabel="x, profile distance (km)", ylabel="depth (km)",
159    )
160
161    draw_mesh(
162        axes[1], x_edges, y_edges, log_rho[log_rho.shape[0] // 3, :, :],
163        style=mesh_style, cmap="turbo",
164    )
165    axes[1].set(
166        title="Horizontal slice, shallow",
167        xlabel="x, profile distance (km)", ylabel="y, cross-line (km)",
168    )
169
170    draw_mesh(
171        axes[2], y_edges, z_edges, log_rho[:, :, log_rho.shape[2] // 2],
172        style=mesh_style, cmap="turbo",
173    )
174    axes[2].invert_yaxis()
175    axes[2].set(
176        title="Cross-line slice",
177        xlabel="y, cross-line (km)", ylabel="depth (km)",
178    )
179    fig.colorbar(xz, ax=axes, shrink=0.85, label=r"$\log_{10}\rho$ (ohm m)")
180    save(fig, "willy_ai3d_geology_maxwell_both_lines.png")
181
182
183def run_fast_inversion(problem):
184    np.random.seed(17)
185    try:
186        import torch
187
188        torch.manual_seed(17)
189    except ImportError:
190        pass
191
192    output_dir = RUN_DIR / "willy_ai3d_maxwell"
193    output_dir.mkdir(parents=True, exist_ok=True)
194    started = time.time()
195    agent = Inv3DAgent(
196        physics="mt3d",
197        n_layers=6,
198        freqs=FAST_FREQUENCIES_HZ,
199        depth_max=DEPTH_MAX_M,
200        n_train_profiles=10,
201        epochs=20,
202        radius=450.0,
203        hidden=(64, 32),
204        dropout=0.1,
205        n_mc=0,
206        geology_grid_nx_ny=4,
207        geology_grid_nz=4,
208        max_mesh_cells=60_000,
209    )
210    result = agent.execute(
211        {
212            "sites": problem["combined_sites"],
213            "coords": problem["coords"],
214            "topography": True,
215            "output_dir": str(output_dir),
216        }
217    )
218    if result.status != "success":
219        raise RuntimeError(f"3-D fast run failed: {result.summary}")
220    checkpoint = output_dir / "gcn_inverter_3d_maxwell.npz"
221    result.data["inverter"].save(checkpoint)
222    recovery = result.data.get("mt3d_recovery") or {}
223    manifest = {
224        "status": result.status,
225        "summary": result.summary,
226        "seed": 17,
227        "n_train_profiles": 10,
228        "epochs_requested": 20,
229        "n_layers": 6,
230        "depth_max_m": DEPTH_MAX_M,
231        "line_offset_m": LINE_OFFSET_M,
232        "frequencies_hz": FAST_FREQUENCIES_HZ.tolist(),
233        "rms_global": float(result.data["rms_global"]),
234        "mt3d_recovery": recovery,
235        "elapsed_seconds": time.time() - started,
236        "checkpoint": checkpoint.name,
237    }
238    (output_dir / "run_manifest.json").write_text(
239        json.dumps(manifest, indent=2, default=float), encoding="utf-8"
240    )
241    print(json.dumps(manifest, default=float), flush=True)
242    return result
243
244
245def _cell_edges_km(centres_m: np.ndarray) -> np.ndarray:
246    return cell_edges_from_centres(centres_m) / 1000.0
247
248
249def plot_inversion_with_mesh(problem, result) -> None:
250    pred_rho = np.asarray(result.data["pred_rho"], dtype=float)
251    depths_km = np.asarray(result.data["depths_km"], dtype=float)
252    n26 = len(problem["sites26"])
253    pred26, pred30 = pred_rho[:n26], pred_rho[n26:]
254    chain26_km = problem["chain26_m"] / 1000.0
255    chain30_km = problem["chain30_m"] / 1000.0
256    elev26 = extract_elevation(problem["sites26"])
257    elev30 = extract_elevation(problem["sites30"])
258    labels26 = [n.split("-")[-1] for n in extract_station_names(problem["sites26"])]
259    labels30 = [n.split("-")[-1] for n in extract_station_names(problem["sites30"])]
260    z_edges_km = cell_edges_from_centres(depths_km)
261
262    fig, axes = plt.subplots(1, 2, figsize=(15.0, 5.6), constrained_layout=True)
263    mesh_style = PYCSAMT_MESH.style_for("review")
264    fills = []
265    for ax, chain_km, elev_m, labels, pred, name in zip(
266        axes,
267        (chain26_km, chain30_km),
268        (elev26, elev30),
269        (labels26, labels30),
270        (pred26, pred30),
271        ("L26PLT", "L30PLT"),
272    ):
273        x_edges_km = cell_edges_from_centres(chain_km)
274        x_centres = 0.5 * (x_edges_km[:-1] + x_edges_km[1:])
275        elev_centres_km = interp_elev(chain_km, elev_m / 1000.0, x_centres)
276        data = pred.T  # (n_layers, n_sta)
277        x_nodes, z_draped, data_draped = drape_section(
278            x_edges_km, z_edges_km, data, elev_centres_km
279        )
280        x_2d = np.broadcast_to(x_nodes[None, :], z_draped.shape)
281        fill, _edges = draw_mesh(
282            ax, x_2d, z_draped, data_draped, style=mesh_style,
283            cmap="turbo", vmin=0.7, vmax=3.5,
284        )
285        fills.append(fill)
286        surface_km = interp_elev(chain_km, elev_m / 1000.0, x_nodes)
287        ax.plot(x_nodes, surface_km, color="#211813", linewidth=1.6, zorder=8)
288        marker_y = elev_m / 1000.0 + 0.03
289        ax.scatter(chain_km, marker_y, marker="v", s=32, color="black", zorder=10)
290        for xi, yi, lab in zip(chain_km, marker_y + 0.10, labels):
291            ax.text(
292                xi, yi, lab, rotation=90, ha="center", va="bottom",
293                fontsize=6.6, zorder=11,
294            )
295        ax.set_ylim(float(surface_km.min() - depths_km[-1]), float(surface_km.max() + 0.55))
296        ax.set_xlim(float(chain_km.min()), float(chain_km.max()))
297        ax.set_xlabel("Profile distance (km)")
298        ax.set_title(f"{name}: Maxwell-trained 3-D AI prediction, mesh + topography")
299    axes[0].set_ylabel("Elevation (km)")
300    fig.colorbar(fills[0], ax=axes, label=r"$\log_{10}\rho$ (ohm m)", shrink=0.85)
301    save(fig, "willy_ai3d_maxwell_predictions_both_lines.png")
302
303
304def plot_validation(result) -> None:
305    recovery = result.data.get("mt3d_recovery") or {}
306    fig, axes = plt.subplots(1, 2, figsize=(9.0, 4.0), constrained_layout=True)
307    axes[0].bar(["AI-3D combined"], [float(result.data["rms_global"])], color="#2f6f8f")
308    axes[0].axhline(1, color="0.25", ls="--", lw=1)
309    axes[0].set(title="Observed-response diagnostic", ylabel="global RMS")
310    axes[1].bar(
311        ["RMSE", "MAE"],
312        [float(recovery.get("rmse", np.nan)), float(recovery.get("mae", np.nan))],
313        color=["#c85745", "#e0a458"],
314    )
315    axes[1].set(
316        title=f"Held-out synthetic recovery (n={recovery.get('n_samples', 0)})",
317        ylabel=r"error in $\log_{10}\rho$",
318    )
319    save(fig, "willy_ai3d_maxwell_validation_both_lines.png")
320
321
322def production_config_reference():
323    """Configuration-only reference for a real, many-hour 3-D run.
324
325    Not executed here. Copy this function's body (or run this script with
326    ``--production``) in your own environment; a realistic budget for this
327    survey is likely several hours to tens of hours on a single CPU core,
328    depending on ``n_train_profiles``, ``geology_grid_nx_ny/nz``, and
329    ``max_mesh_cells``.
330    """
331    return Inv3DAgent(
332        physics="mt3d",
333        n_layers=10,
334        freqs=np.geomspace(1.0, 10_000.0, 24),
335        depth_max=2200.0,
336        n_train_profiles=200,
337        epochs=60,
338        radius=450.0,
339        hidden=(128, 64, 32),
340        dropout=0.1,
341        n_mc=0,
342        correlation_length_x_m=(300.0, 1200.0),
343        correlation_length_y_m=(300.0, 900.0),
344        correlation_length_z_m=(80.0, 400.0),
345        log_resistivity_mean=2.1,
346        log_resistivity_std=0.75,
347        geology_grid_nx_ny=8,
348        geology_grid_nz=8,
349        mesh_safety_factor=8.0,
350        max_mesh_cells=300_000,
351    )
352
353
354def main(argv: list[str] | None = None) -> int:
355    parser = argparse.ArgumentParser()
356    parser.add_argument(
357        "--production",
358        action="store_true",
359        help="Build the production agent (configuration only; does NOT execute).",
360    )
361    args = parser.parse_args(argv)
362
363    lines = load_lines()
364    problem = build_combined_problem(lines)
365    plot_geology_and_mesh(problem)
366
367    if args.production:
368        agent = production_config_reference()
369        print(
370            "production config only, not executed:",
371            agent.physics, agent.n_train_profiles, agent.epochs,
372        )
373        return 0
374
375    result = run_fast_inversion(problem)
376    plot_inversion_with_mesh(problem, result)
377    plot_validation(result)
378
379    quality = problem["solver_model"].quality
380    recovery = result.data.get("mt3d_recovery") or {}
381    print(
382        "combined",
383        "stations", len(problem["combined_sites"]),
384        "geology", problem["grid"].shape,
385        "mesh", problem["solver_model"].mesh.shape,
386        "cells", quality.cell_count,
387        "RMS", f"{result.data['rms_global']:.3f}",
388        "recovery_RMSE", f"{recovery.get('rmse', float('nan')):.3f}",
389        "recovery_n", recovery.get("n_samples", 0),
390    )
391    return 0
392
393
394if __name__ == "__main__":
395    raise SystemExit(main())

Run it after the corrected EDI export step, exactly like the 2-D script:

python docs/scripts/generate_tutorial_porphyry_ai3d_workflow.py

Executed output:

combined stations 50 geology (18, 6, 24) mesh (25, 12, 30) cells 9000 RMS 2.244 recovery_RMSE 0.536 recovery_n 1

Pass --production to build (but not execute) the production agent instead of running the fast one – it prints its configuration and returns without training, so you can inspect the object before committing CPU time:

python docs/scripts/generate_tutorial_porphyry_ai3d_workflow.py --production

18.16.19. Correction Parameter Report#

A production project should archive, per station, exactly what was decided and why. Build one table per line from the intermediate results already computed above.

>>> import pandas as pd
>>> pd.set_option("display.width", 160)
>>> pd.set_option("display.max_columns", None)
>>> report26 = gb26[["station", "gain", "twist_deg", "shear"]].merge(
...     ns26[["station", "ns_index", "ss_delta_log10", "distortion_type"]], on="station", how="left"
... ).merge(
...     factors26[["station", "fac_z_reviewed"]], on="station", how="left"
... )
>>> report26["strike_deg"] = -36.42
>>> report26["emap_method"] = "flma"
>>> report26.iloc[[0, 15, 19, 20]].round(3)
    station  gain  twist_deg  shear  ns_index  ss_delta_log10 distortion_type  fac_z_reviewed  strike_deg emap_method
0   26-001A   1.0     19.724 -0.466     1.625           0.496          static           0.565      -36.42        flma
15  26-016A   1.0     -0.505  0.175     1.907           0.151          static           0.840      -36.42        flma
19  26-020A   1.0     60.352  0.544     2.378           0.667           mixed           0.464      -36.42        flma
20  26-021U   1.0    -14.728  0.891     1.244          -0.647          static           2.107      -36.42        flma

>>> report26["distortion_type"].value_counts().to_dict()
{'mixed': 17, 'static': 8}
>>> round(report26["twist_deg"].abs().median(), 2), round(report26["fac_z_reviewed"].median(), 3)
(8.34, 0.542)

>>> report30 = gb30[["station", "gain", "twist_deg", "shear"]].merge(
...     ns30[["station", "ns_index", "ss_delta_log10", "distortion_type"]], on="station", how="left"
... ).merge(
...     factors30[["station", "fac_z_reviewed"]], on="station", how="left"
... )
>>> report30["strike_deg"] = -45.84
>>> report30["emap_method"] = "flma"
>>> report30["distortion_type"].value_counts().to_dict()
{'static': 13, 'mixed': 12}
>>> round(report30["twist_deg"].abs().median(), 2), round(report30["fac_z_reviewed"].median(), 3)
(10.37, 0.535)

Save both tables next to the corrected EDIs so a reviewer – or a future run of this same pipeline – can see exactly which correction each station received without re-deriving it:

>>> report26.to_csv("runs/L26PLT_correction_report.csv", index=False)
>>> report30.to_csv("runs/L30PLT_correction_report.csv", index=False)

18.16.20. Adapting This Tutorial#

For a different two-line project, change only the input folders first:

1line_a_dir = "path/to/your/line_a_edis"
2line_b_dir = "path/to/your/line_b_edis"

Then rerun the same sequence. If your survey has tipper, add it to the strike and rotation sections following Condition an MT Line With Tipper and Rotation. If the strike rose is tight rather than broad, trust the rotation more; if it is broad like both lines here, retain the full tensor for an external 3-D Maxwell inversion rather than treating the station-graph candidate as a physical cross-check. If your survey does have a real controlled-source transmitter, Map Groundwater Geology From CSAMT covers the near-field and source-overprint diagnostics this tutorial deliberately omits.

18.16.21. See Also#

Correct Static Shift

A single-line, single-method static-shift workflow to compare against the conditional approach used here.

Condition an MT Line With Tipper and Rotation

The nearest existing precedent for the QC-to-rotation portion of this workflow, on a quieter MT line with tipper.

Prepare an Occam2D Inversion

The Occam2D preparation pattern reused above, in full single-line depth.

Run Classical Inversions: Occam2D, ModEM, and MARE2DEM

How to locate or build the Occam2D/ModEM binaries, launch the runs prepared above, and load the results.

Building a Defensible 3-D AI Inversion Problem

Construction and capability gating for a genuine topographic 3-D geology and Maxwell problem.

Map Groundwater Geology From CSAMT

The near-field and source-overprint diagnostics this tutorial omits, run for real against a real ten-station CSAMT line.

Field Zones: Near, Transition, And Far Field

The full near-field and source-overprint theory behind that companion tutorial’s diagnostics.

Static Shift

The physics and correction-factor derivation behind the static-shift section.

ModEM

Full ModEM backend documentation.

Choosing A Model Backend

Deciding between Occam2D, ModEM, MARE2DEM, and AI inversion.