18.8. Process A TEMAVG Survey: TEM To Corrected EDI#

This tutorial walks a real TEM/TDEM survey from raw field files to a reviewed, corrected EDI collection with a real geographic anchor, on to an inspectable 2-D triangular mesh, and finally to a real, gated Maxwell-physics AI inversion trained on that mesh: read the bundled data/TEMAVG/JIANGSU TEMAVG file folder, quality-control the raw time-domain decay, resolve a geographic coordinate for the local site grid with pycsamt.gis, transform to pseudo-frequency impedance with pycsamt.tdem, apply the same pycsamt.emtools corrections used for frequency-domain surveys, build/draw a real graded, topography- draped mesh with pycsamt.forward.maxwell and pycsamt.api.mesh, and train/gate Inv2DAgent(physics="mt2d_tri") on it, the same in-process TriFEM2DAdapter path Map Groundwater Geology From CSAMT uses. The classical, external-binary route – Prepare A MARE2DEM Inversion’s PSLG triangulation – stays out of scope here.

TDEM Basics derives every formula used below (transient diffusion, the late-time apparent-resistivity approximation, pseudo-frequency conventions, and the noise/error-floor model); this page focuses on the workflow decisions a real survey forces and does not re-derive that physics.

JIANGSU is a station grid, not one line: the coordinate table spans 102 planned 40 m-spaced profiles, but only 55 were actually surveyed and processed into .AVG/.LOG/.Z file triplets. Every example below works one representative profile, TEM100 (51 stations, 20 m spacing, a 1000 m line), the same way Process Zonge AVG Lines K1 and K2 works one line at a time from a larger CSAMT/AMT survey.

18.8.1. Read the survey folder#

read_temavg_survey() parses every .AVG file in a folder, groups companion .LOG and .Z files by stem, and looks for a coordinate table using common filenames. The CLI reports the same summary without writing any Python:

$ pycsamt tdem info data/TEMAVG/JIANGSU
Survey root : data\TEMAVG\JIANGSU
AVG files   : 55
  TEM100  (1275 records)
  TEM1020  (1275 records)
  TEM1060  (1275 records)
  ...
Z files     : 55
LOG files   : 55
Coordinates : 5159 points  (with elevation)

Reading this survey’s coordinate table, a legacy Coordinate of measuring point.xls, needs the optional xlrd dependency (pip install "pycsamt[docs]" or pip install xlrd); without it the join is silently skipped and this line reports none found instead of raising, so always check it rather than assuming coordinates loaded.

>>> from pycsamt.tdem import read_temavg_survey
>>> survey = read_temavg_survey("data/TEMAVG/JIANGSU")
>>> survey.n_avg_files, survey.n_z_files, survey.n_log_files
(55, 55, 55)
>>> survey.coordinates.n_points
5159
>>> coords = survey.coordinates.to_dataframe()
>>> coords.profile.nunique(), len(survey.avg_files)
(102, 55)

18.8.2. Pick one profile and inspect its geometry#

The file stem’s numeric suffix is the profile id (TEM100 -> profile 100); within one file, TEMAVG’s station column is the along-profile chainage. TEM100 is a genuine central-loop sounding: the Array metadata, the equal TXdx/TXdy, and the mde sidecar’s Line.Azimuth = 90 all agree on an east-west, 360 m square transmitter loop with the receiver at its centre.

>>> avg = survey.get("TEM100")
>>> avg.metadata
{'title': 'TEMAVG 7.77: "TEM100.FLD", Dated 21-07-04, Processed 24 Oct 24',
 'Array': 'In Loop (Central Loop)', 'TXramp': 450, 'TXdx': 360,
 'TXdy': 360, 'TXarea': 129600, 'RXarea': 10000}
>>> len(avg.stations), avg.n_records
(51, 1275)
>>> avg.stations[0], avg.stations[-1]
(100.0, 1100.0)
>>> soundings = survey.to_soundings(stems=["TEM100"])
>>> soundings[0].moment  # M = I * n_tx * A_tx
1296000.0
View the executed survey-map and topography figure codeClick to inspect and copy the complete code
 1def make_survey_overview(survey, avg, soundings) -> Path:
 2    """Plot the full station grid and the TEM100 elevation profile."""
 3    coords = survey.coordinates.to_dataframe()
 4    line = coords[coords.profile == float(_profile_number(PROFILE_STEM))]
 5    line = line.sort_values("point")
 6
 7    fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.6), constrained_layout=True)
 8
 9    ax = axes[0]
10    ax.scatter(
11        coords.gauss_y, coords.gauss_x, s=2, c="#b0b0b0", label="planned grid pegs"
12    )
13    ax.scatter(
14        line.gauss_y, line.gauss_x, s=14, c="#d62728", label=f"{PROFILE_STEM} stations"
15    )
16    ax.set(
17        title=f"JIANGSU grid: {len(survey.avg_files)} surveyed profiles of "
18        f"{coords.profile.nunique()} planned",
19        # Chinese Gauss-Krüger convention: X = northing, Y = zone-prefixed
20        # easting -- the opposite of the usual (easting, northing) reading.
21        xlabel="Gauss-Krüger Y (zone-prefixed easting, m)",
22        ylabel="Gauss-Krüger X (northing, m)",
23    )
24    ax.ticklabel_format(useOffset=False, style="plain")
25    ax.legend(fontsize=8)
26    ax.grid(alpha=0.25)
27
28    ax = axes[1]
29    ax.plot(line.point, line.elevation, "o-", ms=3.5, color="#1f77b4")
30    ax.set(
31        title=f"{PROFILE_STEM}: measured topography ({len(line)} stations)",
32        xlabel="Station chainage (m)",
33        ylabel="Elevation (m)",
34    )
35    ax.grid(alpha=0.25)
36
37    target = OUT / "jiangsu_survey_overview.png"
38    fig.savefig(target, dpi=180)
39    plt.close(fig)
40    return target
JIANGSU planned station grid with the TEM100 profile highlighted, and its measured topography.

