Note
Go to the end to download the full example code.
Phase-tensor and Bahr skew diagnostics (pycsamt.emtools.skew)#
pycsamt.emtools.skew measures how far a station departs from a
simple 1-D/2-D structure using two independent skew formulas — the
classic Bahr (1988) invariant \(\eta\) computed directly from
\(Z\), and the Caldwell-Bibby-Bahr phase-tensor skew angle
\(\beta\) (via pycsamt.emtools.tensor) — then offers several
ways to mask, trim, or vote on frequencies where that skew is
acceptably low. This example uses L18PLT
(data/AMT/WILLY_DATA/), the same line the qc example already
flagged as high_skew at every one of its 28 stations under a
strict default threshold. That finding is not a fluke: this whole page
traces its consequences through every skew-based masking function in
the module.
1. Simple: the skew table#
skew_table() is a thin wrapper around
pycsamt.emtools.tensor.build_phase_tensor_table(), returning one
row per (station, frequency) with the phase-tensor skew angle
\(\beta\) (column skew, identical to column beta).
import matplotlib.pyplot as plt
import numpy as np
from _datasets import load_survey
from pycsamt.emtools import (
bahr_skewness,
close_skew_gaps,
ensure_sites,
keep_longest_low_skew,
mask_by_skew,
plot_skew_percentile_ribbon,
plot_skew_traffic_psection,
plot_skew_vote_band,
plot_skewness,
select_low_skew_band,
skew_table,
)
from pycsamt.emtools._core import (
_get_z_block,
_iter_items,
_name,
)
survey = load_survey("amt_l18plt")
table = skew_table(survey)
abs_skew = table["skew"].abs()
print(abs_skew.describe())
by_station = table.groupby("station")["skew"].apply(
lambda s: s.abs().median()
)
print("lowest-skew stations:\n", by_station.sort_values().head(3))
print("highest-skew stations:\n", by_station.sort_values().tail(3))
count 1484.000000
mean 44.536824
std 25.198206
min 0.353578
25% 23.593647
50% 40.992429
75% 66.994007
max 89.910303
Name: skew, dtype: float64
lowest-skew stations:
station
18-015U 22.459269
18-017U 22.912833
18-016A 23.525350
Name: skew, dtype: float64
highest-skew stations:
station
18-018A 66.547818
18-022V 66.787861
18-023A 67.022970
Name: skew, dtype: float64
Reading this output. Median \(|\beta|\) across all 1484
station-frequency rows is 41.0 degrees, mean 44.5 — this is the same
survey the qc example flagged high_skew at every station
under its strict default threshold, now seen directly at the source.
Even the lowest-median station, 18-015U, still runs 22.5
degrees. Two stations threaded through earlier examples land at
opposite ends here: 18-016A (flagged for extreme ratio
anisotropy) is actually among the least skewed (23.5), while
18-018A (also flagged for ratio anisotropy) is among the most
skewed (66.5) — a reminder that ratio anisotropy and phase-tensor
skew measure genuinely different things, even when the same stations
get flagged by both.
2. Bahr skewness: an independent formula#
bahr_skewness() computes the classic Bahr
(1988) invariant \(\eta\) straight from the complex impedance
tensor — no phase-tensor decomposition involved — and
plot_skewness() plots it against a 2D/3D
threshold.
s = ensure_sites(survey, recursive=False)
z_by_name = {}
for i, ed in enumerate(_iter_items(s)):
name = _name(ed, i)
if name in ("18-016A", "18-018A"):
_, z, fr = _get_z_block(ed)
z_by_name[name] = (z, fr)
fig, axes = plt.subplots(1, 2, figsize=(11.0, 4.2))
for ax, name in zip(axes, ("18-016A", "18-018A")):
z, fr = z_by_name[name]
eta = bahr_skewness(z)
print(
f"{name}: Bahr eta min/median/max = "
f"{np.nanmin(eta):.2f} / {np.nanmedian(eta):.2f} / {np.nanmax(eta):.2f}"
)
plot_skewness(fr, z, threshold=0.4, ax=ax, title=name)

