Anisotropy Diagnostics#

pycsamt.emtools.anisotropy is the user-facing workflow for checking whether a CSAMT/AMT impedance tensor behaves like an isotropic 1-D/2-D earth, or whether the two off-diagonal modes and the tensor diagonal terms suggest axial anisotropy or 3-D structure.

The module follows the axial-anisotropy diagnostic idea described by Wang and Tan (2017) for CSAMT data. In a practical field workflow, the method answers three concrete questions:

  • do \(Z_{xy}\) and \(Z_{yx}\) imply similar apparent resistivities?

  • do the tensor diagonal terms create a large Swift skew?

  • are the suspicious responses isolated single-frequency spikes, or coherent station-period patterns?

This page explains how to use the three workflow functions in the user guide context. Full callable signatures are intentionally left to the API reference.

Why This Matters#

In ideal 1-D MT/CSAMT, the off-diagonal impedance modes carry the useful response and the diagonal terms are close to zero. Real survey data are messier. Galvanic distortion, 3-D bodies, local conductors, acquisition noise, and genuine electrical anisotropy can all make the tensor depart from that ideal form.

The anisotropy module does not prove a geological model by itself. It is a diagnostic layer. Use it after loading and quality control, before trusting a 1-D inversion, and alongside phase-tensor, strike, static shift, and map views.

The Core Quantities#

For each station and frequency, the implementation reads the full impedance tensor:

\[\begin{split}Z = \begin{bmatrix} Z_{xx} & Z_{xy} \\ Z_{yx} & Z_{yy} \end{bmatrix}\end{split}\]

The two Cagniard apparent resistivities are computed from the off-diagonal modes using the practical-unit convention used by EDI impedance values in pyCSAMT:

\[\rho_{xy} = 0.2 {|Z_{xy}|^2 \over f} \qquad \rho_{yx} = 0.2 {|Z_{yx}|^2 \over f}\]

where f is frequency in hertz. This practical-unit formula is the one used by the code. It is different from the SI expression \(|Z|^2 / (\omega\mu_0)\), which applies when impedance is stored in SI ohms.

The main ratio metric is:

\[\Lambda = \log_{10}\left({\rho_{xy} \over \rho_{yx}}\right)\]

Interpret it as a signed mode contrast:

  • ratio_log10 = 0 means both modes have the same apparent resistivity.

  • ratio_log10 = 0.1 means \(\rho_{xy}\) is about 1.26 times \(\rho_{yx}\).

  • ratio_log10 = 0.3 means a factor of about 2.

  • negative values mean \(\rho_{yx}\) is larger than \(\rho_{xy}\).

The module also computes Swift skew:

\[S = {|Z_{xx} - Z_{yy}| \over |Z_{xy} + Z_{yx}|}\]

and a per-frequency Swift strike angle. The skew is useful because it uses the diagonal tensor terms, while the ratio uses the off-diagonal modes. Those two indicators are related, but they are not redundant.

Data Contract#

The functions accept the same flexible sites input as the rest of emtools:

  • a directory containing EDI files,

  • one EDI-like object,

  • a Sites container,

  • an iterable of site-like objects.

Internally, the module calls ensure_sites. That means duplicate-file, recursive-loading, strict-mode, and verbosity behavior is consistent with the rest of the pyCSAMT user guide.

Use dense CSAMT/AMT frequency coverage when possible. Sparse long-period natural-source MT can still be passed to the functions, but the station-period patterns are usually less stable and less diagnostic.

Workflow Overview#

The usual workflow is:

  1. Load a survey line.

  2. Compute the per-frequency detail table.

  3. Collapse it to a per-station summary.

  4. Plot one or more station x period pseudo-sections.

  5. Interpret broad patterns before isolated extreme pixels.

 1from pathlib import Path
 2
 3from pycsamt.emtools.anisotropy import (
 4    analyze_anisotropy,
 5    anisotropy_table,
 6    plot_anisotropy,
 7)
 8
 9edi_dir = Path("data/AMT/WILLY_DATA/L18PLT")
10
11detail = analyze_anisotropy(edi_dir)
12summary = anisotropy_table(edi_dir)
13ax = plot_anisotropy(edi_dir, metric="ratio_log10")
../../_images/user-guide-emtools-anisotropy-01.png

Lines 8-12 are the complete workflow. The same edi_dir is accepted by all three functions because each function delegates loading to the shared site loader.

Per-Frequency Detail#

