Cross-Spectra Analysis#
pycsamt.emtools.spectra works below the processed impedance tensor.
Most emtools workflows start from an EDI Z tensor. This module
starts from the raw cross-power spectral matrix stored in a
pycsamt.seg.spectra.Spectra object: one complex Hermitian
(n_channel, n_channel) matrix for each frequency in an EDI
>=SPECTRASECT block.
Use this module when you need to inspect spectra before they become impedance and tipper estimates:
channel power spectral density (PSD);
inter-channel squared coherence;
coherence-derived SNR;
frequency-band selection from coherence;
the full cross-power matrix at one frequency;
impedance and tipper recovered from spectra;
PSD and coherence pseudo-sections across multiple spectra stations.
Full function signatures and parameter defaults are maintained in the
API reference. The analysis and plotting
functions are exported through the public pycsamt.emtools namespace.
The Spectra container itself lives in pycsamt.seg.spectra.
When To Use This Page#
Use spectra tools only when your EDI file contains a
>=SPECTRASECT block. Many EDI files contain only final impedance and
tipper estimates. Those files are valid for most pyCSAMT workflows, but
they do not contain the raw cross-power matrices required here.
The bundled spectra examples are under data/MT/SPECTRA/:
data/MT/SPECTRA/spectra01.edi
data/MT/SPECTRA/spectra02.edi
They are de-identified field spectra files. Their identifying metadata was sanitized, while the numeric spectra blocks were kept for documentation and examples.
Workflow Map#
Task |
Use this |
Output |
|---|---|---|
Compute coherence cube |
|
Array with shape |
Build PSD table |
|
Tidy table with station, frequency, channel, and PSD. |
Build coherence table |
|
Tidy table with channel pairs and squared coherence. |
Estimate SNR from coherence |
|
Coherence table plus |
Restrict frequency band |
|
New |
Create coherence mask |
|
Boolean mask of frequencies that pass coherence criteria. |
Summarize spectra |
|
Per-frequency table with PSD and mean coherence. |
Plot single-station spectra |
|
PSD curves, pairwise coherence, and matrix image. |
Recover transfer functions |
|
Apparent resistivity/phase and induction tipper from spectra. |
Plot station sections |
|
Station-period pseudo-sections from multiple spectra objects. |
Loading Spectra#
Load spectra with Spectra.from_file. This is the one required
three-level import on this page because Spectra is a segmentation
container, not an emtools function.
1from pathlib import Path
2
3from pycsamt.seg.spectra import Spectra
4
5spectra_dir = Path("data/MT/SPECTRA")
6sp1 = Spectra.from_file(spectra_dir / "spectra01.edi")
7sp2 = Spectra.from_file(spectra_dir / "spectra02.edi")
8
9print(sp1.name, sp1.n_freq, sp1.n_chan)
10print(sp1.freq.min(), sp1.freq.max())
11print(sp1.id_to_chtype)
SPECTRA01 51 7
1.72 10400.0
{'31.003': 'HX', '32.003': 'HY', '33.003': 'HZ', '34.003': 'EX', '35.003': 'EY', '36.003': 'HX', '37.003': 'HY'}
A Spectra object exposes the core arrays used by this module:
1print(sp1.freq.shape) # frequency vector
2print(sp1.S.shape) # cross-power matrix: (n_freq, n_chan, n_chan)
3print(sp1.bw.shape) # bandwidth metadata
4print(sp1.avgt.shape) # averaging-time metadata
5print(sp1.chan_ids) # channel identifiers
(51,)
(51, 7, 7)
(51,)
(51,)
['31.003', '32.003', '33.003', '34.003', '35.003', '36.003', '37.003']
The matrix sp1.S[k] is the full channel-by-channel cross-power
matrix at one frequency.
Coherence Matrix#
coherence_matrix computes squared coherence for every channel pair:
The result is real-valued and bounded between 0 and 1.
1import numpy as np
2
3from pycsamt.emtools import coherence_matrix
4
5coh = coherence_matrix(sp1)
6
7print(coh.shape)
8print(np.nanmin(coh), np.nanmax(coh))
9print(np.diagonal(coh, axis1=1, axis2=2)[0])
(51, 7, 7)
3.26311824218168e-06 1.0
[1. 1. 1. 1. 1. 1. 1.]
The diagonal is 1 because each channel is perfectly coherent with itself. The off-diagonal values are the useful ones for judging whether two channels carry a stable relationship at a given frequency.
PSD Table#
psd_table extracts the diagonal of the cross-power matrix as a tidy
table. It accepts one Spectra object, a list of spectra, or a
dictionary of station names to spectra.
1from pycsamt.emtools import psd_table
2
3psd = psd_table(sp1)
4print(psd.head())
5
6psd_norm = psd_table({"spectra01": sp1, "spectra02": sp2}, normalize=True)
7print(psd_norm.groupby(["station", "channel"])["psd"].max().head())
station freq period channel psd
0 SPECTRA01 10400.0 0.000096 HX(31.003) 1.561000e-09
1 SPECTRA01 8800.0 0.000114 HX(31.003) 2.342000e-09
2 SPECTRA01 7200.0 0.000139 HX(31.003) 4.808000e-09
3 SPECTRA01 6000.0 0.000167 HX(31.003) 3.892000e-09
4 SPECTRA01 5200.0 0.000192 HX(31.003) 2.224000e-09
station channel
spectra01 EX(34.003) 1.0
EY(35.003) 1.0
HX(31.003) 1.0
HX(36.003) 1.0
HY(32.003) 1.0
Name: psd, dtype: float64
Expected columns:
station, freq, period, channel, psd
Set normalize=True when you want to compare channel shapes rather
than absolute units. Electric and magnetic channels often have very
different physical units and raw PSD magnitudes.
Coherence Table#
coherence_table converts the coherence cube into a tidy table. By
default it includes all upper-triangle channel pairs. Pass pairs to
focus on physically meaningful pairs.
1from pycsamt.emtools import coherence_table
2
3# Example channel-index pairs used by the bundled spectra example:
4# EX-HY and EY-HX.
5mt_pairs = [(3, 1), (4, 0)]
6
7coherence = coherence_table(sp1, pairs=mt_pairs)
8print(coherence.head())
9print(coherence.groupby("pair")["coherence"].describe())
station freq period ... ch_j pair coherence
0 SPECTRA01 10400.0 0.000096 ... HY(32.003) EX(34.003)-HY(32.003) 0.956060
1 SPECTRA01 8800.0 0.000114 ... HY(32.003) EX(34.003)-HY(32.003) 0.976360
2 SPECTRA01 7200.0 0.000139 ... HY(32.003) EX(34.003)-HY(32.003) 0.986516
3 SPECTRA01 6000.0 0.000167 ... HY(32.003) EX(34.003)-HY(32.003) 0.985696
4 SPECTRA01 5200.0 0.000192 ... HY(32.003) EX(34.003)-HY(32.003) 0.997629
[5 rows x 7 columns]
count mean std ... 50% 75% max
pair ...
EX(34.003)-HY(32.003) 51.0 0.797033 0.270587 ... 0.900658 0.959647 0.998137
EY(35.003)-HX(31.003) 51.0 0.728161 0.244365 ... 0.798813 0.938283 0.996734
[2 rows x 8 columns]
Expected columns:
station, freq, period, ch_i, ch_j, pair, coherence
Use channel indices after checking sp.id_to_chtype. Spectra files can
carry duplicate reference channels or project-specific channel ordering.
Coherence-Derived SNR#
snr_table estimates SNR from squared coherence:
1from pycsamt.emtools import snr_table
2
3mt_pairs = [(3, 1), (4, 0)]
4
5snr = snr_table(sp1, pairs=mt_pairs)
6print(snr[["station", "freq", "pair", "coherence", "snr", "snr_db"]].head())
7print(snr.groupby("pair")["snr_db"].mean())
station freq pair coherence snr snr_db
0 SPECTRA01 10400.0 EX(34.003)-HY(32.003) 0.956060 21.758405 13.376271
1 SPECTRA01 8800.0 EX(34.003)-HY(32.003) 0.976360 41.301034 16.159609
2 SPECTRA01 7200.0 EX(34.003)-HY(32.003) 0.986516 73.161532 18.642828
3 SPECTRA01 6000.0 EX(34.003)-HY(32.003) 0.985696 68.909149 18.382769
4 SPECTRA01 5200.0 EX(34.003)-HY(32.003) 0.997629 420.809415 26.240854
pair
EX(34.003)-HY(32.003) 9.044718
EY(35.003)-HX(31.003) 6.879817
Name: snr_db, dtype: float64
This is not the same table as the impedance-error SNR used in
remove_noise. In this spectra workflow, SNR is derived from channel
coherence before transfer functions are estimated.
Band Selection#
band_select returns a new Spectra object restricted to a
frequency interval. It slices all spectra arrays and metadata together.
1from pycsamt.emtools import band_select
2
3high_band = band_select(sp1, f_min=100.0, f_max=10400.0)
4
5print(sp1.n_freq, high_band.n_freq)
6print(high_band.freq.min(), high_band.freq.max())
51 27
115.0 10400.0
Use band selection when a frequency range is known to be more reliable or when you need a common band across stations.
Coherence Masks#
mask_low_coherence returns a boolean mask over frequencies. It does
not modify the Spectra object. A True value means the frequency
passes the coherence criterion.
1from pycsamt.emtools import mask_low_coherence
2
3mt_pairs = [(3, 1), (4, 0)]
4
5pass_any = mask_low_coherence(
6 sp1,
7 pairs=mt_pairs,
8 threshold=0.5,
9 require_all=False,
10)
11
12pass_all = mask_low_coherence(
13 sp1,
14 pairs=mt_pairs,
15 threshold=0.5,
16 require_all=True,
17)
18
19print(f"any pair passes: {pass_any.sum()} / {pass_any.size}")
20print(f"all pairs pass: {pass_all.sum()} / {pass_all.size}")
any pair passes: 44 / 51
all pairs pass: 42 / 51
Use require_all=True when every requested channel pair must be
coherent before a frequency is accepted. Use False for a looser
screen where at least one pair is enough.
Spectra Summary#
spectra_summary produces one row per frequency. It combines
frequency metadata, channel PSD values, and mean off-diagonal coherence.
1from pycsamt.emtools import spectra_summary
2
3summary = spectra_summary(sp1)
4print(summary.head())
5print(summary[["freq", "period", "mean_coherence"]].head())
freq period bw ... psd_HX(36.003) psd_HY(37.003) mean_coherence
0 10400.0 0.000096 2600.0 ... 1.561000e-09 5.204000e-09 0.399433
1 8800.0 0.000114 2904.0 ... 2.342000e-09 7.303000e-09 0.530258
2 7200.0 0.000139 1800.0 ... 4.808000e-09 1.702000e-08 0.666576
3 6000.0 0.000167 1980.0 ... 3.892000e-09 1.619000e-08 0.690481
4 5200.0 0.000192 1300.0 ... 2.224000e-09 9.476000e-09 0.695167
[5 rows x 13 columns]
freq period mean_coherence
0 10400.0 0.000096 0.399433
1 8800.0 0.000114 0.530258
2 7200.0 0.000139 0.666576
3 6000.0 0.000167 0.690481
4 5200.0 0.000192 0.695167
Use this table for quick reporting and for finding frequency ranges where average coherence collapses.
PSD Plot#
plot_psd draws the auto-spectrum for selected channels.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import plot_psd
4
5fig, ax = plt.subplots(figsize=(9, 5))
6plot_psd(
7 sp1,
8 channels=None,
9 log_psd=True,
10 title=f"{sp1.name} PSD",
11 ax=ax,
12)
13fig.tight_layout()
14fig.savefig("spectra_psd_spectra01.png", dpi=200)
15plt.close(fig)
Pass a channel list when you want only a subset:
1import matplotlib.pyplot as plt
2
3plot_psd(sp1, channels=[0, 1, 3, 4], log_psd=True)
4plt.gcf().savefig("spectra_psd_subset_spectra01.png", dpi=200)
5plt.close(plt.gcf())
The x-axis follows the global pyCSAMT plotting control, which is usually period-oriented for MT-style figures.
Coherence Plot#
plot_coherence creates one axis per channel pair and draws the
threshold line.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import plot_coherence
4
5mt_pairs = [(3, 1), (4, 0)]
6
7axes = plot_coherence(
8 sp1,
9 pairs=mt_pairs,
10 threshold=0.5,
11 show_threshold=True,
12 title=f"{sp1.name} MT-pair coherence",
13)
14axes[0].figure.tight_layout()
15axes[0].figure.savefig("spectra_coherence_spectra01.png", dpi=200)
16plt.close(axes[0].figure)
Use this plot before deciding on a band cut. A single mean coherence number can hide whether failures are isolated, broad-band, or confined to one channel pair.
Full Spectral Matrix#
plot_spectra_matrix visualizes the complete cross-power matrix at
one frequency. The diagonal cells are auto-spectra. Off-diagonal cells
are cross-spectra.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import plot_spectra_matrix
4
5fig = plot_spectra_matrix(
6 sp1,
7 freq_idx=0,
8 quantity="abs",
9 log_scale=True,
10 title="Cross-power matrix",
11)
12fig.savefig("spectra_cross_power_matrix.png", dpi=200, bbox_inches="tight")
13plt.close(fig)
Available quantity values are "abs", "real", "imag", and
"phase". The log absolute view is usually the best first look because
electric and magnetic channel powers can span many orders of magnitude.
Recovering Impedance From Spectra#
plot_z_from_spectra calls Spectra.to_Z internally and plots
apparent resistivity and phase from the spectra-derived impedance.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import plot_z_from_spectra
4
5fig = plot_z_from_spectra(
6 sp1,
7 e_labels=("EX", "EY"),
8 h_labels=("HX", "HY"),
9 ridge=None,
10 estimate_error=False,
11 show_error=True,
12)
13fig.savefig("spectra_impedance_spectra01.png", dpi=200, bbox_inches="tight")
14plt.close(fig)
For programmatic access, call to_Z on the Spectra object:
1z_obj, tipper_obj = sp1.to_Z(
2 e_labels=("EX", "EY"),
3 h_labels=("HX", "HY"),
4 estimate_error=False,
5)
6
7print(z_obj.z.shape)
8print(z_obj.resistivity[:, 0, 1].min(), z_obj.resistivity[:, 0, 1].max())
(51, 2, 2)
3.9104053794314146 119.75913187624168
Use ridge when the magnetic cross-power block is poorly conditioned.
Keep estimate_error=False if degrees-of-freedom metadata is
incomplete and you do not need uncertainty envelopes.
Recovering Tipper From Spectra#
plot_tipper_from_spectra recovers induction tipper components from
the same spectra object when an HZ channel is available.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import plot_tipper_from_spectra
4
5axes = plot_tipper_from_spectra(
6 sp1,
7 h_labels=("HX", "HY"),
8 ridge=None,
9 estimate_error=False,
10 show_error=True,
11)
12axes[0].figure.savefig("spectra_tipper_spectra01.png", dpi=200, bbox_inches="tight")
13plt.close(axes[0].figure)
If no vertical magnetic channel is available, the function returns axes with a no-tipper message rather than failing.
Multiple-Station Sections#
plot_psd_section and plot_coherence_section accept a list or
dictionary of Spectra objects. They build a common log-frequency grid
over the overlapping frequency range and interpolate each station onto
that grid.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools import plot_coherence_section, plot_psd_section
4
5spectra_sites = {
6 "spectra01": sp1,
7 "spectra02": sp2,
8}
9
10ax_psd = plot_psd_section(
11 spectra_sites,
12 channel=3,
13 log_psd=True,
14 title="EX PSD section",
15)
16ax_psd.figure.savefig("spectra_psd_section.png", dpi=200, bbox_inches="tight")
17plt.close(ax_psd.figure)
18
19ax_coh = plot_coherence_section(
20 spectra_sites,
21 pair=(3, 1),
22 threshold=0.5,
23 show_threshold=True,
24 title="EX-HY coherence section",
25)
26ax_coh.figure.savefig("spectra_coherence_section.png", dpi=200, bbox_inches="tight")
27plt.close(ax_coh.figure)
The section functions use only the shared frequency overlap. They do not extrapolate outside a station’s spectra band.
Practical QC Recipe#
A compact spectra QC sequence is:
1from pycsamt.emtools import (
2 band_select,
3 coherence_table,
4 mask_low_coherence,
5 spectra_summary,
6)
7
8mt_pairs = [(3, 1), (4, 0)]
9
10full_mask = mask_low_coherence(
11 sp1,
12 pairs=mt_pairs,
13 threshold=0.5,
14 require_all=True,
15)
16print(f"full band pass: {full_mask.sum()} / {full_mask.size}")
17
18clean = band_select(sp1, f_min=100.0, f_max=10400.0)
19clean_mask = mask_low_coherence(
20 clean,
21 pairs=mt_pairs,
22 threshold=0.5,
23 require_all=True,
24)
25print(f"selected band pass: {clean_mask.sum()} / {clean_mask.size}")
26
27coh = coherence_table(clean, pairs=mt_pairs)
28summary = spectra_summary(clean)
29
30print(coh.groupby("pair")["coherence"].describe())
31print(summary[["freq", "mean_coherence"]].head())
full band pass: 42 / 51
selected band pass: 27 / 27
count mean std ... 50% 75% max
pair ...
EX(34.003)-HY(32.003) 27.0 0.947011 0.045600 ... 0.956582 0.986106 0.998137
EY(35.003)-HX(31.003) 27.0 0.858419 0.153881 ... 0.925313 0.988420 0.996734
[2 rows x 8 columns]
freq mean_coherence
0 10400.0 0.399433
1 8800.0 0.530258
2 7200.0 0.666576
3 6000.0 0.690481
4 5200.0 0.695167
This sequence turns a visual coherence problem into a reproducible band selection: define pairs, apply threshold, slice the spectra, and verify the selected band.
Pitfalls#
Do not use these functions on impedance-only EDIs. They require a
Spectra object with a real cross-power matrix.
Do not assume channel indices are universal. Always inspect
sp.id_to_chtype before hard-coding pairs such as (3, 1).
Do not confuse spectra coherence SNR with impedance-error SNR. The
spectra snr_table is derived from squared coherence. The
noise-removal SNR table is based on impedance errors.
Do not compare raw PSD values across electric and magnetic channels as if they shared units. Use log scaling or normalization when comparing shape.
Worked Example#
The example loads the bundled de-identified spectra EDI files, plots PSD and coherence, builds PSD/coherence/SNR tables, selects a cleaner band, displays the full spectral matrix, recovers impedance and tipper from spectra, and builds multi-station PSD/coherence sections.
Open the rendered gallery page here: Cross-spectra analysis and visualization (pycsamt.emtools.spectra).