18-016A: Bahr eta min/median/max = 1.01 / 1.16 / 2.34
18-018A: Bahr eta min/median/max = 0.76 / 1.12 / 2.92
Reading this output/figure. Bahr’s classic threshold is \(\eta=0.4\) (below: acceptably 2D; above: 3D or noise/distortion dominated) — every single point for both stations sits above it, and the “2D” wedge on the right of each panel is essentially unused. But the two stations’ average Bahr skewness is nearly identical (1.26 vs. 1.25) despite their very different phase-tensor \(\beta\) medians from section 1 (23.5 vs. 66.5 degrees) — the two independent skew formulas agree that both stations are unambiguously non-2D, but disagree sharply on which one is “worse”. Neither number is wrong; they simply weight the tensor’s asymmetry differently, a real reason to check more than one skew diagnostic before ranking stations by structural complexity.
3. Masking by skew threshold#
mask_by_skew() sets rows with
\(|\beta|\) above (or, in other modes, below) a threshold to NaN.
import logging # noqa: E402
logging.disable(logging.ERROR)
masked_default = mask_by_skew(survey, thresh=6.0)
masked_loose = mask_by_skew(survey, thresh=45.0)
logging.disable(logging.NOTSET)
def n_finite(sites, name):
ss = ensure_sites(sites, recursive=False)
for i, ed in enumerate(_iter_items(ss)):
if _name(ed, i) == name:
_, z, fr = _get_z_block(ed)
return int(np.isfinite(z).all(axis=(1, 2)).sum()), z.shape[0]
raise KeyError(name)
for name in ("18-001A", "18-016A", "18-018A"):
kept_d, total = n_finite(masked_default, name)
kept_l, _ = n_finite(masked_loose, name)
print(
f"{name}: thresh=6 (default) keeps {kept_d}/{total}; thresh=45 keeps {kept_l}/{total}"
)
18-001A: thresh=6 (default) keeps 2/53; thresh=45 keeps 22/53
18-016A: thresh=6 (default) keeps 5/53; thresh=45 keeps 40/53
18-018A: thresh=6 (default) keeps 1/53; thresh=45 keeps 9/53
Reading this output. At the module’s default thresh=6, almost
nothing survives — 2-6 of 53 frequencies per station, matching
section 1’s finding that even the calmest station sits at 22.5
degrees median. Raising the threshold to 45 (close to this survey’s
mean) recovers a much more usable fraction, without needing to
fabricate cleaner data: the function works exactly as documented, the
default is simply tuned for a far less skewed survey than this one.
4. Keeping only the longest low-skew run#
keep_longest_low_skew() finds the longest
contiguous run of low-skew frequencies and masks everything else —
unless no run reaches min_len, in which case it falls back to
fallback ("keep_all" by default).
logging.disable(logging.ERROR)
for name in ("18-016A", "18-018A"):
kept_strict, total = n_finite(
keep_longest_low_skew(survey, thresh=25.0, min_len=5),
name,
)
print(f"{name}: thresh=25, min_len=5 keeps {kept_strict}/{total}")
logging.disable(logging.NOTSET)
18-016A: thresh=25, min_len=5 keeps 16/53
18-018A: thresh=25, min_len=5 keeps 53/53
Reading this output. 18-016A (this survey’s calmer station)
genuinely gets trimmed to its longest acceptable run: 16 of 53
frequencies. 18-018A (the most skewed) keeps all 53 — not because
it passed, but because no contiguous run of 5 or more low-skew
frequencies exists anywhere in its spectrum, so the fallback quietly
returns everything unmasked. This is worth checking explicitly before
trusting a “kept everything” result: on a station this skewed, that
outcome means the threshold could not be satisfied at all, not that
the station is clean.
5. Closing small gaps in the low-skew mask#
close_skew_gaps() fills short interior
gaps (up to max_gap frequencies) inside an otherwise-passing
stretch, rather than fragmenting it into many tiny kept segments.
logging.disable(logging.ERROR)
for name in ("18-001A", "18-016A", "18-018A"):
k0, total = n_finite(
close_skew_gaps(survey, thresh=25.0, max_gap=0), name
)
k3, _ = n_finite(close_skew_gaps(survey, thresh=25.0, max_gap=3), name)
print(
f"{name}: max_gap=0 keeps {k0}/{total}; max_gap=3 keeps {k3}/{total}"
)
logging.disable(logging.NOTSET)
18-001A: max_gap=0 keeps 8/53; max_gap=3 keeps 12/53
18-016A: max_gap=0 keeps 30/53; max_gap=3 keeps 33/53
18-018A: max_gap=0 keeps 2/53; max_gap=3 keeps 2/53
Reading this output. Allowing gaps of up to 3 frequencies recovers
a handful of extra rows for 18-001A (8 → 12) and 18-016A
(30 → 33) — short interior dips above threshold get bridged rather
than splitting one usable stretch into several. 18-018A stays at
2 of 53 either way: with so few passing points to begin with, there
is no run long enough for gap-filling to help.
7. Three “beautiful” survey-wide skew views#
plot_skew_traffic_psection() colours every
(station, period) cell green/amber/red by \(|\beta|\), with alpha
encoding confidence (distance from the nearest threshold).
plot_skew_traffic_psection(survey, t1=15.0, t2=35.0)