Use analyze_anisotropy when you need the raw station-frequency diagnostics. It returns one row for each station and frequency.

 1from pathlib import Path
 2
 3from pycsamt.emtools.anisotropy import analyze_anisotropy
 4
 5edi_dir = Path("data/AMT/WILLY_DATA/L18PLT")
 6
 7detail = analyze_anisotropy(
 8    edi_dir,
 9    ratio_threshold=0.1,
10    skew_threshold=0.2,
11    recursive=True,
12    on_dup="replace",
13    strict=False,
14    verbose=0,
15)
16
17cols = [
18    "station",
19    "freq_hz",
20    "period_s",
21    "rho_xy_ohmm",
22    "rho_yx_ohmm",
23    "ratio_log10",
24    "swift_skew",
25    "strike_deg",
26]
27
28print(detail[cols].head())
29detail.to_csv("l18plt_anisotropy_detail.csv", index=False)
   station  freq_hz  period_s  ...  ratio_log10  swift_skew  strike_deg
0  18-001A  10400.0  0.000096  ...    -0.107418    4.293444  -77.840209
1  18-001A   8707.0  0.000115  ...    -0.124451    4.021890  -76.385301
2  18-001A   7289.0  0.000137  ...    -0.145718    3.846208  -75.453650
3  18-001A   6102.0  0.000164  ...    -0.247039    2.369018  -67.350131
4  18-001A   5108.0  0.000196  ...    -0.397532    1.729795  -60.108794

[5 rows x 8 columns]

The important output columns are:

  • station: station name resolved from the loaded site.

  • freq_hz and period_s: frequency and inverse frequency.

  • rho_xy_ohmm and rho_yx_ohmm: practical-unit apparent resistivities from the two off-diagonal modes.

  • phi_xy_deg and phi_yx_deg: phase of the two off-diagonal modes.

  • ratio_log10: \(\log_{10}(\rho_{xy}/\rho_{yx})\).

  • phase_diff_deg: phi_xy_deg - phi_yx_deg.

  • swift_skew: Swift skew from the full tensor.

  • strike_deg: Swift strike angle in degrees.

The threshold arguments are accepted here for workflow consistency, but the detail table itself does not add a Boolean flag. The thresholds are used when the detail table is collapsed by anisotropy_table.

Single-Station Inspection#

Before interpreting a pseudo-section, inspect one or two stations as curves. This makes it easier to distinguish coherent frequency trends from isolated spikes.

 1import matplotlib.pyplot as plt
 2
 3from pycsamt.emtools.anisotropy import (
 4    ANISO_RATIO_THRESH,
 5    SWIFT_SKEW_THRESH,
 6    analyze_anisotropy,
 7)
 8
 9detail = analyze_anisotropy("data/AMT/WILLY_DATA/L18PLT")
10
11station = "18-009A"
12one = detail.loc[detail["station"] == station].sort_values("period_s")
13
14fig, (ax_ratio, ax_skew) = plt.subplots(
15    2,
16    1,
17    figsize=(7, 6),
18    sharex=True,
19)
20
21ax_ratio.semilogx(one["period_s"], one["ratio_log10"], "o-")
22ax_ratio.axhline(ANISO_RATIO_THRESH, color="0.4", linestyle="--")
23ax_ratio.axhline(-ANISO_RATIO_THRESH, color="0.4", linestyle="--")
24ax_ratio.set_ylabel("log10(rho_xy / rho_yx)")
25
26ax_skew.semilogx(one["period_s"], one["swift_skew"], "o-", color="C3")
27ax_skew.axhline(SWIFT_SKEW_THRESH, color="0.4", linestyle="--")
28ax_skew.set_xlabel("Period (s)")
29ax_skew.set_ylabel("Swift skew")
30
31fig.suptitle(f"{station} anisotropy diagnostics")
32fig.tight_layout()
../../_images/user-guide-emtools-anisotropy-03.png

Line 12 selects one station. Lines 21-23 draw the ratio threshold band, and line 26 draws the Swift skew threshold. If both curves stay above their thresholds over many neighboring periods, the station deserves more attention than a station with one isolated outlier.

Per-Station Summary#

Use anisotropy_table when you want one row per station. It calls analyze_anisotropy internally, then groups by station.

 1from pycsamt.emtools.anisotropy import anisotropy_table
 2
 3table = anisotropy_table(
 4    "data/AMT/WILLY_DATA/L18PLT",
 5    ratio_threshold=0.1,
 6    skew_threshold=0.2,
 7)
 8
 9ranked = (
10    table.assign(abs_mean_ratio=table["mean_ratio_log10"].abs())
11    .sort_values(
12        ["anisotropy_flag", "abs_mean_ratio", "mean_swift_skew"],
13        ascending=[False, False, False],
14    )
15)
16
17print(
18    ranked[
19        [
20            "station",
21            "n_freq",
22            "mean_ratio_log10",
23            "max_abs_ratio_log10",
24            "mean_swift_skew",
25            "median_strike_deg",
26            "anisotropy_flag",
27        ]
28    ].head(10)
29)
    station  n_freq  ...  median_strike_deg  anisotropy_flag