Left: 55 of 102 planned profiles were actually surveyed; TEM100 (red) is the southernmost, running east-west along nearly constant Gauss-Krüger X (northing). Right: TEM100’s 51 stations cross about 82 m of relief (1037-1119 m) over the 1000 m line – real topography that a later inversion mesh needs to honour, not a flat-earth convenience.#

18.8.3. Quality-control the raw time-domain decay before transforming#

TDEM Basics warns generically against “interpreting noisy late gates” and “dropping sign information without documenting why.” TEM100 shows exactly the pattern that warning is about. TEMAVG’s magnitude column is signed; a negative value at late time means the stacked transient crossed zero, i.e. the signal has fallen into the noise floor before that gate. The late-time formula only ever sees |dBdt|, so feeding a sign-flipped gate through it does not raise an error – it silently fabricates an apparent resistivity from noise.

>>> import numpy as np
>>> mag = np.array([rec.magnitude for rec in avg.records])
>>> win = np.array([rec.window for rec in avg.records])
>>> pct = np.array([rec.percent_magnitude for rec in avg.records])
>>> int((mag < 0).sum()), len(mag)
(49, 1275)
>>> round(float(pct.max()), 1)  # the %Mag column alone never flags these
18.0
>>> [(w, int(((win == w) & (mag < 0)).sum())) for w in range(20, 26)]
[(20, 1), (21, 2), (22, 4), (23, 10), (24, 9), (25, 18)]

The percent-magnitude column – the closest thing TEMAVG provides to a native error estimate – never exceeds 18% anywhere in this file, so a threshold on it alone would not catch a single one of these 49 rows. Only the sign is diagnostic here, and it grows sharply at the last three windows: by window 25 (the latest gate, 12.2 ms), 18 of 51 stations have gone negative.

View the executed decay-curve and per-window QC figure codeClick to inspect and copy the complete code
 1def make_time_domain_qc(avg, soundings) -> tuple[Path, dict]:
 2    """Plot signed decay curves and the per-window sign-reversal count."""
 3    n_windows = max(rec.window for rec in avg.records)
 4    neg_count = np.zeros(n_windows, dtype=int)
 5    for rec in avg.records:
 6        if rec.magnitude < 0.0:
 7            neg_count[rec.window - 1] += 1
 8    n_stations = len(avg.stations)
 9
10    picks = [soundings[0], soundings[len(soundings) // 2], soundings[-1]]
11
12    fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.6), constrained_layout=True)
13
14    ax = axes[0]
15    for snd in picks:
16        t_ms = snd.time_gates * 1e3
17        pos = snd.data > 0.0
18        ax.loglog(t_ms[pos], snd.data[pos], "o-", ms=4, lw=1.2, label=snd.station_name)
19        if (~pos).any():
20            ax.loglog(
21                t_ms[~pos],
22                np.abs(snd.data[~pos]),
23                "x",
24                ms=9,
25                mew=2,
26                color="black",
27                label="_nolegend_",
28            )
29    ax.set(
30        title="Signed decay curves (x = negative magnitude)",
31        xlabel="Time (ms)",
32        ylabel="|processed magnitude| (V)",
33    )
34    ax.grid(True, which="both", alpha=0.3)
35    ax.legend(fontsize=8)
36
37    ax = axes[1]
38    ax.bar(np.arange(1, n_windows + 1), neg_count, color="#d62728")
39    ax.set(
40        title=f"{PROFILE_STEM}: stations with negative magnitude per window",
41        xlabel="Time-window number",
42        ylabel=f"Station count (of {n_stations})",
43    )
44    ax.grid(alpha=0.25, axis="y")
45
46    target = OUT / "tem100_time_domain_qc.png"
47    fig.savefig(target, dpi=180)
48    plt.close(fig)
49
50    stats = {
51        "n_windows": n_windows,
52        "neg_count_per_window": neg_count,
53        "n_stations": n_stations,
54        "n_records": len(avg.records),
55        "n_negative_total": int(neg_count.sum()),
56    }
57    return target, stats
Signed TEM100 decay curves with sign-flipped late gates marked, and a per-window count of stations with negative magnitude.

Left: three representative stations follow the expected power-law decay for four decades before the marked (x) gates flip sign near the noise floor. Right: sign reversals are essentially absent before window 16 and climb steeply after window 21 – a station-dependent, not a fixed-window, effect.#

The decision applied throughout the rest of this page is to drop each station’s own negative-magnitude gates before transforming, rather than applying a single blanket window cutoff to every station or, worse, silently abs()-ing them into the late-time formula:

View the noise-floor gate-drop functionClick to inspect and copy the complete code
 1def drop_noise_floor_gates(snd: TEMSounding) -> tuple[TEMSounding, int]:
 2    """Remove gates whose processed magnitude is negative.
 3
 4    A negative TEMAVG ``magnitude`` at late time means the stacked
 5    transient has crossed zero -- the signal has fallen into the noise
 6    floor and the sign is not physically meaningful for the late-time
 7    apparent-resistivity formula, which only ever sees ``|dBdt|``. Feeding
 8    those gates in unfiltered does not raise an error; it silently
 9    fabricates a resistivity value from noise.
10    """
11    keep = snd.data > 0.0
12    n_dropped = int((~keep).sum())
13    cleaned = TEMSounding(
14        time_gates=snd.time_gates[keep],
15        data=snd.data[keep],
16        current=snd.current,
17        tx_area=snd.tx_area,
18        data_type=snd.data_type,
19        tx_turns=snd.tx_turns,
20        rx_area=snd.rx_area,
21        rx_turns=snd.rx_turns,
22        offset=snd.offset,
23        loop_shape=snd.loop_shape,
24        loop_dims=snd.loop_dims,
25        station_name=snd.station_name,
26        x=snd.x,
27        y=snd.y,
28        elevation=snd.elevation,
29        error=(snd.error[keep] if snd.error is not None else None),
30        waveform=snd.waveform,
31    )
32    return cleaned, n_dropped
>>> from docs.scripts.generate_tutorial_temavg_workflow import drop_noise_floor_gates
>>> cleaned = [drop_noise_floor_gates(s)[0] for s in soundings]
>>> n_dropped = sum(drop_noise_floor_gates(s)[1] for s in soundings)
>>> n_dropped, sum(s.n_gates for s in soundings)
(49, 1275)
>>> min(s.n_gates for s in cleaned), max(s.n_gates for s in cleaned)
(15, 25)

One station loses 10 of its 25 gates this way; most lose zero or one. This trims the deepest nominal sensitivity where the trimming happens (dropping gate 25 removes the lowest pseudo-frequency point specifically), a real trade-off against keeping fabricated late-time values – not a free QC step.

18.8.4. Determine a geographic anchor for the local site grid#

The coordinate table carries two coordinate pairs per station: a local x/y (the small numbers used so far, ~100 m scale, matching station chainage) and a projected gauss_x/gauss_y pair in the millions – Chinese Gauss-Krüger convention, with the zone number folded into gauss_y as a zone * 1,000,000 prefix. Neither is geographic lat/lon, and unlike Process Zonge AVG Lines K1 and K2’s K1/K2 lines (whose EPSG:32649 provenance was already known), no CRS is documented for this survey. It has to be inferred, and checked, before it is used.

