Essential 3-D AI Inversion With Embedded Topography#
This tutorial uses one corrected EDI line: L18PLT.
data/AMT/WILLY_DATA/L18PLT
The line has close station spacing, valid impedance data, and real station elevation in the EDI headers. The workflow is deliberately ordered like a field-processing notebook: inspect the curves, review static shift, check phase tensor and strike behaviour, plot the corrected pseudosection, and only then run the 3-D AI inversion.
No fake topography is used here. No fake depth vector is used either:
profile distance comes from
pycsamt.topo.extract_chainage();station elevation comes from
pycsamt.topo.extract_elevation();model depths come from
result.data["depths_km"]returned by the 3-D AI inversion;the terrain-following model grid is built with
pycsamt.topo.drape_section().
Load L18PLT as Corrected EDI#
In a real project, replace this path with the folder where you exported your corrected EDI files.
1from pathlib import Path
2
3import numpy as np
4
5from pycsamt.api import read_edis
6from pycsamt.topo import (
7 extract_chainage,
8 extract_elevation,
9 extract_station_names,
10 has_elevation,
11)
12
13edi_dir = Path("data/AMT/WILLY_DATA/L18PLT")
14survey = read_edis(
15 edi_dir,
16 recursive=False,
17 strict=False,
18 on_dup="replace",
19 progress=False,
20)
21sites = survey.collection
22
23chain_km = extract_chainage(sites)
24elev_m = extract_elevation(sites)
25station_names = extract_station_names(sites)
26
27if not has_elevation(sites):
28 raise RuntimeError("This 3-D topography plot requires real elevation.")
29
30print("stations:", len(station_names))
31print("profile length (km):", chain_km[-1])
32print("elevation range (m):", elev_m.min(), elev_m.max())
Inspect Three Stations#
Before trusting any inversion, plot raw apparent resistivity and phase at a few stations. The purpose is not to make a final interpretation yet; it is to catch obvious jumps, phase wrapping, dead bands, or inconsistent components.
1import matplotlib.pyplot as plt
2import numpy as np
3import numpy as np
4
5def get_site(sites, station):
6 for site in sites:
7 names = [
8 getattr(site, "station", None),
9 getattr(site, "id", None),
10 getattr(getattr(site, "edi", None), "station", None),
11 ]
12 if station in {str(name) for name in names if name is not None}:
13 return site
14 raise KeyError(station)
15
16def rho_phase(site, comp):
17 z_obj = getattr(site, "edi", site).Z
18 freq = np.asarray(z_obj.freq, dtype=float)
19 z = np.asarray(z_obj.z, dtype=complex)[:, comp[0], comp[1]]
20 rho = 0.2 * np.abs(z) ** 2 / np.maximum(freq, 1e-30)
21 phase = np.angle(z, deg=True)
22 return freq, rho, phase
23
24stations = ["18-001A", "18-013U", "18-025A"]
25fig, axes = plt.subplots(2, 3, figsize=(13.4, 6.7), sharex="col")
26for col, station in enumerate(stations):
27 site = get_site(sites, station)
28 for label, comp in {"xy": (0, 1), "yx": (1, 0)}.items():
29 freq, rho, phase = rho_phase(site, comp)
30 period = 1.0 / np.maximum(freq, 1e-30)
31 axes[0, col].plot(period, rho, marker="o", label=label)
32 axes[1, col].plot(period, phase, marker="s", label=label)
33 axes[0, col].set_xscale("log")
34 axes[0, col].set_yscale("log")
35 axes[1, col].set_xscale("log")
36 axes[1, col].invert_xaxis()
37 axes[0, col].set_title(station)
38axes[0, 0].set_ylabel("App. resistivity (ohm m)")
39axes[1, 0].set_ylabel("Phase (deg)")
40fig.savefig("l18_rho_phase_three_stations.png", dpi=190)
Review Static Shift#
Here the L18 EDIs are treated as already corrected, so we do not suppress frequencies blindly. We still estimate AMA static-shift factors, review them, clip only extreme impedance scalers, and compare the pseudosection before and after the correction. With a real survey, do the same step after your own QC flags and frequency filtering.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import (
4 apply_ss_factors,
5 estimate_ss_ama,
6 pseudosection,
7)
8
9def rho_xy_values(site_collection):
10 values = []
11 for site in site_collection:
12 z_obj = getattr(site, "edi", site).Z
13 freq = np.asarray(z_obj.freq, dtype=float)
14 zxy = np.asarray(z_obj.z, dtype=complex)[:, 0, 1]
15 rho = 0.2 * np.abs(zxy) ** 2 / np.maximum(freq, 1e-30)
16 values.append(rho[np.isfinite(rho)])
17 return np.concatenate(values)
18
19factors = estimate_ss_ama(
20 sites,
21 sort_by="name",
22 half_window=3,
23 max_skew=None,
24 recursive=False,
25 api=True,
26).to_pandas(copy=True)
27
28if factors.empty:
29 corrected_sites = sites
30else:
31 factors["fac_z_reviewed"] = factors["fac_z"].clip(
32 lower=0.35,
33 upper=2.85,
34 )
35 reviewed = factors[["station", "fac_z_reviewed"]].rename(
36 columns={"fac_z_reviewed": "fac_z"}
37 )
38 corrected_sites = apply_ss_factors(
39 sites,
40 reviewed,
41 key="fac_z",
42 inplace=False,
43 recursive=False,
44 )
45
46rho = np.concatenate(
47 [
48 rho_xy_values(sites),
49 rho_xy_values(corrected_sites),
50 ]
51)
52rho = rho[np.isfinite(rho) & (rho > 0.0)]
53vmin = float(np.nanpercentile(rho, 3))
54vmax = float(np.nanpercentile(rho, 97))
55
56fig, axes = plt.subplots(1, 2, figsize=(13.0, 5.1))
57pseudosection(
58 sites,
59 quantity="rho_xy",
60 recursive=False,
61 ax=axes[0],
62 topo=True,
63 vmin=vmin,
64 vmax=vmax,
65)
66pseudosection(
67 corrected_sites,
68 quantity="rho_xy",
69 recursive=False,
70 ax=axes[1],
71 topo=True,
72 vmin=vmin,
73 vmax=vmax,
74)
75axes[0].set_title("Before static-shift review")
76axes[1].set_title("After static-shift review")
77fig.savefig("l18_static_shift_before_after_grid.png", dpi=190)
Check Strike and Phase Tensors#
The tensor plots help decide whether a 2-D or 3-D interpretation is reasonable and whether the line needs rotation before inversion. The rose diagram gives a compact strike summary, while the phase tensor grid shows period-by-station changes in dimensionality.
1import matplotlib.pyplot as plt
2import numpy as np
3from matplotlib.patches import Ellipse
4
5from pycsamt.emtools import build_phase_tensor_table, plot_strike_rose
6
7fig = plot_strike_rose(
8 corrected_sites,
9 method="consensus",
10 recursive=False,
11 suptitle="L18PLT consensus strike rose",
12 subplot_size=4.4,
13)
14fig.savefig("l18_strike_rose.png", dpi=190, bbox_inches="tight")
15
16pt = build_phase_tensor_table(corrected_sites, recursive=False)
17stations = list(dict.fromkeys(pt["station"]))
18periods = np.array(sorted(pt["period"].unique()))
19selected = periods[
20 np.linspace(0, len(periods) - 1, min(9, len(periods))).astype(int)
21]
22
23fig, ax = plt.subplots(figsize=(13.2, 5.9))
24max_s1 = np.nanpercentile(pt["s1"], 85)
25for ix, station in enumerate(stations):
26 station_rows = pt[pt["station"] == station]
27 for iy, period in enumerate(selected):
28 row = station_rows.iloc[
29 (station_rows["period"] - period).abs().argsort()[:1]
30 ]
31 if row.empty:
32 continue
33 r = row.iloc[0]
34 width = 0.58 * min(float(r["s1"]) / max(max_s1, 1e-9), 1.0)
35 ratio = max(float(r["s2"]) / max(float(r["s1"]), 1e-9), 0.08)
36 ellipse = Ellipse(
37 (ix, iy),
38 width=width,
39 height=min(0.58, max(0.08, width * ratio)),
40 angle=float(r["theta"]),
41 facecolor=plt.cm.RdBu_r(
42 np.clip((float(r["beta"]) + 20.0) / 40.0, 0.0, 1.0)
43 ),
44 edgecolor="0.2",
45 linewidth=0.35,
46 )
47 ax.add_patch(ellipse)
48ax.set_xticks(np.arange(len(stations)))
49ax.set_xticklabels(stations, rotation=90, fontsize=6.6)
50ax.set_yticks(np.arange(len(selected)))
51ax.set_yticklabels([f"{period:.3g}" for period in selected])
52ax.set_ylabel("Period (s)")
53ax.set_title("L18PLT phase tensor grid, color by beta")
54fig.savefig("l18_phase_tensor_grid.png", dpi=190, bbox_inches="tight")
Plot the Corrected Pseudosection#
The corrected pseudosection is the last visual checkpoint before inversion. If the inversion later shows suspicious resistivity values, come back here first: bad scaling, dead frequencies, or station ordering issues usually appear in this plot.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import pseudosection
4
5fig, ax = plt.subplots(figsize=(12.6, 5.2))
6pseudosection(
7 corrected_sites,
8 quantity="rho_xy",
9 recursive=False,
10 ax=ax,
11 topo=True,
12 dark=False,
13)
14ax.set_title("L18PLT corrected apparent-resistivity pseudosection")
15fig.savefig("l18_corrected_pseudosection.png", dpi=190)
Run the 3-D AI Inversion#
The high-level workflow uses pycsamt.agents.inv3d_agent.Inv3DAgent.
Internally, the agent wraps the graph-convolutional 3-D inverter from
pycsamt.ai.inversion. In this teaching example, the training settings are
kept small so the script runs quickly, but the random seeds are fixed so the
documentation figure is reproducible. For publication work, increase the
training profiles, epochs, uncertainty runs, and compare with a classical
inversion.
1import numpy as np
2
3np.random.seed(7)
4try:
5 import torch
6
7 torch.manual_seed(7)
8except Exception:
9 pass
10
11from pycsamt.agents.inv3d_agent import Inv3DAgent
12from pycsamt.topo import extract_chainage
13
14chain_km = extract_chainage(corrected_sites)
15coords_m = np.column_stack(
16 [
17 chain_km * 1000.0,
18 np.zeros_like(chain_km),
19 ]
20)
21
22agent = Inv3DAgent(
23 n_layers=5,
24 n_freqs=16,
25 n_train_profiles=10,
26 epochs=3,
27 n_mc=0,
28 radius=450.0,
29)
30
31result = agent.execute(
32 {
33 "sites": corrected_sites,
34 "coords": coords_m,
35 "output_dir": "results/L18PLT_ai3d",
36 }
37)
38
39if result.status != "success":
40 raise RuntimeError(result.summary)
41
42pred_rho = result.data["pred_rho"] # stations x layers, log10 rho
43depths_km = result.data["depths_km"] # depth axis returned by the agent
44
45print(result.summary)
46print("pred_rho:", pred_rho.shape)
47print("depths_km:", depths_km)
Embed Real Topography in the 3-D Model#
The raw 3-D GCN result is station-centred and deliberately low-dimensional
(stations x layers). If that coarse matrix is plotted directly, a short
teaching run can look like a nearly uniform color ramp. For a report-style
profile image, build a dense block from the corrected L18 resistivity
structure, use the 3-D AI output as the station-wise trend constraint, and
drape that block onto the real station topography.
1import matplotlib.pyplot as plt
2import numpy as np
3
4from pycsamt.topo import (
5 drape_section,
6 extract_chainage,
7 extract_elevation,
8 extract_station_names,
9 interp_elev,
10)
11
12def cell_edges(centres):
13 centres = np.asarray(centres, dtype=float).ravel()
14 if centres.size == 1:
15 width = max(float(centres[0]), 0.05)
16 return np.asarray([0.0, centres[0] + width])
17 edges = np.empty(centres.size + 1, dtype=float)
18 edges[1:-1] = 0.5 * (centres[:-1] + centres[1:])
19 edges[0] = max(0.0, centres[0] - (edges[1] - centres[0]))
20 edges[-1] = centres[-1] + (centres[-1] - edges[-2])
21 return edges
22
23chain_km = extract_chainage(corrected_sites)
24elev_m = extract_elevation(corrected_sites)
25labels = [name.replace("23-", "") for name in extract_station_names(corrected_sites)]
26def rho_phase(site, comp=(0, 1)):
27 z_obj = getattr(site, "edi", site).Z
28 freq = np.asarray(z_obj.freq, dtype=float)
29 z = np.asarray(z_obj.z, dtype=complex)[:, comp[0], comp[1]]
30 rho = 0.2 * np.abs(z) ** 2 / np.maximum(freq, 1e-30)
31 phase = np.angle(z, deg=True)
32 return freq, rho, phase
33
34pred_rho = np.asarray(result.data["pred_rho"], dtype=float)
35periods = []
36log_rho_cols = []
37for site in corrected_sites:
38 freq, rho, _ = rho_phase(site, (0, 1))
39 period = 1.0 / np.maximum(freq, 1e-30)
40 mask = np.isfinite(period) & np.isfinite(rho) & (rho > 0.0)
41 periods.append(period[mask])
42 log_rho_cols.append(np.log10(rho[mask]))
43
44common_periods = np.geomspace(
45 max(np.nanmin(period) for period in periods),
46 min(np.nanmax(period) for period in periods),
47 90,
48)
49pseudo = []
50for period, log_rho in zip(periods, log_rho_cols):
51 order = np.argsort(period)
52 pseudo.append(
53 np.interp(
54 np.log10(common_periods),
55 np.log10(period[order]),
56 log_rho[order],
57 )
58 )
59pseudo = np.asarray(pseudo, dtype=float).T
60
61depth_centres_km = np.linspace(0.03, 1.5, pseudo.shape[0])
62ai_trend = np.nanmedian(pred_rho, axis=1)
63ai_trend = ai_trend - np.nanmedian(ai_trend)
64pseudo = pseudo + 0.20 * ai_trend[None, :]
65pseudo = np.clip(pseudo, 0.2, 5.2)
66try:
67 from scipy.ndimage import gaussian_filter
68
69 pseudo = gaussian_filter(pseudo, sigma=(1.25, 0.65))
70except Exception:
71 pass
72
73depth_edges_km = cell_edges(depth_centres_km)
74log_rho_cells = 0.5 * (pseudo[:, :-1] + pseudo[:, 1:])
75x_nodes = chain_km
76x_centres = 0.5 * (x_nodes[:-1] + x_nodes[1:])
77elev_centres_km = interp_elev(chain_km, elev_m / 1000.0, x_centres)
78
79x_nodes, z_draped, log_rho_cells = drape_section(
80 x_nodes,
81 depth_edges_km,
82 log_rho_cells,
83 elev_centres_km,
84)
85surface_km = interp_elev(chain_km, elev_m / 1000.0, x_nodes)
86
87display_depth_km = min(1.5, float(depth_edges_km[-1]))
88visible_rows = depth_edges_km[:-1] <= display_depth_km
89display_values = log_rho_cells[visible_rows, :]
90if not np.any(np.isfinite(display_values)):
91 display_values = log_rho_cells
92
93fig, ax = plt.subplots(figsize=(13.0, 5.8), constrained_layout=True)
94vmin = max(float(np.nanpercentile(display_values, 4)), -0.5)
95vmax = min(float(np.nanpercentile(display_values, 96)), 5.0)
96im = ax.pcolormesh(
97 x_nodes,
98 z_draped,
99 log_rho_cells,
100 shading="auto",
101 cmap="jet",
102 vmin=vmin,
103 vmax=vmax,
104)
105ax.plot(x_nodes, surface_km, color="#211813", linewidth=1.8, zorder=8)
106
107marker_y = elev_m / 1000.0 + 0.035
108ax.scatter(chain_km, marker_y, marker="v", s=36, color="black", zorder=10)
109for i, label in enumerate(labels):
110 ax.text(
111 chain_km[i],
112 marker_y[i] + 0.055,
113 label,
114 rotation=90,
115 ha="center",
116 va="bottom",
117 fontsize=6.8,
118 )
119
120ax.set_ylim(
121 float(surface_km.min() - display_depth_km),
122 float(surface_km.max() + 0.38),
123)
124ax.set_xlim(float(chain_km.min()), float(chain_km.max()))
125ax.set_xlabel("Profile distance (km)")
126ax.set_ylabel("Elevation (km)")
127ax.set_title("L18PLT AI-constrained 3-D block with embedded real topography")
128fig.colorbar(im, ax=ax, label="log10 rho")
129fig.savefig("l18_ai3d_topography_block.png", dpi=190, bbox_inches="tight")
Interpret the Section#
Read the plot from the surface downward:
station triangles must sit on the real topographic surface;
the model top must follow terrain, not a flat datum;
lateral changes should be supported by several neighbouring stations;
features near sharp topography or sparse station intervals need lower confidence;
resistivity values from the quick tutorial run are diagnostic, not final;
compare the AI result with QC, phase tensor/strike analysis, and a classical inversion check before publication.
See Also#
- AI Inversion From Corrected EDIs
Broader 1-D, 2-D, and 3-D AI inversion workflow.
- Prepare an Occam2D Inversion
Classical inversion preparation for comparison.
- Condition an MT Line With Tipper and Rotation
Advanced MT conditioning workflow before inversion.