15  18-016A      53  ...          -7.682709             True
16  18-017U      53  ...          -3.811541             True
14  18-015U      53  ...           8.230015             True
17  18-018A      53  ...          13.908826             True
13  18-014A      53  ...          27.482707             True
9   18-010U      53  ...          24.372200             True
24  18-023A      53  ...          21.460012             True
8   18-009A      53  ...          24.360106             True
26  18-024U      53  ...         -29.242651             True
27  18-025A      53  ...         -20.822334             True

[10 rows x 7 columns]

The summary columns are:

  • n_freq: number of frequencies contributing to the station.

  • mean_ratio_log10: signed average ratio over frequency.

  • max_abs_ratio_log10: largest absolute ratio over frequency.

  • mean_phase_diff_deg: average phase difference between modes.

  • mean_swift_skew: average Swift skew.

  • median_strike_deg: median Swift strike estimate.

  • anisotropy_flag: True when the station exceeds either default criterion.

The default criteria are:

1abs(mean_ratio_log10) > 0.1
2mean_swift_skew > 0.2

That flag is useful for screening, but it should not be your final interpretation. In real field lines, many stations can be flagged. The relative ranking and the station-period shape are usually more informative than the binary column alone.

Pseudo-Section Plotting#

Use plot_anisotropy to map one metric onto station x period space. This is the main visual diagnostic because it shows whether a response is spatially and spectrally coherent.

 1import matplotlib.pyplot as plt
 2
 3from pycsamt.emtools.anisotropy import plot_anisotropy
 4
 5edi_dir = "data/AMT/WILLY_DATA/L18PLT"
 6
 7fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharex=True)
 8
 9plot_anisotropy(edi_dir, metric="ratio_log10", ax=axes[0, 0])
10plot_anisotropy(edi_dir, metric="swift_skew", ax=axes[0, 1])
11plot_anisotropy(edi_dir, metric="phase_diff_deg", ax=axes[1, 0])
12plot_anisotropy(edi_dir, metric="strike_deg", ax=axes[1, 1])
13
14fig.tight_layout()
15fig.savefig("l18plt_anisotropy_metrics.png", dpi=200)
../../_images/user-guide-emtools-anisotropy-05.png

Available plot metrics are:

Metric

Meaning

ratio_log10

Signed \(\log_{10}(\rho_{xy}/\rho_{yx})\); the default and usually the first plot to inspect.

swift_skew

Full-tensor skew; useful for 3-D behavior and diagonal-term effects.

phase_diff_deg

Difference between the two off-diagonal phases.

strike_deg

Swift strike angle estimate in degrees.

For ratio_log10, the plotting function uses a diverging color range centered around zero and can draw a white zero contour. For other metrics, the color range follows the finite values in the grid.

Comparing Neighboring Lines#

The plotting function accepts an existing Matplotlib axis. That makes it easy to compare neighboring survey lines with the same metric.

 1import matplotlib.pyplot as plt
 2
 3from pycsamt.emtools.anisotropy import anisotropy_table, plot_anisotropy
 4
 5l18 = "data/AMT/WILLY_DATA/L18PLT"
 6l22 = "data/AMT/WILLY_DATA/L22PLT"
 7
 8fig, (ax18, ax22) = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
 9
10plot_anisotropy(l18, metric="ratio_log10", ax=ax18)
11ax18.set_title("L18PLT ratio")
12
13plot_anisotropy(l22, metric="ratio_log10", ax=ax22)
14ax22.set_title("L22PLT ratio")
15
16t18 = anisotropy_table(l18)
17t22 = anisotropy_table(l22)
18
19print("L18 mean |ratio|:", t18["mean_ratio_log10"].abs().mean())
20print("L22 mean |ratio|:", t22["mean_ratio_log10"].abs().mean())
21
22fig.tight_layout()
L18 mean |ratio|: 0.5836353106929171
L22 mean |ratio|: 0.5153467627986273
../../_images/user-guide-emtools-anisotropy-06.png

This comparison is useful for sanity checking. Neighboring lines do not need to match exactly, but a line with a completely different pattern should be checked for loading, station ordering, coordinate, or tensor quality issues before the difference is interpreted geologically.

Ratio And Skew Are Complementary#