>>> y = coords.gauss_y.iloc[0]
>>> zone = int(y // 1_000_000)
>>> zone  # embedded in every gauss_y value
19

pyCSAMT registers this zone under several historical and modern Chinese datums; CGCS2000 / Gauss-Kruger zone 19 (EPSG:4497) is the current standard one:

>>> from pycsamt.gis.utils import epsg_project
>>> lon, lat = epsg_project(y, coords.gauss_x.iloc[0], 4497, 4326)
>>> round(lon, 4), round(lat, 4)
(111.1163, 38.7593)

That coordinate is not in Jiangsu – it is roughly 38.8°N, 111.1°E, near the Shanxi/Shaanxi border, about 8 degrees of latitude (nearly 900 km) north of Jiangsu province (30.75-35.2°N). Zone 19 itself only fixes the column of longitude (108-114°E per its EPSG area of use); the northing value fixes latitude independent of which zone-19 CRS is chosen, and every CGCS2000/Xian80/Beijing54 zone-19 candidate lands in the same place. The survey’s elevations (1037-1119 m) are equally inconsistent with Jiangsu’s real topography, a mostly flat coastal plain rarely above a few hundred metres. Both independent checks point the same way: gauss_x/gauss_y reads as a local site grid with an arbitrary large false origin – common practice for Chinese mineral-exploration TEM contractors, and not unusual when a crew has no RTK tie to the national datum on site – rather than true national Gauss-Krüger coordinates. The “JIANGSU” folder name is provenance for the delivery, not evidence for this specific claim.

This is exactly the kind of gap this documentation build does not paper over by default. Here it is used anyway, because a geographic anchor is more useful for the mesh and manifest work below than none, provided it is labelled for what it is: an explicit best-effort guess, not a verified survey location.

View the executed coordinate-projection codeClick to inspect and copy the complete code
 1def attach_geographic_coords(soundings, survey, *, epsg: int = GK_EPSG):
 2    """Overwrite each sounding's local x/y with projected lon/lat.
 3
 4    Uses :func:`pycsamt.gis.utils.epsg_project` on the coordinate table's
 5    ``gauss_x``/``gauss_y`` (already zone-prefixed, so no manual false-
 6    easting arithmetic is needed). This is a best-effort geographic anchor,
 7    not a verified one -- see :data:`GK_EPSG`.
 8    """
 9    from pycsamt.gis.utils import epsg_project
10
11    for snd in soundings:
12        stem, station = snd.station_name.split("_")
13        profile = float("".join(ch for ch in stem if ch.isdigit()))
14        coord = survey.coordinates.get(profile, float(station))
15        if coord is None:
16            continue
17        lon, lat = epsg_project(coord.gauss_y, coord.gauss_x, epsg, 4326)
18        snd.x, snd.y = float(lon), float(lat)
19    return soundings
>>> from docs.scripts.generate_tutorial_temavg_workflow import attach_geographic_coords
>>> _ = attach_geographic_coords(soundings, survey)  # mutates in place
>>> soundings[0].x, soundings[0].y  # now lon, lat -- not local metres
(111.11634836490072, 38.75930206136827)

18.8.5. Transform to frequency domain and write EDI#

TEMtoEDI wraps LateTimeTransform (used here; FourierTransform needs a captured transmitter waveform, which this AVG file does not carry, exactly the limitation TDEM Basics documents) and writes one synthetic EDI per station. Because only Hz was measured, every EDI carries a single independent transfer function: \(Z_{xx}=Z_{yy}=0\) and \(Z_{yx}=-Z_{xy}\), with phase fixed at the default homogeneous \(45^\circ\) rather than measured – the same single-component limitation Process Zonge AVG Lines K1 and K2 documents for the Ex-Hy-only K1/K2 lines, here even more synthetic since even \(Z_{xy}\) itself is derived from one decay curve rather than an independently recorded electric channel.

View the executed clean -> transform -> write -> reload codeClick to inspect and copy the complete code
 1def convert_profile_to_edi(soundings, out_dir: Path):
 2    """Drop noise-floor gates, transform to EDI, write, and reload."""
 3    from pycsamt.emtools import ensure_sites
 4
 5    cleaned, n_dropped = [], 0
 6    for snd in soundings:
 7        c, n = drop_noise_floor_gates(snd)
 8        cleaned.append(c)
 9        n_dropped += n
10
11    conv = TEMtoEDI(method="late_time", phase_mode="homogeneous", out_dir=str(out_dir))
12    written = conv.save(cleaned)
13    sites = ensure_sites(out_dir, recursive=False).ordered(by="station")
14
15    stats = {
16        "n_dropped_total": n_dropped,
17        "n_gates_in": sum(s.n_gates for s in soundings),
18        "n_written": len(written),
19        "n_reloaded": len(sites),
20        "n_gates_out_min": min(len(s.freq) for s in sites),
21        "n_gates_out_max": max(len(s.freq) for s in sites),
22    }
23    return sites, stats
>>> from docs.scripts.generate_tutorial_temavg_workflow import convert_profile_to_edi, RESULTS
>>> raw_sites, stats = convert_profile_to_edi(soundings, RESULTS / "edi_raw" / "TEM100")
>>> stats
{'n_dropped_total': 49, 'n_gates_in': 1275, 'n_written': 51,
 'n_reloaded': 51, 'n_gates_out_min': 15, 'n_gates_out_max': 25}
>>> raw_sites[0].coords  # (lat, lon, elev), real now, not NaN
(38.759302777777776, 111.11634722222222, 1102.9537)

The CLI equivalent for a full survey folder (or, with --stems, one profile) is:

$ pycsamt tdem convert data/TEMAVG/JIANGSU --stems TEM100 --dry-run
Dry run — 51 sounding(s) would be converted:
  TEM100_100
  TEM100_120
  TEM100_140
  ...
  TEM100_1080
  TEM100_1100

$ pycsamt tdem convert data/TEMAVG/JIANGSU --stems TEM100 \
    --output-dir results/process_temavg_survey/edi_raw/TEM100

The CLI path above skips the coordinate-projection step, so it writes HEAD with the raw local x/y from the coordinate table; those values are outside geographic bounds (y up to 1100), so TEMtoEDI leaves LAT/LONG unset rather than writing an invalid geographic coordinate, and records the local values as an INFO note instead. The Python path above is what actually gets a (best-effort) geographic coordinate into HEAD, because it runs attach_geographic_coords() first.

One further detail of this write/reload round trip is worth stating explicitly: Site.rho/Site.phase reload correctly because TEMtoEDI builds \(|Z_{xy}|\) with pyCSAMT’s own field-unit EDI convention, \(|Z|=\sqrt{5f\rho_a}\) (the same one pycsamt.z.resphase.ResPhase.compute_resistivity_phase() uses to read resistivity back out) – not the SI convention \(|Z|=\sqrt{\rho_a\omega\mu_0}\), which shares the same TDEM Basics late-time derivation but is not what the rest of pyCSAMT’s EDI stack expects when it reads Z back off disk.

18.8.6. Correct the EDI collection#

The same single-component limitation from the previous section rules out phase-tensor, skew, and strike diagnostics here for the same reason Process Zonge AVG Lines K1 and K2 gives for K1/K2: \(\operatorname{Re}\mathbf Z\) is singular with only one measured off-diagonal entry. What is applicable is the same two-stage frequency-domain conditioning used there: a Hampel despike in magnitude/phase space, then the Torres-Verdín-Bostick Hanning spatial filter for static shift, this time over the real 20 m station spacing.

View the executed Hampel + static-shift correction codeClick to inspect and copy the complete code
 1def make_edi_processing(raw_sites):
 2    """Apply Hampel despiking and Hanning static-shift correction."""
 3    from pycsamt.emtools import correct_static_shift, hampel_filter_freq
 4
 5    despiked = hampel_filter_freq(
 6        raw_sites, win=2, nsig=3.0, on="z", domain="magphase", inplace=False
 7    )
 8    n_cells_total = 0
 9    n_cells_changed = 0
10    for a, b in zip(raw_sites, despiked):
11        za, zb = a.z[..., 0, 1], b.z[..., 0, 1]
12        n_cells_total += za.size
13        n_cells_changed += int(np.sum(~np.isclose(za, zb, equal_nan=True)))
14
15    corrected = correct_static_shift(
16        despiked, window_m=200.0, spacing_m=20.0, comp="xy", inplace=False
17    )
18
19    station = np.array([float(s.name.split("_")[1]) for s in raw_sites])
20    freq_common = np.unique(np.concatenate([s.freq for s in raw_sites]))
21    freq_common.sort()
22
23    def _grid(sites):
24        grid = np.full((len(sites), freq_common.size), np.nan)
25        for i, s in enumerate(sites):
26            rho = s.rho[..., 0, 1]
27            idx = np.searchsorted(freq_common, s.freq)
28            grid[i, idx] = rho
29        return grid
30
31    rho_raw = _grid(raw_sites)
32    rho_despiked = _grid(despiked)
33    rho_corrected = _grid(corrected)
34
35    with np.errstate(divide="ignore", invalid="ignore"):
36        d_hampel = np.log10(rho_despiked) - np.log10(rho_raw)
37        d_static = np.log10(rho_corrected) - np.log10(rho_despiked)
38
39    fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.6), constrained_layout=True)
40    for ax, delta, title in (
41        (axes[0], d_hampel, "Hampel despike: Δlog10 ρ_xy"),
42        (axes[1], d_static, "Static-shift correction: Δlog10 ρ_xy"),
43    ):
44        vmax = np.nanmax(np.abs(delta)) or 1e-6
45        pc = ax.pcolormesh(
46            station,
47            freq_common,
48            delta.T,
49            shading="nearest",
50            cmap="RdBu_r",
51            vmin=-vmax,
52            vmax=vmax,
53        )
54        ax.set_yscale("log")
55        ax.set(title=title, xlabel="Station chainage (m)", ylabel="Pseudo-frequency (Hz)")
56        fig.colorbar(pc, ax=ax, label=r"$\Delta\log_{10}\rho_a$")
57
58    target = OUT / "tem100_edi_processing_changes.png"
59    fig.savefig(target, dpi=180)
60    plt.close(fig)
61
62    log_diff_static = np.abs(d_static[np.isfinite(d_static)])
63    stats = {
64        "hampel_cells_changed": n_cells_changed,
65        "hampel_cells_total": n_cells_total,
66        "static_median_abs_log10": round(float(np.median(log_diff_static)), 4),
67        "static_max_abs_log10": round(float(np.max(log_diff_static)), 4),
68    }
69    return target, despiked, corrected, stats
>>> from docs.scripts.generate_tutorial_temavg_workflow import make_edi_processing
>>> _, despiked, corrected, proc_stats = make_edi_processing(raw_sites)
>>> proc_stats
{'hampel_cells_changed': 16, 'hampel_cells_total': 1226,
 'static_median_abs_log10': 0.0044, 'static_max_abs_log10': 0.3164}