<Axes: xlabel='Station', ylabel='$\\log_{10}(T)$ (s)'>
Reading this figure. With thresholds relaxed to this survey’s own
scale (\(t_1=15\), \(t_2=35\), rather than the function’s
textbook defaults of 3/6 which would render this entire section solid
red), the survey-wide split is 13% green, 30% amber, 57% red — red
dominates but is not everything. The contrast is sharpest between
neighboring columns: 18-018A (this page’s most-skewed station) is
92% red with almost no amber or green, while its neighbor
18-016A is only 28% red against 28% green and 43% amber —
visibly calmer, exactly matching the station ranking from section 1.
plot_skew_percentile_ribbon(survey)

<Axes: xlabel='Period (s)', ylabel='|beta| (deg)'>
Reading this figure. The median \(|\beta|\) curve (solid line) runs 20-70 degrees across the whole period range, with the 25th-75th percentile band (darker) and 10th-90th (lighter) both staying well clear of zero everywhere — there is no period band on this line where skew drops to a classically “safe” level for any meaningful fraction of stations.
plot_skew_vote_band(survey, thresh=30.0)

<Axes: xlabel='Period (s)', ylabel='fraction |beta| ≤ thresh'>
Reading this figure. The fraction of stations with \(|\beta|\le 30\) per period bin mostly sits under 0.5, dipping to zero around \(10^{-3}\) s and only climbing above 0.6 at the longest periods on the right edge.
8. Advanced: two different “votes” are not the same number#
Section 7’s vote-band curve touches ~0.6-0.65 at the longest periods
— so why did section 6’s select_low_skew_band(frac=0.6) keep
nothing? plot_skew_vote_band() votes on
the raw, pointwise \(|\beta|\le\text{thresh}\) condition alone.
select_low_skew_band() votes on whether
each frequency falls inside that station’s own longest contiguous
run — a strictly narrower condition that can (and here does) push
every station’s vote down at once, even at periods where its raw
skew was briefly fine.
from pycsamt.emtools import (
build_phase_tensor_table, # noqa: E402
)
pt = build_phase_tensor_table(survey, recursive=False)
p = pt["period"].to_numpy(dtype=float)
b = pt["skew"].abs().to_numpy(dtype=float)
lp = np.log10(np.maximum(p, 1e-9))
tail = lp >= np.percentile(lp, 90)
raw_vote_tail = float(np.nanmean(b[tail] <= 30.0))
print(
f"raw pointwise fraction |beta|<=30 in the longest-period decile: {raw_vote_tail:.2f}"
)
raw pointwise fraction |beta|<=30 in the longest-period decile: 0.62
Reading this output. The raw pointwise fraction in the longest
periods is comfortably above 0.6, matching section 7’s vote-band
curve — confirming the two functions really do disagree for the
documented reason (one extra restriction to each station’s own best
contiguous run) rather than a bug in either. Choosing between them
matters in practice: plot_skew_vote_band() is the right
diagnostic for “how much of the survey is clean at this period”,
while select_low_skew_band() is the right masking function
when what is actually needed is one shared, contiguous band usable
across the whole line.
Total running time of the script: (0 minutes 1.521 seconds)