Because the ratio and skew emphasize different tensor information, they can disagree. The following example quantifies the relationship across two lines.

 1import pandas as pd
 2import matplotlib.pyplot as plt
 3
 4from pycsamt.emtools.anisotropy import anisotropy_table
 5
 6t18 = anisotropy_table("data/AMT/WILLY_DATA/L18PLT").assign(line="L18PLT")
 7t22 = anisotropy_table("data/AMT/WILLY_DATA/L22PLT").assign(line="L22PLT")
 8both = pd.concat([t18, t22], ignore_index=True)
 9
10both["abs_ratio"] = both["mean_ratio_log10"].abs()
11corr = both["abs_ratio"].corr(both["mean_swift_skew"])
12
13fig, ax = plt.subplots(figsize=(6, 5))
14for line, group in both.groupby("line"):
15    ax.scatter(
16        group["mean_swift_skew"],
17        group["abs_ratio"],
18        label=line,
19        alpha=0.8,
20    )
21
22ax.set_xlabel("Mean Swift skew")
23ax.set_ylabel("Mean absolute ratio")
24ax.set_title(f"Ratio vs. skew, Pearson r = {corr:.2f}")
25ax.legend()
26fig.tight_layout()
../../_images/user-guide-emtools-anisotropy-07.png

If the two metrics correlate strongly, they are telling a similar story. If they do not, inspect both views. A station can have a strong \(\rho_{xy}/\rho_{yx}\) contrast with modest skew, or large skew with a modest mean ratio.

Reading The Results#

Use the following interpretation pattern:

  • Start with ratio_log10. Look for broad positive or negative zones that persist over multiple stations and periods.

  • Check swift_skew. Treat broad skew highs as important, but be cautious with single-pixel extremes.

  • Check phase_diff_deg if the apparent resistivity ratio is strong; phase disagreement can help separate stable tensor behavior from amplitude-only effects.

  • Use strike_deg as a directional clue, not as a unique structural solution. Strike estimates have ambiguity and can be unstable where the tensor is noisy.

  • Compare neighboring lines before making a geological statement from one profile.

Common Failure Modes#

Empty output

No valid impedance tensor was found. Check that the input path points to EDI files and that the files contain usable Z data.

All stations flagged

This can happen in real field data. Raise the thresholds only if you have a survey-specific reason. More often, keep the defaults and use rankings plus pseudo-sections for interpretation.

Huge isolated Swift skew

Swift skew divides by abs(Zxy + Zyx). If that denominator passes near zero at one frequency, a very large value can appear without a corresponding geological anomaly. Look for neighboring-period support.

Unstable strike

Strike is derived from tensor rotation. It is sensitive to noisy diagonal terms and carries the usual EM strike ambiguity. Interpret stable bands, not isolated values.

Sparse long-period data

The method is most useful with dense CSAMT/AMT frequency sweeps. With sparse natural-source MT, use the output as a rough diagnostic only.

Saving A Reproducible Diagnostic Bundle#

For reports, save both tables and the key figure. That gives reviewers the station-level ranking and the raw station-frequency values behind the plot.

 1from pathlib import Path
 2
 3import matplotlib.pyplot as plt
 4
 5from pycsamt.emtools.anisotropy import (
 6    analyze_anisotropy,
 7    anisotropy_table,
 8    plot_anisotropy,
 9)
10
11survey = Path("data/AMT/WILLY_DATA/L18PLT")
12out = Path("outputs/anisotropy_l18plt")
13out.mkdir(parents=True, exist_ok=True)
14
15detail = analyze_anisotropy(survey)
16table = anisotropy_table(survey)
17
18detail.to_csv(out / "anisotropy_detail.csv", index=False)
19table.to_csv(out / "anisotropy_table.csv", index=False)
20
21fig, ax = plt.subplots(figsize=(10, 5))
22plot_anisotropy(survey, metric="ratio_log10", ax=ax)
23fig.tight_layout()
24fig.savefig(out / "ratio_log10_pseudosection.png", dpi=200)
../../_images/user-guide-emtools-anisotropy-08.png

Line 15 preserves the detailed station-frequency data. Line 16 preserves the station summary. Lines 21-23 save the main pseudo-section used in interpretation.

Worked Example#

The gallery example uses L18PLT and L22PLT, two real AMT/CSAMT survey lines bundled in data/AMT/WILLY_DATA/. It moves from a single station curve, to station rankings, to pseudo-sections, and finally to a two-line comparison showing that ratio and skew can carry different information.

Open the rendered example here: CSAMT axial-anisotropy diagnostics (pycsamt.emtools.anisotropy).