The despike changed 16 of 1226 frequency-station cells – sparse, as expected for outlier detection on an otherwise smooth curve. The static-shift correction is far gentler here than the 0.21-0.36 log10-decade median shifts found for the K1/K2 field CSAMT lines: a median of 0.0044 decades. This is not proof the correction was unnecessary; a synthetic tensor built from one smoothly-varying Hz decay per station is, by construction, spatially smoother than an independently measured field line, and no independent static-shift diagnostic (a co-located sounding, or a second measured component) exists here to validate the correction either way. It is applied to demonstrate the workflow, not because it has been independently confirmed against geology.

Change in log10 apparent resistivity from Hampel despiking and Hanning static-shift correction across the TEM100 profile.

Left: Hampel changes are isolated single cells, concentrated at the noisiest late-time (low pseudo-frequency) row. Right: the static-shift correction is broad but small through the profile interior, and largest at both ends – the expected edge effect of a finite 200 m Hanning window with no stations beyond the profile to average against.#

18.8.7. Compare raw and corrected responses at three stations#

View the executed three-station comparison figure codeClick to inspect and copy the complete code
 1def make_response_panels(raw_sites, corrected_sites) -> Path:
 2    """Compare raw and corrected rho/phase at three representative stations."""
 3    picks_idx = [0, len(raw_sites) // 2, len(raw_sites) - 1]
 4    fig, axes = plt.subplots(2, 3, figsize=(12.5, 6.2), constrained_layout=True)
 5    for col, idx in enumerate(picks_idx):
 6        raw, corr = raw_sites[idx], corrected_sites[idx]
 7        rho_raw = raw.rho[..., 0, 1]
 8        rho_corr = corr.rho[..., 0, 1]
 9        phase_raw = raw.phase[..., 0, 1]
10        phase_corr = corr.phase[..., 0, 1]
11
12        ax = axes[0, col]
13        ax.loglog(raw.freq, rho_raw, "o-", ms=4, color="#7f7f7f", label="raw")
14        ax.loglog(corr.freq, rho_corr, "s-", ms=4, color="#d62728", label="corrected")
15        ax.set(title=raw.name, xlabel="", ylabel=r"$\rho_a$ ($\Omega\cdot$m)" if col == 0 else "")
16        ax.grid(True, which="both", alpha=0.25)
17        if col == 0:
18            ax.legend(fontsize=8)
19
20        ax = axes[1, col]
21        ax.semilogx(raw.freq, phase_raw, "o-", ms=4, color="#7f7f7f")
22        ax.semilogx(corr.freq, phase_corr, "s-", ms=4, color="#d62728")
23        ax.set(
24            xlabel="Pseudo-frequency (Hz)",
25            ylabel=r"$\phi_{xy}$ (deg)" if col == 0 else "",
26        )
27        ax.grid(True, which="both", alpha=0.25)
28
29    target = OUT / "tem100_three_station_comparison.png"
30    fig.savefig(target, dpi=180)
31    plt.close(fig)
32    return target
Raw and corrected apparent resistivity and phase at the first, middle, and last TEM100 stations.

Top row: apparent resistivity retains its overall shape after correction, with the largest changes at the highest and lowest pseudo-frequencies – consistent with the noise-floor gates trimmed earlier and the Hanning window edge effect. Bottom row: phase is flat at exactly \(45^\circ\) for every station, raw and corrected alike. That is not a finding; it is phase_mode="homogeneous" by construction, and neither correction touches phase since both only rescale \(|Z|\) by a real positive factor. Plotting it here makes that limitation visible instead of hiding it behind an unlabeled flat line.#

18.8.8. What this profile cannot support#

Being explicit about scope here matters as much as the corrections themselves:

  • No tensor diagnostics. Phase tensor, skew, and geoelectric strike all need a non-singular \(\mathbf Z\); this survey measured one component.

  • No Fourier transform. TEM100’s AVG file carries no captured transmitter waveform, so FourierTransform’s waveform-deconvolution advantage over the late-time approximation is not available without first supplying an external waveform model.

  • No independent static-shift validation. The correction in the previous section is a documented processing choice, not a confirmed fit to geology – there is no second sounding or component at any station to check it against.

  • No source-effect screening. classify_field_zones() and detect_source_overprint() need a measured transmitter-to-receiver offset; a central-loop TEM survey with the receiver at the loop centre has no such offset to supply.

  • No verified geographic coordinates. The HEAD LAT/LONG now carry a real value, but it is the explicit best-effort guess from the section above, not a confirmed survey location – treat any map or distance computed from it accordingly.

18.8.9. Write the corrected collection and a coordinate manifest#

View the executed manifest-writing codeClick to inspect and copy the complete code
1def write_manifest(corrected_sites):
2    """Write the corrected profile EDIs plus a coordinate manifest."""
3    from pycsamt.site.export import write_sites
4
5    out = RESULTS / "edi_corrected" / PROFILE_STEM
6    paths = write_sites(
7        corrected_sites, out, exist_ok=True, manifest_csv=out / "manifest.csv"
8    )
9    return out, paths
>>> from docs.scripts.generate_tutorial_temavg_workflow import write_manifest
>>> out_dir, paths = write_manifest(corrected)
>>> len(paths)
51

Now that attach_geographic_coords() has run, lat/lon/elev all come out real, finite values – not NaN – because they are read straight from each site’s HEAD, which the write/reload step above already populated:

>>> import pandas as pd
>>> manifest = pd.read_csv(out_dir / "manifest.csv")
>>> manifest[["station", "lat", "lon", "elev", "chainage"]].head(2)
     station        lat         lon       elev  chainage
0  TEM100_100  38.759303  111.116347  1102.9537       NaN
1  TEM100_120  38.759303  111.116578  1103.0357       NaN

chainage alone is still NaN: write_sites() reads it from an explicit ed.chainage attribute that nothing in this pipeline sets, independent of whether lat/lon are known. The along-profile distance used below for the mesh comes directly from each station’s name suffix instead, which for this survey already is physical chainage in metres (confirmed back in “Pick one profile and inspect its geometry”).

18.8.10. Build and view the 2-D triangular mesh#

This is the point Prepare A MARE2DEM Inversion and Mesh Display pick up from – an unstructured triangular mesh graded around real station positions and draped on real topography, built here with build_graded_tri_mesh() and drawn with draw_tri_mesh(). Handing it to MARE2DEM is still out of scope for this page, but actually solving on it is not – the next section trains a real AI inversion directly on this mesh.

The finest cell size at each station is set from this profile’s own skin depth at its highest measured pseudo-frequency, not a fixed constant:

\[\delta \approx 503\sqrt{\rho_a / f}\]
View the executed mesh-building and drawing codeClick to inspect and copy the complete code
 1def make_mesh(corrected_sites):
 2    """Build and draw a real graded 2-D triangular mesh from the profile.
 3
 4    Station chainage and elevation come straight from the corrected EDI
 5    collection now that :func:`attach_geographic_coords` has given every
 6    site real, non-NaN ``Site.coords``. ``surface_cell_m`` is set from
 7    this profile's own skin depth at its highest measured pseudo-
 8    frequency, not a fixed constant.
 9    """
10    from pycsamt.api.mesh import draw_tri_mesh
11    from pycsamt.forward.maxwell.tri_mesh_gen import build_graded_tri_mesh
12
13    station_x = np.array([float(s.name.split("_")[1]) for s in corrected_sites])
14    elevation = np.array([s.coords[2] for s in corrected_sites])
15    order = np.argsort(station_x)
16    station_x, elevation = station_x[order], elevation[order]
17    topo_z = elevation.max() - elevation
18
19    freq_max = max(float(s.freq.max()) for s in corrected_sites)
20    rho_at_fmax = np.array(
21        [s.rho[np.argmax(s.freq), 0, 1] for s in corrected_sites]
22    )
23    skin_depth_m = 503.0 * np.sqrt(np.nanmedian(rho_at_fmax) / freq_max)
24    surface_cell_m = round(float(0.04 * skin_depth_m), -1) or 10.0
25
26    pad = 100.0
27    x_range_m = (float(station_x.min() - pad), float(station_x.max() + pad))
28    z_range_m = (0.0, 600.0)
29
30    mesh = build_graded_tri_mesh(
31        x_range_m,
32        z_range_m,
33        station_x,
34        surface_cell_m=surface_cell_m,
35        topo_x_m=station_x,
36        topo_z_m=topo_z,
37    )
38
39    fig, ax = plt.subplots(figsize=(11.0, 5.0), constrained_layout=True)
40    draw_tri_mesh(ax, mesh, preset="diagram")
41    ax.plot(station_x, np.interp(station_x, station_x, topo_z), "rv", ms=6, zorder=5)
42    ax.set(
43        title=(
44            f"{PROFILE_STEM}: graded triangular mesh "
45            f"({mesh.n_triangles} triangles, surface cell {surface_cell_m:g} m)"
46        ),
47        xlabel="Chainage (m)",
48        ylabel="Depth below highest station (m)",
49    )
50    ax.invert_yaxis()
51    ax.set_xlim(*x_range_m)
52
53    target = OUT / "tem100_triangular_mesh.png"
54    fig.savefig(target, dpi=180)
55    plt.close(fig)
56
57    stats = {
58        "n_nodes": int(mesh.nodes_m.shape[0]),
59        "n_triangles": int(mesh.n_triangles),
60        "surface_cell_m": surface_cell_m,
61        "skin_depth_m_at_fmax": round(float(skin_depth_m), 1),
62        "freq_max_hz": round(freq_max, 1),
63        "x_range_m": x_range_m,
64        "z_range_m": z_range_m,
65    }
66    return target, mesh, stats
>>> from docs.scripts.generate_tutorial_temavg_workflow import make_mesh
>>> target, mesh, mesh_stats = make_mesh(corrected)
>>> mesh_stats
{'n_nodes': 334, 'n_triangles': 586, 'surface_cell_m': 20.0,
 'skin_depth_m_at_fmax': 453.5, 'freq_max_hz': 2729.0,
 'x_range_m': (0.0, 1200.0), 'z_range_m': (0.0, 600.0)}
Graded triangular mesh for the TEM100 profile, refined near stations and draped on real topography.

586 triangles honour the real elevation profile from the first figure (the same double-valley shape) and grade from a 20 m surface cell near the 51 station markers out to 600 m depth. 20 m follows directly from the median apparent resistivity at this profile’s highest frequency (2729 Hz): a ~454 m skin depth, and the “~0.03-0.05 skin depths at the first layer” sizing guidance build_graded_tri_mesh documents for TriFEM2DAdapter accuracy. 600 m depth is a demonstration choice, comfortably below the 82 m of surface relief, not a resolved investigation depth for this survey.#

18.8.11. Train And Gate A Maxwell AI Inversion#

Inv2DAgent(physics="mt2d_tri") trains a graph-convolutional network directly on this mesh’s own triangle-adjacency graph, solving each synthetic training realization with a real forward operator (TriFEM2DAdapter, no external binary), the same in-process path Map Groundwater Geology From CSAMT uses for Tongkeng. Training needs one shared frequency band across every station; gate-dropping in the QC section above did not remove the same gates everywhere, so the 51 stations do not all carry the same 25-point grid any more – restrict to what they still share:

>>> from pycsamt.site.edit import select_freq_all
>>> usable = select_freq_all(corrected, fmin=125.0)
>>> [len(s.freq) for s in usable][:3], len(usable)
([15, 15, 15], 51)

125.0-2729.0 Hz, 15 of the original 25 frequencies, survives at every station – the ten lowest-frequency (latest-time, deepest-sensitivity) gates are exactly the ones the earlier noise-floor QC trimmed unevenly station to station.

View the executed training, gating, and figure-copy codeClick to inspect and copy the complete code
 1def run_ai_inversion(
 2    corrected_sites,
 3    *,
 4    n_train_profiles: int = 100,
 5    epochs: int = 100,
 6    patience: int = 15,
 7    seed: int = 0,
 8):
 9    """Train and gate a real ``Inv2DAgent(physics="mt2d_tri")`` run.
10
11    Not part of the default ``__main__`` regeneration below -- at these
12    settings (100 realizations x 15 frequencies x 51 stations, each a real
13    :class:`~pycsamt.forward.maxwell.tri_fem2d.TriFEM2DAdapter` solve) this
14    takes on the order of 15 minutes. Call it explicitly.
15
16    Restricts to the 15-frequency band (125.0-2729.0 Hz) every one of the
17    51 stations shares after the noise-floor gate drop -- some stations
18    kept as few as 15 of 25 gates, so this, not an arbitrary round number,
19    is the true common band.
20    """
21    import numpy as np
22
23    from pycsamt.agents import Inv2DAgent
24    from pycsamt.forward.maxwell.tri_fem2d import TriFEM2DAdapter
25    from pycsamt.site.edit import select_freq_all
26
27    try:
28        import torch
29
30        torch.manual_seed(seed)
31    except ImportError:
32        pass
33    np.random.seed(seed)
34
35    usable = select_freq_all(corrected_sites, fmin=125.0)
36    freqs_hz = list(list(usable)[0].freq)
37
38    station_x = np.array([float(s.name.split("_")[1]) for s in usable])
39    order = np.argsort(station_x)
40    station_x = station_x[order]
41    elevation = np.array([s.coords[2] for s in usable])[order]
42    topo_z = elevation.max() - elevation
43
44    agent = Inv2DAgent(
45        physics="mt2d_tri",
46        epochs=epochs,
47        patience=patience,
48        n_freqs=len(freqs_hz),
49        depth_max=600.0,
50        n_train_profiles=n_train_profiles,
51        n_stations_per_profile=len(station_x),
52        station_spacing_m=20.0,
53        mesh_target_cell_m=20.0,
54        field_grid_cell_m=10.0,
55        correlation_length_x_m=(100.0, 350.0),
56        correlation_length_z_m=(40.0, 150.0),
57        topo_x_m=station_x,
58        topo_z_m=topo_z,
59        mare2dem_adapter=TriFEM2DAdapter(),
60    )
61    result = agent.execute(
62        {
63            "sites": usable,
64            "freqs": freqs_hz,
65            "output_dir": str(RESULTS / "ai2d_tri"),
66        }
67    )
68    if result.status == "success":
69        import shutil
70
71        fig_path = result.data["figure_paths"].get("inv2d_tri_section")
72        if fig_path:
73            shutil.copy(fig_path, OUT / "tem100_ai2d_tri_section.png")
74    return result

100 training realizations at up to 100 epochs, with validation-based early stopping (patience=15) rather than a fixed epoch count – a real step up from the 40-realization/10-station teaching-scale run Map Groundwater Geology From CSAMT uses, though still far from a production configuration:

>>> from docs.scripts.generate_tutorial_temavg_workflow import run_ai_inversion
>>> result = run_ai_inversion(corrected)
>>> result.status, result.data["pred_triangles"]["mesh"].n_triangles
('success', 486)
>>> result.data["epochs_completed"]
29

Early stopping cut training off at epoch 29 of the 100 requested, not because of a crash or a fixed budget – validation loss stopped improving for 15 consecutive epochs after its best point and training restored that best checkpoint rather than the final one:

>>> h = result.data["training_history"]
>>> round(h["train_loss"][0], 4), round(h["train_loss"][-1], 4)
(0.9852, 0.5994)
>>> round(h["val_loss"][0], 4), round(h["val_loss"][-1], 4)
(2.4569, 2.1756)
>>> round(result.data["best_validation_loss"], 4)
1.253

Training loss falls steadily throughout, the model keeps fitting its own training realizations better every epoch, exactly as expected. Validation loss does not track it down the same way, which is the entire reason patience exists: a model still being rewarded on training data while stalling (or worsening) on held-out data is the definition of starting to overfit, and the checkpoint this run actually kept is from the epoch where validation loss was lowest, not epoch 29’s.

Triangular-mesh AI inversion resistivity section for the TEM100 profile, draped over real topography, with labelled stations along the true surface.

The direct per-triangle log10(resistivity) prediction. Station labels thin to every fifth one (11 of 51) to stay legible – the same StationAxisStyle thinning every other station-axis figure in this documentation set uses, not a special case for this line. A broad, resistive core sits under the shallow saddle near the profile centre, more conductive toward both ends at depth – plausible in general shape, but see the gate immediately below before reading anything more specific into it.#

18.8.12. Gate The Result Before Interpretation#

The same fixed-in-advance thresholds Map Groundwater Geology From CSAMT uses, checked against this run’s actual held-out recovery:

>>> recovery = result.data["mt2d_tri_recovery"]
>>> round(recovery["rmse"], 4), round(recovery["r2"], 4), recovery["n_samples"]
(0.5499, -0.2143, 10)
>>> recovery_pass = recovery["rmse"] <= 0.25 and recovery["r2"] >= 0.60
>>> enough_test_models = recovery["n_samples"] >= 20
>>> promote = recovery_pass and enough_test_models
>>> promote
False

A negative \(R^2\) means this run does not reconstruct held-out geology any better than predicting the training mean would – a real, informative failure, not close to passing, even with early stopping doing its job and 2.5x Tongkeng’s teaching-scale realization count. It is a genuine improvement over an otherwise-identical run this page’s own draft made before the station-label fix below (RMSE 0.673, \(R^2=-0.831\), 21 epochs) – and that gap, from nothing but a different random initialization under the same seed=0, is the same known GCN reproducibility gap AI inversion agents documents for Inv3DAgent: seeding narrows run-to-run variation, it does not eliminate it. Treat today’s numbers as one representative run, and the specific station-marker fix below as the reason this page’s figure is legible at all, not as evidence this configuration is close to production-ready.

100 realizations, 51 stations, and 15 frequencies is real progress over a ten-station teaching example, but a production run needs the same scale-up Map Groundwater Geology From CSAMT recommends and found genuinely hard to get right: hundreds of realizations, multiple seeds, and validation-based early stopping applied consistently rather than as an afterthought – that tutorial’s own --production run, which disabled early stopping, made results dramatically worse, not better.

18.8.13. A Real Station-Label Bug, Found By Running This At 51 Stations#

Inv2DAgent’s mt2d_tri figure code labels every station by name above its marker. That is legible for Tongkeng’s 10 stations, and was tested only at that scale before this page: at TEM100’s 51, the labels collided into unreadable clutter and ran straight through the figure title. The fix draws only a thinned subset of labels – StationAxisStyle’s own label_indices logic, already used for every other station-axis figure in this documentation set, just not wired into this one until now – and moves the title clear of them with a real points-based pad rather than an axes-fraction margin, because PlotConfig’s default bbox_inches="tight" crops empty margin away at save time and silently undid a first attempt at the fix. Both the figure above and Map Groundwater Geology From CSAMT’s own csamt_ai2d_tri_section.png were regenerated with the corrected code.

Before feeding the corrected collection or this mesh into Prepare A MARE2DEM Inversion, remember that the geographic anchor is still the labelled best-effort guess from earlier on this page, not a confirmed survey location – the mesh itself is in local chainage metres and is unaffected by that uncertainty, but any step that needs true geographic coordinates (a basemap, a distance to a known feature) is not. MARE2DEM’s .emdata conversion and the resistivity-grid design that becomes its own triangulated mesh are exactly where that tutorial picks up.