# pycsamt/emtools/spectra.py
# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
pycsamt.emtools.spectra
=======================
Analysis and visualisation of MT cross-spectra stored in
:class:`~pycsamt.seg.spectra.Spectra` objects.
All plotting functions read visual defaults from the package-wide
API singletons at call time, so a single
:func:`~pycsamt.api.style.configure_style` or
:func:`~pycsamt.api.style.use_style` call propagates here too.
Analysis
--------
.. autosummary::
coherence_matrix
psd_table
coherence_table
snr_table
band_select
mask_low_coherence
spectra_summary
Visualisation
-------------
.. autosummary::
plot_psd
plot_coherence
plot_spectra_matrix
plot_z_from_spectra
plot_tipper_from_spectra
plot_psd_section
plot_coherence_section
Quick start
-----------
::
from pycsamt.seg.spectra import Spectra
from pycsamt.emtools.spectra import (
plot_psd, plot_coherence, plot_z_from_spectra,
plot_tipper_from_spectra, coherence_table,
)
sp = Spectra.from_file("site.edi")
# power spectral density per channel
plot_psd(sp, title="HBH05-03 PSD")
# inter-channel coherence
plot_coherence(sp)
# apparent resistivity + phase recovered from spectra
plot_z_from_spectra(sp)
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import (
Any,
)
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from ..api._rose_style import _UNSET
from ..api.control import PYCSAMT_CONTROL
from ..api.plot import add_colorbar
from ..api.section import PYCSAMT_SECTION, SectionStyle
from ..api.style import PYCSAMT_STYLE
from ..api.view import maybe_wrap_frame
from ._core import _axes_list
__all__ = [
# analysis
"coherence_matrix",
"psd_table",
"coherence_table",
"snr_table",
"band_select",
"mask_low_coherence",
"spectra_summary",
# visualisation
"plot_psd",
"plot_coherence",
"plot_spectra_matrix",
"plot_z_from_spectra",
"plot_tipper_from_spectra",
"plot_psd_section",
"plot_coherence_section",
]
# ─────────────────────────────────────────────────────────────────────────────
# Internal helpers
# ─────────────────────────────────────────────────────────────────────────────
def _x_vals(fr: np.ndarray) -> np.ndarray:
return PYCSAMT_CONTROL.x.transform(fr)
def _x_label() -> str:
return PYCSAMT_CONTROL.x.label()
def _x_log() -> bool:
return PYCSAMT_CONTROL.x.use_log_scale()
def _spine_style(ax: Axes) -> None:
ax.grid(True, which="both", ls=":", lw=0.4, color="0.75", zorder=0)
ax.set_axisbelow(True)
def _check_spectra(sp: Any) -> None:
if sp.n_freq == 0:
raise ValueError("Spectra object has no frequency blocks.")
if sp.n_chan == 0:
raise ValueError("Spectra object has no channels.")
def _resolve_pairs(
sp: Any,
pairs: list[tuple[int, int]] | None,
) -> list[tuple[int, int]]:
"""Return (i, j) index pairs; default = all upper-triangle pairs."""
nc = sp.n_chan
if pairs is not None:
return list(pairs)
return [(i, j) for i in range(nc) for j in range(i + 1, nc)]
def _chan_label(sp: Any, idx: int) -> str:
"""Return a human-readable channel label for index *idx*."""
ids = getattr(sp, "chan_ids", None)
id_to_cht = getattr(sp, "id_to_chtype", {}) or {}
if ids and idx < len(ids):
raw = ids[idx]
cht = id_to_cht.get(str(raw), "")
return f"{cht}({raw})" if cht else str(raw)
return f"ch{idx}"
def _sp_to_dict(
sp_input: Any,
) -> dict[str, Any]:
"""Normalise single Spectra / list / dict to ``{name: Spectra}``."""
# avoid importing Spectra at module level to prevent circular deps
from ..seg.spectra import (
Spectra as _Spectra, # noqa: PLC0415
)
if isinstance(sp_input, _Spectra):
name = getattr(sp_input, "name", None) or "site"
return {str(name): sp_input}
if isinstance(sp_input, dict):
return {str(k): v for k, v in sp_input.items()}
if isinstance(sp_input, (list, tuple)):
out = {}
for i, s in enumerate(sp_input):
nm = getattr(s, "name", None) or f"site{i + 1}"
out[str(nm)] = s
return out
raise TypeError(
f"Expected Spectra, list[Spectra], or dict[str, Spectra]; "
f"got {type(sp_input).__name__!r}"
)
def _resolve_section_style(section: str | SectionStyle) -> SectionStyle:
if isinstance(section, SectionStyle):
return section.copy()
return PYCSAMT_SECTION.style_for(str(section)).copy()
# ─────────────────────────────────────────────────────────────────────────────
# Analysis — returns arrays / DataFrames
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def coherence_matrix(sp: Any) -> np.ndarray:
r"""Compute the inter-channel coherence matrix.
The squared coherence between channels *i* and *j* is:
.. math::
\gamma^2_{ij}(f) = \frac{|S_{ij}(f)|^2}{S_{ii}(f)\,S_{jj}(f)}
Parameters
----------
sp : Spectra
Cross-spectra container.
Returns
-------
coh : ndarray, shape ``(n_freq, n_chan, n_chan)``
Real-valued coherence matrix per frequency, values in [0, 1].
"""
_check_spectra(sp)
S = sp.S # (nf, nc, nc)
S_diag = np.real(np.diagonal(S, axis1=1, axis2=2)) # (nf, nc)
denom = (
S_diag[:, :, np.newaxis] * S_diag[:, np.newaxis, :]
) # (nf, nc, nc)
with np.errstate(divide="ignore", invalid="ignore"):
coh = np.abs(S) ** 2 / np.maximum(denom, 1e-40)
coh = np.clip(np.real(coh), 0.0, 1.0)
return coh
[docs]
def psd_table(
sp_input: Any,
*,
normalize: bool = False,
api: bool | None = None,
) -> Any:
"""Power spectral density per channel as a tidy DataFrame.
Parameters
----------
sp_input : Spectra or list/dict of Spectra
One or more cross-spectra containers.
normalize : bool
If ``True``, normalise each channel's PSD by its maximum value.
Returns
-------
pd.DataFrame
Columns: ``station``, ``freq``, ``period``, ``channel``, ``psd``.
"""
rows = []
for name, sp in _sp_to_dict(sp_input).items():
_check_spectra(sp)
S_diag = np.real(np.diagonal(sp.S, axis1=1, axis2=2)) # (nf, nc)
for ch in range(sp.n_chan):
psd = S_diag[:, ch].copy()
if normalize:
mx = np.nanmax(psd)
if mx > 0:
psd = psd / mx
lab = _chan_label(sp, ch)
for fi, f in enumerate(sp.freq):
rows.append(
{
"station": name,
"freq": float(f),
"period": float(1.0 / f) if f > 0 else np.nan,
"channel": lab,
"psd": float(psd[fi]),
}
)
df = pd.DataFrame(
rows, columns=["station", "freq", "period", "channel", "psd"]
)
return maybe_wrap_frame(
df,
api=api,
name="psd_table",
kind="emtools.spectra.psd",
source=sp_input,
description="Power spectral density by station, frequency, and channel.",
)
[docs]
def coherence_table(
sp_input: Any,
*,
pairs: list[tuple[int, int]] | None = None,
api: bool | None = None,
) -> Any:
"""Inter-channel squared coherence as a tidy DataFrame.
Parameters
----------
sp_input : Spectra or list/dict of Spectra
pairs : list of (i, j), optional
Channel index pairs. Default = all upper-triangle pairs.
Returns
-------
pd.DataFrame
Columns: ``station``, ``freq``, ``period``,
``ch_i``, ``ch_j``, ``pair``, ``coherence``.
"""
rows = []
for name, sp in _sp_to_dict(sp_input).items():
_check_spectra(sp)
coh = coherence_matrix(sp) # (nf, nc, nc)
prs = _resolve_pairs(sp, pairs)
for i, j in prs:
li = _chan_label(sp, i)
lj = _chan_label(sp, j)
label = f"{li}-{lj}"
for fi, f in enumerate(sp.freq):
rows.append(
{
"station": name,
"freq": float(f),
"period": float(1.0 / f) if f > 0 else np.nan,
"ch_i": li,
"ch_j": lj,
"pair": label,
"coherence": float(coh[fi, i, j]),
}
)
df = pd.DataFrame(
rows,
columns=[
"station",
"freq",
"period",
"ch_i",
"ch_j",
"pair",
"coherence",
],
)
return maybe_wrap_frame(
df,
api=api,
name="coherence_table",
kind="emtools.spectra.coherence",
source=sp_input,
description="Squared coherence by station, frequency, and channel pair.",
)
[docs]
def snr_table(
sp_input: Any,
*,
pairs: list[tuple[int, int]] | None = None,
api: bool | None = None,
) -> Any:
r"""Signal-to-noise ratio estimated from squared coherence.
Uses the coherence-based estimator:
.. math::
\text{SNR} = \frac{\gamma^2}{1 - \gamma^2}
with the dB version ``SNR_dB = 10 log₁₀(SNR)``.
Parameters
----------
sp_input : Spectra or list/dict of Spectra
pairs : list of (i, j), optional
Returns
-------
pd.DataFrame
Columns: ``station``, ``freq``, ``period``, ``pair``,
``coherence``, ``snr``, ``snr_db``.
"""
df = coherence_table(sp_input, pairs=pairs)
g2 = df["coherence"].to_numpy(dtype=float)
with np.errstate(divide="ignore", invalid="ignore"):
snr = g2 / np.maximum(1.0 - g2, 1e-12)
snr_db = 10.0 * np.log10(np.maximum(snr, 1e-12))
df = df.copy()
df["snr"] = snr
df["snr_db"] = snr_db
return maybe_wrap_frame(
df,
api=api,
name="snr_table",
kind="emtools.spectra.snr",
source=sp_input,
description="Coherence-derived signal-to-noise ratio table.",
)
[docs]
def band_select(
sp: Any,
f_min: float,
f_max: float,
) -> Any:
"""Return a new :class:`Spectra` restricted to the frequency band
``[f_min, f_max]`` Hz.
Parameters
----------
sp : Spectra
f_min, f_max : float
Frequency limits in Hz (inclusive).
Returns
-------
Spectra
A shallow copy with arrays sliced to the band.
"""
from ..seg.spectra import (
Spectra as _Spectra, # noqa: PLC0415
)
_check_spectra(sp)
mask = (sp.freq >= float(f_min)) & (sp.freq <= float(f_max))
if not mask.any():
raise ValueError(
f"No frequencies in [{f_min}, {f_max}] Hz. "
f"Available range: {sp.freq[-1]:.4g}–{sp.freq[0]:.4g} Hz."
)
idx = np.where(mask)[0]
out = _Spectra(name=sp.name)
out._freq = sp.freq[idx].copy()
out._S = sp.S[idx].copy()
out.bw = sp.bw[idx].copy()
out.avgt = sp.avgt[idx].copy()
out.avgf = sp.avgf[idx].copy()
out.rotspec = sp.rotspec[idx].copy()
out.segnum = sp.segnum[idx].copy()
out.band = [sp.band[k] for k in idx.tolist()]
out.chan_ids = list(sp.chan_ids)
out.id_to_chtype = dict(sp.id_to_chtype)
return out
[docs]
def mask_low_coherence(
sp: Any,
*,
pairs: list[tuple[int, int]] | None = None,
threshold: float = 0.5,
require_all: bool = False,
) -> np.ndarray:
"""Boolean mask of frequencies with *sufficient* coherence.
Parameters
----------
sp : Spectra
pairs : list of (i, j), optional
Pairs to evaluate. Default = all upper-triangle pairs.
threshold : float
Minimum coherence required. Default 0.5.
require_all : bool
If ``True``, all requested pairs must exceed *threshold*.
If ``False`` (default), at least one pair suffices.
Returns
-------
mask : ndarray of bool, shape ``(n_freq,)``
``True`` where coherence is acceptable.
"""
_check_spectra(sp)
coh = coherence_matrix(sp) # (nf, nc, nc)
prs = _resolve_pairs(sp, pairs)
flags = np.zeros((sp.n_freq, len(prs)), dtype=bool)
for k, (i, j) in enumerate(prs):
flags[:, k] = coh[:, i, j] >= threshold
if require_all:
return flags.all(axis=1)
return flags.any(axis=1)
[docs]
def spectra_summary(sp: Any, *, api: bool | None = None) -> Any:
"""Compact per-frequency summary table.
Columns: ``freq``, ``period``, ``bw``, ``avgt``, ``rotspec``,
plus the diagonal PSD and mean off-diagonal coherence for each
channel.
Returns
-------
pd.DataFrame
"""
_check_spectra(sp)
coh = coherence_matrix(sp) # (nf, nc, nc)
psd = np.real(np.diagonal(sp.S, axis1=1, axis2=2)) # (nf, nc)
rows = []
for fi, f in enumerate(sp.freq):
row: dict = {
"freq": float(f),
"period": float(1.0 / f) if f > 0 else np.nan,
"bw": float(sp.bw[fi]),
"avgt": float(sp.avgt[fi]),
"rotspec": float(sp.rotspec[fi]),
}
for ch in range(sp.n_chan):
lab = _chan_label(sp, ch)
row[f"psd_{lab}"] = float(psd[fi, ch])
# mean coherence over all pairs at this frequency
nc = sp.n_chan
if nc > 1:
pairs_val = [
coh[fi, i, j] for i in range(nc) for j in range(i + 1, nc)
]
row["mean_coherence"] = float(np.nanmean(pairs_val))
rows.append(row)
df = pd.DataFrame(rows)
return maybe_wrap_frame(
df,
api=api,
name="spectra_summary",
kind="emtools.spectra.summary",
source=sp,
description="Compact per-frequency spectra summary.",
)
# ─────────────────────────────────────────────────────────────────────────────
# Visualisation — plot functions
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_psd(
sp: Any,
*,
channels: Sequence[int] | None = None,
log_psd: bool = True,
lw: float = _UNSET,
alpha: float = _UNSET,
title: str = "",
figsize: tuple[float, float] = (9, 5),
ax: Axes | None = None,
) -> Axes:
"""Plot the power spectral density per channel.
The x-axis follows :data:`~pycsamt.api.control.PYCSAMT_CONTROL`
(log₁₀ period by default). Colours come from
:attr:`~pycsamt.api.style.PYCSAMT_STYLE.multiline`.
Parameters
----------
sp : Spectra
channels : sequence of int, optional
Channel indices to plot. Default = all channels.
log_psd : bool
Display log₁₀(PSD) when ``True``.
lw : float
Line width. Default: ``PYCSAMT_STYLE.multiline.lw``.
alpha : float
Line alpha. Default: ``PYCSAMT_STYLE.multiline.alpha``.
title : str
figsize : (float, float)
ax : Axes or None
Returns
-------
ax : Axes
"""
_check_spectra(sp)
_ml = PYCSAMT_STYLE.multiline
if lw is _UNSET:
lw = _ml.lw
if alpha is _UNSET:
alpha = _ml.alpha
nc = sp.n_chan
chs = list(channels) if channels is not None else list(range(nc))
cols = _ml.colors(len(chs))
psd = np.real(np.diagonal(sp.S, axis1=1, axis2=2)) # (nf, nc)
x = _x_vals(sp.freq)
if ax is None:
_, ax = plt.subplots(figsize=figsize, constrained_layout=True)
for ki, chi in enumerate(chs):
y = psd[:, chi]
if log_psd:
y = np.log10(np.maximum(y, 1e-40))
lab = _chan_label(sp, chi)
ax.plot(x, y, color=cols[ki], lw=lw, alpha=alpha, label=lab)
if _x_log():
ax.set_xscale("log")
ax.set_xlabel(_x_label(), fontsize=9)
ylabel = r"$\log_{10}$PSD" if log_psd else "PSD"
ax.set_ylabel(ylabel, fontsize=9)
ax.legend(fontsize=8, framealpha=0.8)
ax.set_title(
title or f"Power spectral density — {sp.name or 'site'}",
fontsize=10,
pad=6,
)
_spine_style(ax)
return ax
[docs]
def plot_coherence(
sp: Any,
*,
pairs: list[tuple[int, int]] | None = None,
threshold: float = 0.5,
show_threshold: bool = True,
lw: float = _UNSET,
alpha: float = _UNSET,
title: str = "",
axes=None,
figsize: tuple[float, float] | None = None,
) -> np.ndarray:
"""Plot squared coherence for selected channel pairs.
Each pair gets its own sub-axis arranged in a grid. A dashed
horizontal line marks *threshold*.
Parameters
----------
sp : Spectra
pairs : list of (i, j), optional
Default = all upper-triangle pairs.
threshold : float
Quality threshold drawn as a dashed line. Default 0.5.
show_threshold : bool
lw, alpha : float
Style defaults from ``PYCSAMT_STYLE.multiline``.
title : str
figsize : (float, float) or None
Auto-computed from the number of pairs when ``None``.
Returns
-------
axes : ndarray of Axes, shape ``(n_pairs,)``
"""
_check_spectra(sp)
_ml = PYCSAMT_STYLE.multiline
if lw is _UNSET:
lw = _ml.lw
if alpha is _UNSET:
alpha = _ml.alpha
coh = coherence_matrix(sp)
prs = _resolve_pairs(sp, pairs)
n = len(prs)
if n == 0:
raise ValueError("No channel pairs available.")
ncols = min(n, 3)
nrows = (n + ncols - 1) // ncols
if figsize is None:
figsize = (4.0 * ncols, 3.5 * nrows)
axes_given = _axes_list(axes, n) if axes is not None else None
if axes_given is None:
fig, axs_raw = plt.subplots(
nrows, ncols, figsize=figsize, constrained_layout=True
)
axs = np.asarray(axs_raw).ravel()
else:
axs = np.asarray(axes_given, dtype=object)
fig = axs[0].figure
cols = _ml.colors(n)
x = _x_vals(sp.freq)
for ki, (i, j) in enumerate(prs):
ax = axs[ki]
li = _chan_label(sp, i)
lj = _chan_label(sp, j)
y = coh[:, i, j]
ax.plot(x, y, color=cols[ki], lw=lw, alpha=alpha, label=f"{li}-{lj}")
if show_threshold:
ax.axhline(
threshold,
ls="--",
lw=0.8,
color="0.5",
label=f"thr={threshold}",
)
ax.set_ylim(-0.05, 1.05)
if _x_log():
ax.set_xscale("log")
ax.set_xlabel(_x_label(), fontsize=8)
ax.set_ylabel(r"$\gamma^2$", fontsize=8)
ax.set_title(f"{li} – {lj}", fontsize=9, pad=4)
ax.legend(fontsize=7, framealpha=0.7)
_spine_style(ax)
# hide surplus axes
for ak in axs[n:]:
ak.set_visible(False)
fig.suptitle(
title or f"Coherence — {sp.name or 'site'}",
fontsize=10,
y=1.01,
)
return axs[:n]
[docs]
def plot_spectra_matrix(
sp: Any,
*,
freq_idx: int = 0,
quantity: str = "abs",
cmap: str = _UNSET,
log_scale: bool = True,
title: str = "",
ax=None,
figsize: tuple[float, float] = (7, 6),
) -> Figure:
"""Visualise the full cross-spectral density matrix at one frequency.
The matrix is drawn as a colour image. The diagonal shows auto-
spectra (PSD); the upper and lower triangles show the magnitude (or
real/imaginary part) of the cross-spectra.
Parameters
----------
sp : Spectra
freq_idx : int
Index into ``sp.freq`` for the frequency slice.
quantity : {'abs', 'real', 'imag', 'phase'}
Quantity to colour.
cmap : str or _UNSET
Colour map. Defaults to ``"viridis"`` (abs) or ``"RdBu_r"``
(real/imag/phase).
log_scale : bool
Apply log₁₀ to the absolute value when ``quantity="abs"``.
title : str
figsize : (float, float)
Returns
-------
fig : Figure
"""
_check_spectra(sp)
nc = sp.n_chan
M = sp.S[freq_idx] # (nc, nc) complex
freq = sp.freq[freq_idx]
if quantity == "abs":
data = np.abs(M)
if log_scale:
data = np.log10(np.maximum(data, 1e-40))
cb_label = r"$\log_{10}|S_{ij}|$"
else:
cb_label = r"$|S_{ij}|$"
if cmap is _UNSET:
cmap = "viridis"
elif quantity == "real":
data = np.real(M)
cb_label = r"Re($S_{ij}$)"
if cmap is _UNSET:
cmap = "RdBu_r"
elif quantity == "imag":
data = np.imag(M)
cb_label = r"Im($S_{ij}$)"
if cmap is _UNSET:
cmap = "RdBu_r"
elif quantity == "phase":
data = np.angle(M, deg=True)
cb_label = r"Phase($S_{ij}$) (°)"
if cmap is _UNSET:
cmap = "hsv"
else:
raise ValueError(
f"quantity must be 'abs','real','imag','phase'; got {quantity!r}"
)
labels = [_chan_label(sp, k) for k in range(nc)]
if ax is None:
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
else:
fig = ax.figure
im = ax.imshow(data, cmap=cmap, aspect="equal", origin="upper")
add_colorbar(im, ax, label=cb_label, size="4%", pad=0.04)
ax.set_xticks(range(nc))
ax.set_xticklabels(labels, fontsize=8)
ax.set_yticks(range(nc))
ax.set_yticklabels(labels, fontsize=8)
# annotate cells
for r in range(nc):
for c in range(nc):
ax.text(
c,
r,
f"{data[r, c]:.2g}",
ha="center",
va="center",
fontsize=6.5,
color="0.2",
)
ax.set_title(
title
or (
f"Spectral matrix — {sp.name or 'site'} "
f"f = {freq:.4g} Hz [{quantity}]"
),
fontsize=10,
pad=6,
)
return fig
[docs]
def plot_z_from_spectra(
sp: Any,
*,
e_labels: tuple[str, str] = ("EX", "EY"),
h_labels: tuple[str, str] = ("HX", "HY"),
ridge: float | None = None,
estimate_error: bool = False,
show_error: bool = True,
title: str = "",
axes=None,
figsize: tuple[float, float] = (10, 5),
) -> Figure:
"""Plot apparent resistivity and phase recovered from spectra.
Calls :meth:`~pycsamt.seg.spectra.Spectra.to_Z` internally and
renders the result with the standard MT component styling from
:attr:`~pycsamt.api.style.PYCSAMT_STYLE`.
Parameters
----------
sp : Spectra
e_labels, h_labels : tuple of str
Channel type labels used for the E and H blocks in
:meth:`~pycsamt.seg.spectra.Spectra.to_Z`.
ridge : float or None
Tikhonov regularization for S_HH inversion.
estimate_error : bool
Estimate 1-σ errors in :meth:`to_Z`. Default ``False``
(avoids DoF warnings when metadata is incomplete).
show_error : bool
Shade error envelope when errors are available.
title : str
figsize : (float, float)
Returns
-------
fig : Figure
"""
_check_spectra(sp)
z_obj, _ = sp.to_Z(
e_labels=e_labels,
h_labels=h_labels,
ridge=ridge,
estimate_error=estimate_error,
)
_st = PYCSAMT_STYLE.mt
freqs = z_obj.freq
x = _x_vals(freqs)
rho = z_obj.resistivity # (nf, 2, 2)
phi = z_obj.phase # (nf, 2, 2)
z_err = z_obj.z_err # None or (nf, 2, 2)
axes_given = _axes_list(axes, 2)
if axes_given is None:
fig, (ax_r, ax_p) = plt.subplots(
1, 2, figsize=figsize, constrained_layout=True
)
else:
ax_r, ax_p = axes_given
fig = ax_r.figure
pairs = [
("xy", (0, 1), _st.xy),
("yx", (1, 0), _st.yx),
]
for _comp, (r, c), sty in pairs:
rho_c = np.log10(np.maximum(rho[:, r, c], 1e-12))
phi_c = phi[:, r, c]
kw = sty.plot_kwargs()
ax_r.plot(x, rho_c, **kw)
ax_p.plot(x, phi_c, **kw)
if show_error and z_err is not None:
# propagate |Z| error to log-rho error ≈ 2Δ|Z|/|Z| / ln10
z_c = z_obj.z[:, r, c]
z_err_c = z_err[:, r, c]
rel = np.abs(z_err_c) / (np.abs(z_c) + 1e-24)
drho = 2.0 * rel / np.log(10.0)
ax_r.fill_between(
x,
rho_c - drho,
rho_c + drho,
color=sty.color,
alpha=0.15,
linewidth=0,
)
for ax in (ax_r, ax_p):
_spine_style(ax)
if _x_log():
ax.set_xscale("log")
ax.set_xlabel(_x_label(), fontsize=9)
ax_r.set_ylabel(r"$\log_{10}\rho_a$ ($\Omega\cdot$m)", fontsize=9)
ax_r.set_title(r"Apparent resistivity $\rho_a$", fontsize=9, pad=6)
ax_p.set_ylabel(r"Phase (°)", fontsize=9)
ax_p.set_title("Impedance phase", fontsize=9, pad=6)
# shared legend on rho panel
ax_r.legend(fontsize=8, framealpha=0.8)
fig.suptitle(
title or f"Z from spectra — {sp.name or 'site'}",
fontsize=11,
y=1.02,
)
return fig
[docs]
def plot_tipper_from_spectra(
sp: Any,
*,
h_labels: tuple[str, str] = ("HX", "HY"),
ridge: float | None = None,
estimate_error: bool = False,
show_error: bool = True,
title: str = "",
axes=None,
figsize: tuple[float, float] = (10, 5),
) -> np.ndarray:
"""Plot the induction tipper magnitude and phase from spectra.
Displays the real and imaginary parts of T_x and T_y as well as
their magnitudes on a two-panel figure (amplitude | phase).
Parameters
----------
sp : Spectra
h_labels : tuple of str
ridge : float or None
estimate_error : bool
show_error : bool
title : str
figsize : (float, float)
Returns
-------
axes : ndarray of Axes, shape (2,)
``[ax_amp, ax_phase]``
"""
_check_spectra(sp)
_, tip = sp.to_Z(
h_labels=h_labels,
ridge=ridge,
estimate_error=estimate_error,
)
if tip is None:
axes_given = _axes_list(axes, 2) if axes is not None else None
if axes_given is None:
_, axs = plt.subplots(
1, 2, figsize=figsize, constrained_layout=True
)
else:
axs = axes_given
for ax in axs:
ax.text(
0.5,
0.5,
"No tipper (HZ not found)",
ha="center",
va="center",
transform=ax.transAxes,
fontsize=10,
color="0.5",
)
_spine_style(ax)
return np.array(axs)
x = _x_vals(tip.freq)
T = tip.tipper[:, 0, :] # (nf, 2) — Tx, Ty
T_err = tip.tipper_err[:, 0, :] if tip.tipper_err is not None else None
axes_given = _axes_list(axes, 2)
if axes_given is None:
fig, (ax_a, ax_p) = plt.subplots(
1, 2, figsize=figsize, constrained_layout=True
)
else:
ax_a, ax_p = axes_given
fig = ax_a.figure
_st = PYCSAMT_STYLE.mt
specs = [
("T_x", T[:, 0], _st.xy),
("T_y", T[:, 1], _st.yx),
]
for lab, Tc, sty in specs:
amp = np.abs(Tc)
phase = np.angle(Tc, deg=True)
kw = sty.plot_kwargs(label=lab)
ax_a.plot(x, amp, **kw)
ax_p.plot(x, phase, **kw)
if show_error and T_err is not None:
idx = 0 if lab == "T_x" else 1
err = np.abs(T_err[:, idx])
ax_a.fill_between(
x,
amp - err,
amp + err,
color=sty.color,
alpha=0.15,
linewidth=0,
)
for ax in (ax_a, ax_p):
_spine_style(ax)
if _x_log():
ax.set_xscale("log")
ax.set_xlabel(_x_label(), fontsize=9)
ax.legend(fontsize=8, framealpha=0.8)
ax_a.set_ylabel("|T|", fontsize=9)
ax_a.set_title("Tipper magnitude", fontsize=9, pad=6)
ax_p.set_ylabel("Phase (°)", fontsize=9)
ax_p.set_title("Tipper phase", fontsize=9, pad=6)
fig.suptitle(
title or f"Tipper from spectra — {sp.name or 'site'}",
fontsize=11,
y=1.02,
)
return np.array([ax_a, ax_p])
[docs]
def plot_psd_section(
sp_input: Any,
*,
channel: int = 0,
log_psd: bool = True,
cmap: str = "viridis",
vmin: float | None = None,
vmax: float | None = None,
section: str | SectionStyle = "pseudosection",
title: str = "",
figsize: tuple[float, float] | None = None,
ax: Axes | None = None,
) -> Axes:
"""Pseudo-section of PSD across stations (station × period).
Interpolates all Spectra objects to a common log-spaced frequency
grid before assembling the 2-D colour map.
Parameters
----------
sp_input : Spectra or list/dict of Spectra
channel : int
Channel index to display. Default 0.
log_psd : bool
Colour log₁₀(PSD) when ``True``.
cmap : str
vmin, vmax : float or None
section : str or SectionStyle
Layout preset from :data:`~pycsamt.api.section.PYCSAMT_SECTION`.
title : str
figsize : (float, float) or None
ax : Axes or None
Returns
-------
ax : Axes
"""
sp_dict = _sp_to_dict(sp_input)
names = list(sp_dict.keys())
sps = list(sp_dict.values())
# common log-frequency grid (intersection)
all_freqs = [s.freq for s in sps]
f_min = max(float(f.min()) for f in all_freqs)
f_max = min(float(f.max()) for f in all_freqs)
n_f = max(len(f) for f in all_freqs)
f_grid = np.logspace(np.log10(f_min), np.log10(f_max), n_f)
# build PSD matrix (n_stations, n_freqs)
matrix = np.full((len(sps), n_f), np.nan)
for si, sp in enumerate(sps):
psd_raw = np.real(np.diagonal(sp.S, axis1=1, axis2=2))[:, channel]
matrix[si] = np.interp(
np.log10(f_grid),
np.log10(sp.freq[::-1]),
psd_raw[::-1],
)
data = np.log10(np.maximum(matrix, 1e-40)) if log_psd else matrix
y = np.log10(1.0 / f_grid) # log10(period)
sty = _resolve_section_style(section)
if figsize is None:
figsize = sty.figsize_for(n_stations=len(sps), n_y=n_f)
if ax is None:
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
else:
ax.get_figure()
# pixel edges
st_x = np.arange(len(sps), dtype=float)
dx_half = 0.5
x_edges = np.r_[st_x[0] - dx_half, st_x + dx_half]
if len(y) > 1:
dy = np.abs(np.diff(y)) / 2.0
sgn = np.sign(np.diff(y))
y_edges = np.r_[
y[0] - dy[0], y[:-1] + sgn * dy, y[-1] + sgn[-1] * dy[-1]
]
else:
y_edges = np.r_[y[0] - 0.2, y[0] + 0.2]
pc = ax.pcolormesh(
x_edges,
y_edges,
data.T,
cmap=cmap,
shading="flat",
vmin=vmin,
vmax=vmax,
)
cb_label = r"$\log_{10}$PSD" if log_psd else "PSD"
sty.add_colorbar(pc, ax, label=cb_label)
sty.apply_axis(ax, xlabel="Station", ylabel=r"$\log_{10}T$ (s)")
if y[0] > y[-1]:
ax.invert_yaxis()
sty.apply_stations(ax, st_x, names)
chan_lab = _chan_label(sps[0], channel)
ax.set_title(
title or f"PSD pseudo-section — channel {chan_lab}",
fontsize=10,
pad=6,
)
_spine_style(ax)
return ax
[docs]
def plot_coherence_section(
sp_input: Any,
*,
pair: tuple[int, int] | None = None,
threshold: float = 0.5,
show_threshold: bool = True,
cmap: str = "RdYlGn",
section: str | SectionStyle = "pseudosection",
title: str = "",
figsize: tuple[float, float] | None = None,
ax: Axes | None = None,
) -> Axes:
"""Pseudo-section of coherence across stations (station × period).
Parameters
----------
sp_input : Spectra or list/dict of Spectra
pair : (int, int) or None
Single channel pair ``(i, j)`` to display. When ``None`` the
mean over all upper-triangle pairs is shown.
threshold : float
Value shown by the shared colorbar. Default 0.5.
show_threshold : bool
Add a contour at *threshold* when ``True``.
cmap : str
Default ``"RdYlGn"`` — red=low, green=high coherence.
section : str or SectionStyle
title : str
figsize : (float, float) or None
ax : Axes or None
Returns
-------
ax : Axes
"""
sp_dict = _sp_to_dict(sp_input)
names = list(sp_dict.keys())
sps = list(sp_dict.values())
# common grid
all_freqs = [s.freq for s in sps]
f_min = max(float(f.min()) for f in all_freqs)
f_max = min(float(f.max()) for f in all_freqs)
n_f = max(len(f) for f in all_freqs)
f_grid = np.logspace(np.log10(f_min), np.log10(f_max), n_f)
matrix = np.full((len(sps), n_f), np.nan)
for si, sp in enumerate(sps):
coh_m = coherence_matrix(sp) # (nf, nc, nc)
nc = sp.n_chan
if pair is not None:
i, j = pair
coh_1d = coh_m[:, i, j]
else:
prs = [(i, j) for i in range(nc) for j in range(i + 1, nc)]
coh_1d = np.nanmean([coh_m[:, i, j] for i, j in prs], axis=0)
matrix[si] = np.interp(
np.log10(f_grid),
np.log10(sp.freq[::-1]),
coh_1d[::-1],
)
y = np.log10(1.0 / f_grid)
sty = _resolve_section_style(section)
if figsize is None:
figsize = sty.figsize_for(n_stations=len(sps), n_y=n_f)
if ax is None:
_, ax = plt.subplots(figsize=figsize, constrained_layout=True)
ax.get_figure()
else:
ax.get_figure()
st_x = np.arange(len(sps), dtype=float)
dx_half = 0.5
x_edges = np.r_[st_x[0] - dx_half, st_x + dx_half]
if len(y) > 1:
dy = np.abs(np.diff(y)) / 2.0
sgn = np.sign(np.diff(y))
y_edges = np.r_[
y[0] - dy[0], y[:-1] + sgn * dy, y[-1] + sgn[-1] * dy[-1]
]
else:
y_edges = np.r_[y[0] - 0.2, y[0] + 0.2]
pc = ax.pcolormesh(
x_edges,
y_edges,
matrix.T,
cmap=cmap,
shading="flat",
vmin=0.0,
vmax=1.0,
)
sty.add_colorbar(pc, ax, label=r"$\gamma^2$")
if show_threshold and matrix.shape[1] > 1:
ax.contour(
st_x,
y,
matrix.T,
levels=[threshold],
colors=["k"],
linewidths=0.8,
linestyles=["--"],
)
sty.apply_axis(ax, xlabel="Station", ylabel=r"$\log_{10}T$ (s)")
if y[0] > y[-1]:
ax.invert_yaxis()
sty.apply_stations(ax, st_x, names)
pair_str = (
f"ch{pair[0]}-ch{pair[1]}" if pair is not None else "mean all pairs"
)
ax.set_title(
title or f"Coherence pseudo-section — {pair_str}",
fontsize=10,
pad=6,
)
_spine_style(ax)
return ax