# pycsamt/emtools/plot.py
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
from ..api._rose_style import _UNSET
from ..api.control import PYCSAMT_CONTROL, wrap_phase
from ..api.style import PYCSAMT_STYLE
from ..utils.plot import plot_errorbar
from ._core import (
_axes_list,
_get_t_block,
_get_z_block,
_iter_items,
_name,
ensure_sites,
)
# ------------------------------- helpers -------------------------------- #
def _style_col() -> dict:
"""Return component → color dict from the current PYCSAMT_STYLE.mt."""
mt = PYCSAMT_STYLE.mt
return {
"xy": mt.xy.color,
"yx": mt.yx.color,
"xx": mt.xx.color,
"yy": mt.yy.color,
}
# legacy alias kept so that any direct caller outside this module still works
_COL = {
"xy": "#1f77b4",
"yx": "#d62728",
"xx": "#2ca02c",
"yy": "#9467bd",
}
_IDX = {"x": 0, "y": 1}
def _comp_slice(z: np.ndarray, comp: str) -> np.ndarray:
a, b = _IDX[comp[0]], _IDX[comp[1]]
return z[:, a, b]
def _phase_deg(z: np.ndarray) -> np.ndarray:
return np.degrees(np.angle(z))
def _wrap_phase(ph: np.ndarray, rng: tuple[float, float]) -> np.ndarray:
return wrap_phase(ph, rng)
def _rhoa_from(z: np.ndarray, fr: np.ndarray) -> np.ndarray:
return (0.2 * (np.abs(z) ** 2)) / (fr + 1e-24)
def _err_log10_rhoa(z: np.ndarray, ze: np.ndarray | None) -> np.ndarray:
if ze is None:
return None
# δ log10 ρ ≈ 2/(ln10) · σ/|Z|
s = np.abs(ze)
m = np.abs(z) + 1e-24
return (2.0 / np.log(10.0)) * (s / m)
def _err_log10_mag(z: np.ndarray, ze: np.ndarray | None) -> np.ndarray:
if ze is None:
return None
# δ log10|Z| ≈ σ/(|Z| ln 10)
s = np.abs(ze)
m = np.abs(z) + 1e-24
return s / (m * np.log(10.0))
def _err_phase_deg(z: np.ndarray, ze: np.ndarray | None) -> np.ndarray:
if ze is None:
return None
# small-angle approx: δφ ≈ σ/|Z| (rad)
s = np.abs(ze)
m = np.abs(z) + 1e-24
return np.degrees(s / m)
def _err_rhoa(
z: np.ndarray, ze: np.ndarray | None, fr: np.ndarray
) -> np.ndarray | None:
"""Return approximate apparent-resistivity uncertainty."""
if ze is None:
return None
rho = _rhoa_from(z, fr)
s = np.abs(ze)
m = np.abs(z) + 1e-24
return rho * 2.0 * s / m
def _pick(axs, i, j):
return axs[i * 2 + j]
def _zblk_flex(ed: Any):
try:
return _get_z_block(ed, with_errors=True)
except TypeError:
return _get_z_block(ed)
def _control_or_default(control):
"""Return the active plotting-view control."""
return control or PYCSAMT_CONTROL
def _site_items(
sites: Any,
*,
recursive: bool,
on_dup: str,
strict: bool,
verbose: int,
) -> list[Any]:
"""Return site items, falling back to raw iterables when needed."""
try:
normalized = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
items = list(_iter_items(normalized))
if items:
return items
except Exception:
if strict:
raise
return list(_iter_items(sites))
def _x_from_freq(fr: np.ndarray, control) -> tuple[np.ndarray, str, bool]:
"""Return x values, label, and log-scale flag from frequencies."""
xctrl = _control_or_default(control).x
return xctrl.transform(fr), xctrl.label(), xctrl.use_log_scale()
def _rho_display(
z: np.ndarray,
ze: np.ndarray | None,
fr: np.ndarray,
control,
) -> tuple[np.ndarray, np.ndarray | None, str]:
"""Return apparent resistivity, errors, and axis label."""
c = _control_or_default(control)
rho = _rhoa_from(z, fr)
rho_err = _err_rhoa(z, ze, fr)
return c.rho.transform(rho), c.rho.error(rho, rho_err), c.rho.label()
def _phase_display(
z: np.ndarray,
ze: np.ndarray | None,
control,
phase_range: tuple[float, float] | None,
) -> tuple[np.ndarray, np.ndarray | None, str, tuple[float, float] | None]:
"""Return phase, errors, label, and display range."""
c = _control_or_default(control)
ph = _phase_deg(z)
pc = c.phase
if phase_range is not None:
ph = wrap_phase(ph, phase_range)
display_range = phase_range
else:
ph = pc.transform(ph)
display_range = pc.range if pc.wrap else None
err = _err_phase_deg(z, ze)
if pc.unit.lower() == "radian":
if err is not None:
err = np.deg2rad(err)
if display_range is not None:
display_range = tuple(np.deg2rad(display_range))
return ph, err, pc.label(), display_range
def _component_style(comp: str, raw: bool, force_style: bool):
"""Return style object for a component or raw data."""
if raw and not force_style:
return PYCSAMT_STYLE.raw
return PYCSAMT_STYLE.mt.component(comp)
def _errorbar_from_style(
ax,
x: np.ndarray,
y: np.ndarray,
yerr: np.ndarray | None,
style,
*,
color: str | None = None,
show_error_bars: bool = True,
):
"""Draw a styled errorbar curve."""
kwargs = (
style.errorbar_kwargs() if hasattr(style, "errorbar_kwargs") else {}
)
if color is not None:
kwargs["color"] = color
kwargs["ecolor"] = color
kwargs["mec"] = color
marker = kwargs.pop("marker", "o")
ms = kwargs.pop("ms", kwargs.pop("markersize", 4.0))
mfc = kwargs.pop("mfc", "white")
mew = kwargs.pop("mew", 1.0)
ls = kwargs.pop("ls", "-")
lw = kwargs.pop("lw", 1.0)
ecolor = kwargs.pop("ecolor", kwargs.get("color", "black"))
capsize = kwargs.pop("capsize", 2.0)
elinewidth = kwargs.pop("elinewidth", lw)
label = kwargs.pop("label", None)
if not show_error_bars:
yerr = None
return ax.errorbar(
x,
y,
yerr=yerr,
color=kwargs.pop("color", "black"),
marker=marker,
ms=ms,
mfc=mfc,
mec=ecolor,
mew=mew,
ls=ls,
lw=lw,
ecolor=ecolor,
capsize=capsize,
elinewidth=elinewidth,
label=label,
**kwargs,
)
def _axes_group_bounds(
axes: list[plt.Axes],
) -> tuple[float, float, float, float]:
"""Return figure-coordinate bounds for a group of axes."""
boxes = [ax.get_position() for ax in axes]
left = min(box.x0 for box in boxes)
right = max(box.x1 for box in boxes)
bottom = min(box.y0 for box in boxes)
top = max(box.y1 for box in boxes)
return left, right, bottom, top
def _add_raw_group_labels(
fig: plt.Figure,
ax_rho: list[plt.Axes],
ax_phase: list[plt.Axes],
*,
station: str,
rho_label: str,
phase_label: str,
x_label: str,
show_x_label: bool,
x_label_pad: float = 0.078,
) -> None:
"""Draw shared labels for one raw 1-D station group."""
left, right, bottom, top = _axes_group_bounds(ax_rho + ax_phase)
xmid = 0.5 * (left + right)
rho_y = 0.5 * (ax_rho[0].get_position().y0 + ax_rho[0].get_position().y1)
phase_y = 0.5 * (
ax_phase[0].get_position().y0 + ax_phase[0].get_position().y1
)
fig.text(
xmid,
min(top + 0.024, 0.985),
station,
ha="center",
va="bottom",
fontsize=9,
fontweight="bold",
)
fig.text(
max(left - 0.026, 0.006),
rho_y,
rho_label,
ha="right",
va="center",
rotation=90,
fontsize=8,
)
fig.text(
max(left - 0.026, 0.006),
phase_y,
phase_label,
ha="right",
va="center",
rotation=90,
fontsize=8,
)
if show_x_label:
fig.text(
xmid,
max(bottom - x_label_pad, 0.006),
x_label,
ha="center",
va="top",
fontsize=8,
)
def _apply_raw_tick_style(
ax: plt.Axes,
*,
show_x: bool,
rotation: float,
fontsize: int,
) -> None:
"""Apply compact tick-label styling to raw 1-D panels."""
ax.tick_params(axis="both", labelsize=fontsize)
if show_x:
ax.tick_params(axis="x", labelbottom=True)
for label in ax.get_xticklabels():
label.set_rotation(rotation)
label.set_ha("center" if abs(rotation) >= 80 else "right")
label.set_fontsize(fontsize)
else:
ax.tick_params(axis="x", labelbottom=False)
def _raw_label_mode(
label_mode: str | None,
shared_group_labels: bool,
) -> str:
"""Resolve the raw-panel label mode."""
if label_mode is None:
return "shared" if shared_group_labels else "axis"
mode = str(label_mode).lower()
if mode not in {"shared", "axis"}:
msg = "label_mode must be 'shared' or 'axis'."
raise ValueError(msg)
return mode
# ------------------------------ main plot -------------------------------- #
[docs]
def plot_sites_panels(
sites: Any,
*,
components: tuple[str, ...] = ("xy", "yx"),
quantity: str = "rhoa", # rhoa|impedance
x_axis: str = "period", # period|frequency
phase_range: tuple[float, float] | None = (-90.0, 90.0),
stations: list[str] | None = None,
ncols: int = 6,
wspace: float = 0.20,
hspace: float = 0.08,
height_ratio: tuple[int, int] = (2, 1),
axes=None,
figsize_scale: tuple[float, float] = (2.6, 2.6),
colors: dict[str, str] | None = None, # None → from PYCSAMT_STYLE.mt
marker=_UNSET, # default: PYCSAMT_STYLE.mt.xy.marker
ms=_UNSET, # default: PYCSAMT_STYLE.mt.xy.ms
lw=_UNSET, # default: PYCSAMT_STYLE.mt.xy.lw
ls=_UNSET, # default: PYCSAMT_STYLE.mt.xy.ls
show_error_bars: bool = True,
show_legend: bool = False,
title_fmt: str = "{station}",
ylim_rhoa: tuple[float, float] | None = None,
ylim_phase: tuple[float, float] | None = None,
grid: bool = True,
preserve_duplicates: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
):
# ── resolve visual style from PYCSAMT_STYLE.mt ───────────────────────
_mt = PYCSAMT_STYLE.mt
if marker is _UNSET:
marker = _mt.xy.marker
if ms is _UNSET:
ms = _mt.xy.ms
if lw is _UNSET:
lw = _mt.xy.lw
if ls is _UNSET:
ls = _mt.xy.ls
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
comps = tuple(c.lower() for c in components)
cmap = {**_style_col(), **(colors or {})}
# gather stations
items = []
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
if stations and st not in stations:
continue
items.append((st, ed))
if not items:
axes_given = _axes_list(axes, 1) if axes is not None else None
if axes_given is None:
fig = plt.figure(figsize=(6, 3))
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no sites", ha="center", va="center")
return fig
n = len(items)
ncols = max(1, int(ncols))
nrows = (n + ncols - 1) // ncols
W, H = figsize_scale
axes_given = _axes_list(axes, n * 2) if axes is not None else None
if axes_given is None:
fig = plt.figure(
figsize=(W * ncols, H * nrows),
constrained_layout=False,
)
outer = fig.add_gridspec(nrows, ncols, wspace=wspace, hspace=hspace)
axs: list[plt.Axes] = []
for k in range(nrows * ncols):
r, c = divmod(k, ncols)
if k >= n:
# filler invisible axes
gs = outer[r, c].subgridspec(
2, 1, hspace=0.02, height_ratios=height_ratio
)
ax1 = fig.add_subplot(gs[0])
ax1.axis("off")
ax2 = fig.add_subplot(gs[1])
ax2.axis("off")
axs += [ax1, ax2]
continue
gs = outer[r, c].subgridspec(
2, 1, hspace=0.02, height_ratios=height_ratio
)
axR = fig.add_subplot(gs[0])
axP = fig.add_subplot(gs[1], sharex=axR)
axs += [axR, axP]
else:
axs = axes_given
fig = axs[0].figure
# draw each station
for idx, (st, ed) in enumerate(items):
Z, z, fr, ze = _get_z_block(ed, with_errors=True)
if z is None or fr is None:
continue
x = (1.0 / fr) if x_axis == "period" else fr
axR = _pick(axs, idx, 0)
axP = _pick(axs, idx, 1)
axR.set_xscale("log")
axP.set_xscale("log")
if grid:
axR.grid(True, alpha=0.25, which="both")
axP.grid(True, alpha=0.25, which="both")
for comp in comps:
col = cmap.get(comp, "k")
zz = _comp_slice(z, comp)
ee = None
if isinstance(ze, np.ndarray) and ze.shape == z.shape:
ee = _comp_slice(ze, comp)
# top: rhoa or |Z|
if quantity == "impedance":
ymag = np.abs(zz)
y = np.log10(ymag)
yerr = _err_log10_mag(zz, ee)
ylab = r"$\log_{10}|Z|$"
else:
rho = _rhoa_from(zz, fr)
y = np.log10(rho)
yerr = _err_log10_rhoa(zz, ee)
ylab = r"$\log_{10}\rho_a$ ($\Omega\,\mathrm{m}$)"
plot_errorbar(
axR,
x,
y,
y_err=yerr,
color=col,
marker=marker,
ms=ms,
ls=ls,
lw=lw,
show_error_bars=show_error_bars,
)
# bottom: phase
ph = _phase_deg(zz)
if phase_range is not None:
ph = _wrap_phase(ph, phase_range)
plot_errorbar(
axP,
x,
ph,
y_err=_err_phase_deg(zz, ee),
color=col,
marker=marker,
ms=ms,
ls=ls,
lw=lw,
show_error_bars=False, # phase bars optional; off
)
# cosmetics per panel
if idx // ncols == nrows - 1:
axP.set_xlabel(
"Period (s)" if x_axis == "period" else "Freq (Hz)"
)
else:
axP.set_xlabel("")
axR.set_ylabel(ylab)
axP.set_ylabel("Phase (°)")
if ylim_rhoa:
axR.set_ylim(*ylim_rhoa)
if ylim_phase:
axP.set_ylim(*ylim_phase)
elif phase_range is not None:
axP.set_ylim(*phase_range)
if show_legend and idx == 0:
labs = [c.upper() for c in comps]
handles = []
for c in comps:
h = axR.plot([], [], color=cmap.get(c, "k"), ls=ls, lw=lw)[0]
handles.append(h)
axR.legend(handles, labs, ncol=len(labs), fontsize=8)
axR.set_title(title_fmt.format(station=st), pad=4)
# tidy shared ticks
for i in range(n):
axR = _pick(axs, i, 0)
axP = _pick(axs, i, 1)
if (i % ncols) != 0:
axR.set_yticklabels([])
axP.set_yticklabels([])
if (i // ncols) != (nrows - 1):
axP.set_xticklabels([])
return fig
[docs]
def plot_raw_sites_1d(
sites: Any,
*,
stations: list[str] | None = None,
components: tuple[str, ...] = ("xx", "xy", "yx", "yy"),
raw: bool = True,
force_style: bool = False,
control: Any | None = None,
phase_range: tuple[float, float] | None = None,
ncols_groups: int = 3,
comp_wspace: float = 0.12,
group_hspace: float = 0.25,
height_ratio: tuple[int, int] = (2, 1),
axes=None,
figsize_scale: tuple[float, float] = (4.2, 3.1),
colors: dict[str, str] | None = None,
title_group_fmt: str = "{station}",
title_comp_fmt: str = "Z{component}",
shared_group_labels: bool = True,
label_mode: str | None = None,
shared_x_label_pad: float = 0.078,
x_tick_rotation: float | None = None,
tick_fontsize: int = 7,
show_error_bars: bool = True,
show_component_legend: bool = True,
ylim_rhoa: tuple[float, float] | None = None,
ylim_phase: tuple[float, float] | None = None,
grid: bool = True,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
):
"""Plot raw or processed 1-D rho/phase panels by station.
The layout mirrors diagnostic raw-data figures used in AMT/MT
workflows: every selected station is a group, every component is a
column, and each component column contains apparent resistivity above
phase. When ``raw=True`` the raw-data style from
:data:`pycsamt.api.style.PYCSAMT_STYLE.raw` is used automatically,
producing black diagnostic traces unless ``force_style=True`` or
explicit ``colors`` are provided.
"""
ctl = _control_or_default(control)
comps = tuple(c.lower() for c in components)
resolved_label_mode = _raw_label_mode(label_mode, shared_group_labels)
use_shared_labels = resolved_label_mode == "shared"
if x_tick_rotation is None:
x_tick_rotation = 45.0 if use_shared_labels else 90.0
site_items = _site_items(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
items = []
keep = set(stations or [])
for i, ed in enumerate(site_items):
st = _name(ed, i)
if keep and st not in keep:
continue
items.append((st, ed))
if not items:
axes_given = _axes_list(axes, 1) if axes is not None else None
if axes_given is None:
fig = plt.figure(figsize=(6, 3))
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
return fig
n = len(items)
ncols_groups = max(1, int(ncols_groups))
nrows = (n + ncols_groups - 1) // ncols_groups
W, H = figsize_scale
axes_given = (
_axes_list(axes, n * len(comps) * 2) if axes is not None else None
)
if axes_given is None:
fig = plt.figure(
figsize=(W * ncols_groups, H * nrows),
constrained_layout=False,
)
outer = fig.add_gridspec(
nrows,
ncols_groups,
wspace=0.35,
hspace=group_hspace,
)
panel_axes: list[tuple[list[plt.Axes], list[plt.Axes]]] = []
for g in range(n):
r, c = divmod(g, ncols_groups)
gs = outer[r, c].subgridspec(
2,
len(comps),
hspace=0.02,
wspace=comp_wspace,
height_ratios=height_ratio,
)
ax_rho = [fig.add_subplot(gs[0, j]) for j in range(len(comps))]
ax_phase = [
fig.add_subplot(gs[1, j], sharex=ax_rho[j])
for j in range(len(comps))
]
panel_axes.append((ax_rho, ax_phase))
else:
fig = axes_given[0].figure
panel_axes = []
width = len(comps)
for g in range(n):
base = g * width * 2
panel_axes.append(
(
axes_given[base : base + width],
axes_given[base + width : base + 2 * width],
)
)
for g, (station, ed) in enumerate(items):
ax_rho, ax_phase = panel_axes[g]
station_label = title_group_fmt.format(station=station)
if not use_shared_labels:
ax_rho[0].set_title(station_label, pad=6)
out = _zblk_flex(ed)
if len(out) == 4:
_, z, fr, ze = out
else:
_, z, fr = out[:3]
ze = None
if z is None or fr is None:
for ax in ax_rho + ax_phase:
ax.axis("off")
continue
fr = np.asarray(fr, dtype=float)
x, x_label, log_x = _x_from_freq(fr, ctl)
group_rho_label = ""
group_phase_label = ""
for j, comp in enumerate(comps):
a_rho = ax_rho[j]
a_phase = ax_phase[j]
a_rho.text(
0.5,
0.96,
title_comp_fmt.format(component=comp.upper()),
ha="center",
va="top",
transform=a_rho.transAxes,
fontsize=8,
bbox={
"boxstyle": "round,pad=0.12",
"facecolor": "white",
"edgecolor": "none",
"alpha": 0.75,
},
)
if log_x:
a_rho.set_xscale("log")
a_phase.set_xscale("log")
if grid:
a_rho.grid(True, alpha=0.25, which="both")
a_phase.grid(True, alpha=0.25, which="both")
zz = _comp_slice(z, comp)
ee = None
if isinstance(ze, np.ndarray) and ze.shape == z.shape:
ee = _comp_slice(ze, comp)
rho_y, rho_err, rho_label = _rho_display(zz, ee, fr, ctl)
phase_y, phase_err, phase_label, display_range = _phase_display(
zz,
ee,
ctl,
phase_range,
)
group_rho_label = rho_label
group_phase_label = phase_label
style = _component_style(comp, raw, force_style)
color = None
if colors is not None:
color = colors.get(comp)
_errorbar_from_style(
a_rho,
x,
rho_y,
rho_err,
style,
color=color,
show_error_bars=show_error_bars,
)
_errorbar_from_style(
a_phase,
x,
phase_y,
phase_err,
style,
color=color,
show_error_bars=False,
)
if ylim_rhoa is not None:
a_rho.set_ylim(*ylim_rhoa)
if ylim_phase is not None:
a_phase.set_ylim(*ylim_phase)
elif display_range is not None:
a_phase.set_ylim(*display_range)
is_bottom_group = (g // ncols_groups) == (nrows - 1)
if j == 0 and not use_shared_labels:
a_rho.set_ylabel(rho_label)
a_phase.set_ylabel(phase_label)
else:
a_rho.set_ylabel("")
a_phase.set_ylabel("")
if j != 0:
a_rho.set_yticklabels([])
a_phase.set_yticklabels([])
if is_bottom_group and not use_shared_labels:
a_phase.set_xlabel(x_label)
else:
a_phase.set_xlabel("")
_apply_raw_tick_style(
a_phase,
show_x=is_bottom_group,
rotation=float(x_tick_rotation),
fontsize=int(tick_fontsize),
)
_apply_raw_tick_style(
a_rho,
show_x=False,
rotation=float(x_tick_rotation),
fontsize=int(tick_fontsize),
)
if use_shared_labels:
_add_raw_group_labels(
fig,
ax_rho,
ax_phase,
station=station_label,
rho_label=group_rho_label,
phase_label=group_phase_label,
x_label=x_label,
show_x_label=(g // ncols_groups) == (nrows - 1),
x_label_pad=shared_x_label_pad,
)
if show_component_legend:
handles = []
labels = []
for comp in comps:
style = _component_style(comp, raw, force_style)
color = colors.get(comp) if colors else None
kwargs = (
style.plot_kwargs() if hasattr(style, "plot_kwargs") else {}
)
if color is not None:
kwargs["color"] = color
handles.append(plt.Line2D([], [], **kwargs))
labels.append(comp.upper())
fig.legend(
handles,
labels,
loc="lower center",
ncol=len(labels),
frameon=False,
# Far enough below the figure bottom edge to clear the
# shared-group x-axis label text: with an odd ncols_groups
# (e.g. 3), one group's label sits at the same horizontal
# center as this legend, and -0.015 used to overlap it.
bbox_to_anchor=(0.5, -0.14),
fontsize=8,
)
return fig
def _add_response_tipper_group_labels(
fig: plt.Figure,
axes: Sequence[plt.Axes],
row_axes: dict[str, plt.Axes],
*,
station: str,
rho_label: str,
phase_label: str,
x_label: str,
show_x_label: bool,
x_label_pad: float = 0.070,
) -> None:
"""Draw shared labels for one response/tipper station group."""
left, right, bottom, top = _axes_group_bounds(list(axes))
xmid = 0.5 * (left + right)
fig.text(
xmid,
min(top + 0.020, 0.985),
station,
ha="center",
va="bottom",
fontsize=9,
fontweight="bold",
)
for key, label in (
("rho", rho_label),
("phase", phase_label),
("tx", r"$T_x$"),
("ty", r"$T_y$"),
):
ax = row_axes.get(key)
if ax is None or not label:
continue
box = ax.get_position()
fig.text(
max(left - 0.046, 0.006),
0.5 * (box.y0 + box.y1),
label,
ha="right",
va="center",
rotation=90,
fontsize=8,
)
if show_x_label:
fig.text(
xmid,
max(bottom - x_label_pad, 0.006),
x_label,
ha="center",
va="top",
fontsize=8,
)
def _plot_tipper_part(
ax: plt.Axes,
x: np.ndarray,
values: np.ndarray,
errors: np.ndarray | None,
*,
show_error_bars: bool,
line_style: str | None = None,
real_label: str = "real",
imag_label: str = "imag",
) -> None:
"""Plot real and imaginary tipper parts on one compact axis."""
real_style = PYCSAMT_STYLE.mt.xy
imag_style = PYCSAMT_STYLE.mt.yx
if line_style is not None:
real_style = real_style.copy(ls=line_style)
imag_style = imag_style.copy(ls=line_style)
_errorbar_from_style(
ax,
x,
np.real(values),
errors,
real_style,
show_error_bars=show_error_bars,
)
imag_kw = imag_style.copy(marker="x", mfc=imag_style.color)
_errorbar_from_style(
ax,
x,
np.imag(values),
errors,
imag_kw,
show_error_bars=show_error_bars,
)
ax.plot([], [], **real_style.plot_kwargs(label=real_label))
ax.plot([], [], **imag_kw.plot_kwargs(label=imag_label))
[docs]
def plot_response_tipper(
sites: Any,
*,
stations: list[str] | None = None,
components: tuple[str, ...] = ("xy", "yx"),
tipper_components: tuple[str, ...] = ("tx", "ty"),
raw: bool = False,
force_style: bool = False,
control: Any | None = None,
phase_range: tuple[float, float] | None = None,
ncols_groups: int = 3,
comp_wspace: float = 0.12,
group_hspace: float = 0.32,
height_ratios: tuple[float, ...] = (2.2, 1.1, 0.75, 0.75),
axes=None,
figsize_scale: tuple[float, float] = (4.8, 4.6),
colors: dict[str, str] | None = None,
tipper_span_group: bool = False,
line_style: str | None = None,
tipper_line_style: str | None = ":",
label_component_x: bool = True,
label_tipper_x: bool = True,
title_group_fmt: str = "{station}",
title_comp_fmt: str = "Z{component}",
shared_group_labels: bool = True,
shared_x_label_pad: float = 0.074,
x_tick_rotation: float = 0.0,
tick_fontsize: int = 7,
show_error_bars: bool = True,
show_tipper_error_bars: bool = False,
show_component_legend: bool = True,
show_tipper_legend: bool = True,
ylim_rhoa: tuple[float, float] | None = None,
ylim_phase: tuple[float, float] | None = None,
ylim_tipper: tuple[float, float] | None = (-0.6, 0.6),
grid: bool = True,
preserve_duplicates: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> plt.Figure:
"""Plot impedance response panels with station-level tipper rows.
The figure is designed for MT/AMT quality control where impedance and
tipper behaviour must be inspected together. Each station is a group:
apparent resistivity and phase are shown per impedance component, while
compact :math:`T_x` and :math:`T_y` panels span the full group width.
Parameters
----------
sites : Sites-like
EDI path, collection of EDI files, ``Sites`` object, or iterable
accepted by :func:`pycsamt.emtools._core.ensure_sites`.
stations : list of str, optional
Station names to display. When omitted, all stations are used.
components : tuple of str, default ("xy", "yx")
Impedance tensor components plotted in the resistivity and phase
rows. Values are component keys such as ``"xy"``, ``"yx"``,
``"xx"``, or ``"yy"``.
tipper_components : tuple of str, default ("tx", "ty")
Tipper components to draw. The usual complete diagnostic uses both
``"tx"`` and ``"ty"``.
raw : bool, default False
If ``True``, impedance response curves use the package raw-data
style unless ``force_style`` is also true. Tipper real/imaginary
curves keep distinct package component colours for readability.
force_style : bool, default False
Use component colours even when ``raw=True``.
control : object, optional
Plot view control. Defaults to
:data:`pycsamt.api.control.PYCSAMT_CONTROL`.
phase_range : tuple of float or None, optional
Explicit phase display range. If omitted, the active
``control.phase`` policy is used.
ncols_groups : int, default 3
Number of station groups per figure row.
comp_wspace, group_hspace : float
Spacing inside and between station groups.
height_ratios : tuple of float, default (2.2, 1.1, 0.75, 0.75)
Relative heights for rho, phase, Tx, and Ty rows. If only one
tipper component is requested, the unused extra ratio is ignored.
figsize_scale : tuple of float, default (4.8, 4.6)
Width and height multiplier for each station group row/column.
colors : dict, optional
Optional impedance component colour overrides.
tipper_span_group : bool, default False
If ``True``, each tipper row spans all impedance component columns
in a station group. If ``False``, Tx and Ty are repeated under
each component column, giving a compact grid such as
``rho/phase/Tx/Ty`` for every component.
line_style : str or None, optional
Optional line style override for impedance curves.
tipper_line_style : str or None, default ":"
Optional line style override for real and imaginary tipper curves.
label_component_x, label_tipper_x : bool, default True
Put the active x-axis label under each impedance-component stack
and under the bottom tipper row.
title_group_fmt, title_comp_fmt : str
Format strings for station and component labels.
shared_group_labels : bool, default True
Use group-level rho/phase/tipper labels instead of repeating labels
on every small axis.
shared_x_label_pad : float, default 0.074
Figure-coordinate padding used for shared x labels.
x_tick_rotation : float, default 0
Rotation for bottom x tick labels.
tick_fontsize : int, default 7
Tick-label size for compact panels.
show_error_bars, show_tipper_error_bars : bool
Toggle impedance and tipper error bars independently.
show_component_legend, show_tipper_legend : bool
Toggle global legends.
ylim_rhoa, ylim_phase, ylim_tipper : tuple or None
Optional y-axis limits.
grid : bool, default True
Draw light panel grids.
preserve_duplicates : bool, default False
Preserve repeated in-memory EDI objects instead of normalizing them
through :func:`ensure_sites`. This is useful for diagnostic demos or
before/after comparisons where two display stations intentionally
share the same source station name.
recursive, on_dup, strict, verbose
Forwarded to :func:`pycsamt.emtools._core.ensure_sites`.
Returns
-------
matplotlib.figure.Figure
The assembled response/tipper figure.
Examples
--------
>>> from pycsamt.emtools.plot import plot_response_tipper
>>> fig = plot_response_tipper(
... "data/AMT/TIPPER/HBH03_IMP.edi",
... components=("xy", "yx"),
... )
"""
ctl = _control_or_default(control)
comps = tuple(c.lower() for c in components)
tips = tuple(t.lower() for t in tipper_components)
for comp in comps:
if comp not in {"xx", "xy", "yx", "yy"}:
msg = f"unknown impedance component {comp!r}."
raise ValueError(msg)
for tip in tips:
if tip not in {"tx", "ty"}:
msg = "tipper_components must contain only 'tx' and/or 'ty'."
raise ValueError(msg)
if (
preserve_duplicates
and isinstance(sites, (list, tuple))
and not all(isinstance(item, (str, bytes)) for item in sites)
):
site_items = list(sites)
else:
site_items = _site_items(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
keep = set(stations or [])
items: list[tuple[str, Any]] = []
seen_names: dict[str, int] = {}
for i, ed in enumerate(site_items):
base_st = _name(ed, i)
count = seen_names.get(base_st, 0) + 1
seen_names[base_st] = count
st = base_st if count == 1 else f"{base_st}_{count}"
if keep and st not in keep:
continue
items.append((st, ed))
if not items:
axes_given = _axes_list(axes, 1) if axes is not None else None
if axes_given is None:
fig = plt.figure(figsize=(6, 3))
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
return fig
n = len(items)
ncols_groups = max(1, int(ncols_groups))
nrows = (n + ncols_groups - 1) // ncols_groups
W, H = figsize_scale
row_names = ("rho", "phase", *tips)
ratios = tuple(height_ratios[: len(row_names)])
if len(ratios) < len(row_names):
ratios = ratios + tuple([0.75] * (len(row_names) - len(ratios)))
axes_per_group = 2 * len(comps) + (
len(tips) if tipper_span_group else len(tips) * len(comps)
)
axes_given = (
_axes_list(axes, n * axes_per_group) if axes is not None else None
)
groups: list[dict[str, Any]] = []
if axes_given is None:
fig = plt.figure(
figsize=(W * ncols_groups, H * nrows),
constrained_layout=False,
)
outer = fig.add_gridspec(
nrows,
ncols_groups,
wspace=0.35,
hspace=group_hspace,
)
for g in range(n):
r, c = divmod(g, ncols_groups)
gs = outer[r, c].subgridspec(
len(row_names),
len(comps),
hspace=0.28,
wspace=comp_wspace,
height_ratios=ratios,
)
ax_rho = [fig.add_subplot(gs[0, j]) for j in range(len(comps))]
ax_phase = [
fig.add_subplot(gs[1, j], sharex=ax_rho[j])
for j in range(len(comps))
]
tip_axes: dict[str, plt.Axes | list[plt.Axes]] = {}
for row, tip in enumerate(tips, start=2):
if tipper_span_group:
tip_axes[tip] = fig.add_subplot(
gs[row, :], sharex=ax_rho[0]
)
else:
tip_axes[tip] = [
fig.add_subplot(gs[row, j], sharex=ax_rho[j])
for j in range(len(comps))
]
groups.append({"rho": ax_rho, "phase": ax_phase, "tip": tip_axes})
else:
fig = axes_given[0].figure
width = len(comps)
for g in range(n):
base = g * axes_per_group
ax_rho = axes_given[base : base + width]
ax_phase = axes_given[base + width : base + 2 * width]
cursor = base + 2 * width
tip_axes: dict[str, plt.Axes | list[plt.Axes]] = {}
for tip in tips:
if tipper_span_group:
tip_axes[tip] = axes_given[cursor]
cursor += 1
else:
tip_axes[tip] = axes_given[cursor : cursor + width]
cursor += width
groups.append({"rho": ax_rho, "phase": ax_phase, "tip": tip_axes})
for g, (station, ed) in enumerate(items):
group = groups[g]
ax_rho = group["rho"]
ax_phase = group["phase"]
tip_axes = group["tip"]
station_label = title_group_fmt.format(station=station)
z_out = _zblk_flex(ed)
if len(z_out) == 4:
_, z, fr, ze = z_out
else:
_, z, fr = z_out[:3]
ze = None
if z is None or fr is None:
flat_tip_axes = []
for value in tip_axes.values():
flat_tip_axes.extend(
value if isinstance(value, list) else [value]
)
for ax in ax_rho + ax_phase + flat_tip_axes:
ax.axis("off")
continue
fr = np.asarray(fr, dtype=float)
x, x_label, log_x = _x_from_freq(fr, ctl)
group_rho_label = ""
group_phase_label = ""
is_bottom_group = (g // ncols_groups) == (nrows - 1)
for j, comp in enumerate(comps):
a_rho = ax_rho[j]
a_phase = ax_phase[j]
a_rho.text(
0.5,
0.96,
title_comp_fmt.format(component=comp.upper()),
ha="center",
va="top",
transform=a_rho.transAxes,
fontsize=8,
bbox={
"boxstyle": "round,pad=0.12",
"facecolor": "white",
"edgecolor": "none",
"alpha": 0.75,
},
)
for ax in (a_rho, a_phase):
if log_x:
ax.set_xscale("log")
if grid:
ax.grid(True, alpha=0.25, which="both")
zz = _comp_slice(z, comp)
ee = None
if isinstance(ze, np.ndarray) and ze.shape == z.shape:
ee = _comp_slice(ze, comp)
rho_y, rho_err, rho_label = _rho_display(zz, ee, fr, ctl)
phase_y, phase_err, phase_label, display_range = _phase_display(
zz,
ee,
ctl,
phase_range,
)
group_rho_label = rho_label
group_phase_label = phase_label
style = _component_style(comp, raw, force_style)
if line_style is not None and hasattr(style, "copy"):
style = style.copy(ls=line_style)
color = colors.get(comp) if colors else None
_errorbar_from_style(
a_rho,
x,
rho_y,
rho_err,
style,
color=color,
show_error_bars=show_error_bars,
)
_errorbar_from_style(
a_phase,
x,
phase_y,
phase_err,
style,
color=color,
show_error_bars=False,
)
if ylim_rhoa is not None:
a_rho.set_ylim(*ylim_rhoa)
if ylim_phase is not None:
a_phase.set_ylim(*ylim_phase)
elif display_range is not None:
a_phase.set_ylim(*display_range)
if not shared_group_labels and j == 0:
a_rho.set_ylabel(rho_label)
a_phase.set_ylabel(phase_label)
elif j != 0:
a_rho.set_yticklabels([])
a_phase.set_yticklabels([])
a_rho.set_xlabel("")
a_phase.set_xlabel(x_label if label_component_x else "")
_apply_raw_tick_style(
a_rho,
show_x=False,
rotation=x_tick_rotation,
fontsize=tick_fontsize,
)
_apply_raw_tick_style(
a_phase,
show_x=bool(label_component_x),
rotation=x_tick_rotation,
fontsize=tick_fontsize,
)
tipper_x_label = None
t_out = _get_t_block(ed, with_errors=True)
if len(t_out) == 4:
_, tipper, tfr, terr = t_out
else:
_, tipper, tfr = t_out[:3]
terr = None
if tipper is None or tfr is None:
for tip, axes_obj in tip_axes.items():
axes_list = (
axes_obj if isinstance(axes_obj, list) else [axes_obj]
)
for ax in axes_list:
ax.text(
0.5,
0.5,
"no tipper",
ha="center",
va="center",
transform=ax.transAxes,
fontsize=8,
color="0.4",
)
if grid:
ax.grid(True, alpha=0.25, which="both")
else:
tx, t_label, t_log_x = _x_from_freq(np.asarray(tfr, float), ctl)
tipper = np.asarray(tipper, complex)
terr_arr = terr if isinstance(terr, np.ndarray) else None
for tip in tips:
axes_obj = tip_axes[tip]
axes_list = (
axes_obj if isinstance(axes_obj, list) else [axes_obj]
)
col = 0 if tip == "tx" else 1
err = None
if terr_arr is not None and terr_arr.shape == tipper.shape:
err = np.abs(terr_arr[:, col])
for ax in axes_list:
if t_log_x:
ax.set_xscale("log")
if grid:
ax.grid(True, alpha=0.25, which="both")
_plot_tipper_part(
ax,
tx,
tipper[:, col],
err,
show_error_bars=show_tipper_error_bars,
line_style=tipper_line_style,
)
if ylim_tipper is not None:
ax.set_ylim(*ylim_tipper)
ax.axhline(0.0, color="0.75", lw=0.7, zorder=0)
tipper_x_label = t_label
if tipper_x_label is not None:
x_label = tipper_x_label
flat_tip_axes: list[plt.Axes] = []
for axes_obj in tip_axes.values():
if isinstance(axes_obj, list):
flat_tip_axes.extend(axes_obj)
else:
flat_tip_axes.append(axes_obj)
all_axes = ax_rho + ax_phase + flat_tip_axes
for ax in all_axes:
_apply_raw_tick_style(
ax,
show_x=False,
rotation=x_tick_rotation,
fontsize=tick_fontsize,
)
if label_component_x:
for ax in ax_phase:
_apply_raw_tick_style(
ax,
show_x=True,
rotation=x_tick_rotation,
fontsize=tick_fontsize,
)
ax.set_xlabel(x_label, labelpad=2)
bottom_axes_obj = (
list(tip_axes.values())[-1] if tip_axes else ax_phase
)
bottom_axes = (
bottom_axes_obj
if isinstance(bottom_axes_obj, list)
else [bottom_axes_obj]
)
for bottom_ax in bottom_axes:
_apply_raw_tick_style(
bottom_ax,
show_x=bool(label_tipper_x),
rotation=x_tick_rotation,
fontsize=tick_fontsize,
)
bottom_ax.set_xlabel(x_label if label_tipper_x else "")
if shared_group_labels:
row_axes = {
"rho": ax_rho[0],
"phase": ax_phase[0],
}
if "tx" in tip_axes:
tx_axes = tip_axes["tx"]
row_axes["tx"] = (
tx_axes[0] if isinstance(tx_axes, list) else tx_axes
)
if "ty" in tip_axes:
ty_axes = tip_axes["ty"]
row_axes["ty"] = (
ty_axes[0] if isinstance(ty_axes, list) else ty_axes
)
_add_response_tipper_group_labels(
fig,
all_axes,
row_axes,
station=station_label,
rho_label=group_rho_label,
phase_label=group_phase_label,
x_label=x_label,
show_x_label=(
is_bottom_group
and not (label_component_x or label_tipper_x)
),
x_label_pad=shared_x_label_pad,
)
if show_component_legend:
handles = []
labels = []
for comp in comps:
style = _component_style(comp, raw, force_style)
color = colors.get(comp) if colors else None
kwargs = (
style.plot_kwargs() if hasattr(style, "plot_kwargs") else {}
)
if color is not None:
kwargs["color"] = color
handles.append(plt.Line2D([], [], **kwargs))
labels.append(f"Z{comp.upper()}")
fig.legend(
handles,
labels,
loc="lower center",
ncol=max(1, len(labels)),
frameon=False,
bbox_to_anchor=(0.38, -0.015),
fontsize=8,
)
if show_tipper_legend:
real_style = PYCSAMT_STYLE.mt.xy
imag_style = PYCSAMT_STYLE.mt.yx.copy(
ls="--",
marker="x",
mfc=PYCSAMT_STYLE.mt.yx.color,
)
handles = [
plt.Line2D([], [], **real_style.plot_kwargs(label="real")),
plt.Line2D([], [], **imag_style.plot_kwargs(label="imag")),
]
fig.legend(
handles,
["tipper real", "tipper imag"],
loc="lower center",
ncol=2,
frameon=False,
bbox_to_anchor=(0.68, -0.015),
fontsize=8,
)
return fig
def _pair_by_station(
sites: Any, new_sites: Any | None
) -> list[tuple[str, Any, Any | None]]:
S0 = ensure_sites(sites, recursive=False, strict=False)
M0: dict[str, Any] = {}
for i, ed in enumerate(_iter_items(S0)):
M0[_name(ed, i)] = ed
if new_sites is None:
return [(k, v, None) for k, v in M0.items()]
S1 = ensure_sites(new_sites, recursive=False, strict=False)
M1: dict[str, Any] = {}
for i, ed in enumerate(_iter_items(S1)):
M1[_name(ed, i)] = ed
out: list[tuple[str, Any, Any | None]] = []
for st, ed in M0.items():
out.append((st, ed, M1.get(st, None)))
return out
[docs]
def plot_sites_compare(
sites: Any,
new_sites: Any | None = None,
*,
components: tuple[str, ...] = ("xy", "yx"),
quantity: str = "rhoa", # rhoa|impedance
x_axis: str = "period", # period|frequency
phase_range: tuple[float, float] | None = (-90.0, 90.0),
stations: list[str] | None = None,
ncols_groups: int = 3, # station groups per row
group_gap: float = 0.35, # space between station groups
pair_wspace: float = 0.06, # space between raw/after
hspace: float = 0.06,
height_ratio: tuple[int, int] = (2, 1),
axes=None,
figsize_scale: tuple[float, float] = (3.0, 3.0),
colors: dict[str, str] | None = None, # None → from PYCSAMT_STYLE.mt
marker=_UNSET, # default: PYCSAMT_STYLE.mt.xy.marker
ms=_UNSET, # default: PYCSAMT_STYLE.mt.xy.ms
lw=_UNSET, # default: PYCSAMT_STYLE.mt.xy.lw
ls=_UNSET, # default: PYCSAMT_STYLE.mt.xy.ls
show_error_bars: bool = True,
labels: tuple[str, str] = ("raw", "after"),
title_group_fmt: str = "{station}",
title_col_fmt: str = "{tag}",
show_legend: bool = False,
ylim_rhoa: tuple[float, float] | None = None,
ylim_phase: tuple[float, float] | None = None,
grid: bool = True,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
):
# ── resolve visual style from PYCSAMT_STYLE.mt ───────────────────────
_mt = PYCSAMT_STYLE.mt
if marker is _UNSET:
marker = _mt.xy.marker
if ms is _UNSET:
ms = _mt.xy.ms
if lw is _UNSET:
lw = _mt.xy.lw
if ls is _UNSET:
ls = _mt.xy.ls
comps = tuple(c.lower() for c in components)
cmap = {**_style_col(), **(colors or {})}
pairs = _pair_by_station(sites, new_sites)
if stations:
keep = set(stations)
pairs = [p for p in pairs if p[0] in keep]
if not pairs:
axes_given = _axes_list(axes, 1) if axes is not None else None
if axes_given is None:
fig = plt.figure(figsize=(6, 3))
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
return fig
dual = any(p[2] is not None for p in pairs)
cols_per_grp = 2 if dual else 1
n = len(pairs)
ncols_groups = max(1, int(ncols_groups))
nrows = (n + ncols_groups - 1) // ncols_groups
W, H = figsize_scale
# prebuild inner axes
AxR: list[list[plt.Axes]] = []
AxP: list[list[plt.Axes]] = []
axes_given = (
_axes_list(axes, n * cols_per_grp * 2) if axes is not None else None
)
if axes_given is None:
fig = plt.figure(
figsize=(W * ncols_groups * cols_per_grp, H * nrows),
constrained_layout=False,
)
outer = fig.add_gridspec(
nrows, ncols_groups, wspace=group_gap, hspace=hspace
)
for g in range(n):
r, c = divmod(g, ncols_groups)
gs = outer[r, c].subgridspec(
2,
cols_per_grp,
hspace=0.02,
wspace=pair_wspace,
height_ratios=height_ratio,
)
axR = [fig.add_subplot(gs[0, j]) for j in range(cols_per_grp)]
axP = [
fig.add_subplot(gs[1, j], sharex=axR[0])
for j in range(cols_per_grp)
]
AxR.append(axR)
AxP.append(axP)
else:
fig = axes_given[0].figure
for g in range(n):
base = g * cols_per_grp * 2
AxR.append(axes_given[base : base + cols_per_grp])
AxP.append(
axes_given[base + cols_per_grp : base + 2 * cols_per_grp]
)
# draw each group
for g, (st, ed0, ed1) in enumerate(pairs):
# group title
AxR[g][0].set_title(title_group_fmt.format(station=st), pad=4)
cols = [(labels[0], ed0)]
if dual:
cols.append((labels[1], ed1))
for j, (tag, ed) in enumerate(cols):
axR = AxR[g][j]
axP = AxP[g][j]
if ed is None:
axR.axis("off")
axP.axis("off")
continue
out = _zblk_flex(ed)
if len(out) == 4:
_, z, fr, ze = out
else:
_, z, fr = out[:3]
ze = None
if z is None or fr is None:
axR.axis("off")
axP.axis("off")
continue
x = (1.0 / fr) if x_axis == "period" else fr
axR.set_xscale("log")
axP.set_xscale("log")
if grid:
axR.grid(True, alpha=0.25, which="both")
axP.grid(True, alpha=0.25, which="both")
for comp in comps:
col = cmap.get(comp, "k")
zz = _comp_slice(z, comp)
ee = None
if isinstance(ze, np.ndarray) and ze.shape == z.shape:
ee = _comp_slice(ze, comp)
if quantity == "impedance":
y = np.log10(np.abs(zz))
yerr = _err_log10_mag(zz, ee)
ylab = r"$\log_{10}|Z|$"
else:
rho = _rhoa_from(zz, fr)
y = np.log10(rho)
yerr = _err_log10_rhoa(zz, ee)
ylab = r"$\log_{10}\rho_a$ ($\Omega\,\mathrm{m}$)"
plot_errorbar(
axR,
x,
y,
y_err=yerr,
color=col,
marker=marker,
ms=ms,
ls=ls,
lw=lw,
show_error_bars=show_error_bars,
)
ph = _phase_deg(zz)
if phase_range is not None:
ph = _wrap_phase(ph, phase_range)
plot_errorbar(
axP,
x,
ph,
y_err=_err_phase_deg(zz, ee),
color=col,
marker=marker,
ms=ms,
ls=ls,
lw=lw,
show_error_bars=False,
)
# per-column cosmetics
axR.text(
0.02,
0.96,
title_col_fmt.format(tag=tag),
ha="left",
va="top",
transform=axR.transAxes,
fontsize=9,
)
if ylim_rhoa:
axR.set_ylim(*ylim_rhoa)
if ylim_phase:
axP.set_ylim(*ylim_phase)
elif phase_range is not None:
axP.set_ylim(*phase_range)
if (g // ncols_groups) == (nrows - 1):
axP.set_xlabel(
"Period (s)" if x_axis == "period" else "Freq (Hz)"
)
else:
axP.set_xlabel("")
# Only the leftmost column of each raw/after pair gets a
# ylabel — every column used to get one, and with y-tick
# labels hidden on the inner column (below) the rotated
# text had nothing to visually separate it from the
# adjacent axis, so it rendered on top of the plotted data.
if j == 0:
axR.set_ylabel(ylab)
axP.set_ylabel("Phase (°)")
# shared ticks/legend
for g in range(n):
for j in range(cols_per_grp):
axR = AxR[g][j]
axP = AxP[g][j]
# hide y labels on inner columns except leftmost
if not (j == 0):
axR.set_yticklabels([])
axP.set_yticklabels([])
# hide x labels except bottom row (done above)
if (g // ncols_groups) != (nrows - 1):
axP.set_xticklabels([])
if show_legend:
labs = [c.upper() for c in comps]
hs = [
plt.Line2D(
[],
[],
color=cmap.get(c, "k"),
lw=lw,
ls=ls,
marker=marker,
ms=ms,
)
for c in comps
]
fig.legend(
hs,
labs,
loc="upper center",
ncol=len(labs),
frameon=False,
bbox_to_anchor=(0.5, 1.02),
fontsize=9,
)
return fig
# -------------------- measured vs predicted panels --------------------- #
def _pairs_meas_pred(
sites: Any, pred_sites: Any
) -> list[tuple[str, Any, Any]]:
S0 = ensure_sites(sites, recursive=False, strict=False)
S1 = ensure_sites(pred_sites, recursive=False, strict=False)
m0 = {_name(ed, i): ed for i, ed in enumerate(_iter_items(S0))}
m1 = {_name(ed, i): ed for i, ed in enumerate(_iter_items(S1))}
out = []
for st, ed in m0.items():
if st in m1:
out.append((st, ed, m1[st]))
return sorted(out, key=lambda x: x[0])
def _nearest_idx(x: np.ndarray, y: np.ndarray) -> np.ndarray:
ix = np.searchsorted(x, y)
ix = np.clip(ix, 1, x.size - 1)
left = np.abs(y - x[ix - 1])
right = np.abs(y - x[ix])
pick_left = left <= right
ix[pick_left] -= 1
return ix
def _align_pred(
fr_m: np.ndarray, fr_p: np.ndarray, z_p: np.ndarray
) -> np.ndarray:
# _nearest_idx requires its reference array ascending; EDI frequency
# arrays are typically stored descending (high frequency first), so
# sort fr_p (and z_p with it) before searching.
order = np.argsort(fr_p)
fr_sorted = fr_p[order]
z_sorted = z_p[order]
j = _nearest_idx(fr_sorted, fr_m)
return z_sorted[j]
def _rms_from(
zm: np.ndarray,
zp: np.ndarray,
ze: np.ndarray | None,
fr: np.ndarray,
*,
quantity: str,
) -> float:
if quantity == "impedance":
ym = np.log10(np.abs(zm))
yp = np.log10(np.abs(zp))
dy = ym - yp
if ze is not None:
ye = _err_log10_mag(zm, ze)
else:
ye = None
else:
rm = _rhoa_from(zm, fr)
rp = _rhoa_from(zp, fr)
dy = np.log10(rm) - np.log10(rp)
if ze is not None:
ye = _err_log10_rhoa(zm, ze)
else:
ye = None
if ye is None:
return float(np.sqrt(np.nanmean(dy * dy)))
w = 1.0 / (np.square(ye) + 1e-12)
return float(np.sqrt(np.nanmean(w * dy * dy)))
[docs]
def plot_sites_fit_grid(
sites: Any,
pred_sites: Any,
*,
components: tuple[str, ...] = ("xx", "xy", "yx", "yy"),
quantity: str = "rhoa", # rhoa|impedance
x_axis: str = "period", # period|frequency
phase_range: tuple[float, float] | None = (-180.0, 180.0),
stations: list[str] | None = None,
ncols_groups: int = 2, # station groups per row
comp_wspace: float = 0.10, # space between components
group_hspace: float = 0.18,
height_ratio: tuple[int, int] = (2, 1),
axes=None,
figsize_scale: tuple[float, float] = (4.0, 3.0),
colors_meas: dict[str, str] | None = None, # None → PYCSAMT_STYLE.mt
color_fit_te=_UNSET, # default: PYCSAMT_STYLE.mt.te.color
color_fit_tm=_UNSET, # default: PYCSAMT_STYLE.mt.tm.color
marker=_UNSET, # default: PYCSAMT_STYLE.mt.xy.marker
ms=_UNSET, # default: PYCSAMT_STYLE.mt.xy.ms
lw=_UNSET, # default: PYCSAMT_STYLE.mt.xy.lw
ls_meas=_UNSET, # default: PYCSAMT_STYLE.mt.xy.ls
lw_fit: float = 2.0,
ls_fit: str = "-",
show_error_bars: bool = True,
show_mode_legend: bool = True,
title_group_fmt: str = "{station}",
ylim_rhoa: tuple[float, float] | None = None,
ylim_phase: tuple[float, float] | None = None,
grid: bool = True,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
):
# ── resolve visual style from PYCSAMT_STYLE.mt ───────────────────────
_mt = PYCSAMT_STYLE.mt
if color_fit_te is _UNSET:
color_fit_te = _mt.te.color
if color_fit_tm is _UNSET:
color_fit_tm = _mt.tm.color
if marker is _UNSET:
marker = _mt.xy.marker
if ms is _UNSET:
ms = _mt.xy.ms
if lw is _UNSET:
lw = _mt.xy.lw
if ls_meas is _UNSET:
ls_meas = _mt.xy.ls
comps = tuple(c.lower() for c in components)
cmap = {**_style_col(), **(colors_meas or {})}
pairs = _pairs_meas_pred(sites, pred_sites)
if stations:
keep = set(stations)
pairs = [p for p in pairs if p[0] in keep]
if not pairs:
axes_given = _axes_list(axes, 1) if axes is not None else None
if axes_given is None:
fig = plt.figure(figsize=(6, 3))
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no matching stations", ha="center", va="center")
return fig
n = len(pairs)
ncols_groups = max(1, int(ncols_groups))
nrows = (n + ncols_groups - 1) // ncols_groups
W, H = figsize_scale
# build axes per group: 2 x ncomp
AX = []
axes_given = (
_axes_list(axes, n * len(comps) * 2) if axes is not None else None
)
if axes_given is None:
fig = plt.figure(
figsize=(W * ncols_groups, H * nrows),
constrained_layout=False,
)
outer = fig.add_gridspec(
nrows, ncols_groups, wspace=0.35, hspace=group_hspace
)
for g in range(n):
r, c = divmod(g, ncols_groups)
gs = outer[r, c].subgridspec(
2,
len(comps),
hspace=0.02,
wspace=comp_wspace,
height_ratios=height_ratio,
)
axR = [fig.add_subplot(gs[0, j]) for j in range(len(comps))]
axP = [
fig.add_subplot(gs[1, j], sharex=axR[0])
for j in range(len(comps))
]
AX.append((axR, axP))
else:
fig = axes_given[0].figure
width = len(comps)
for g in range(n):
base = g * width * 2
AX.append(
(
axes_given[base : base + width],
axes_given[base + width : base + 2 * width],
)
)
# draw each station group
for g, (st, edm, edp) in enumerate(pairs):
axR, axP = AX[g]
# station title centered over the row of components
axR[0].set_title(title_group_fmt.format(station=st), pad=18)
Zm = _zblk_flex(edm)
if len(Zm) == 4:
_, zm, frm, zem = Zm
else:
_, zm, frm = Zm[:3]
zem = None
Zp = _zblk_flex(edp)
if len(Zp) == 4:
_, zp, frp, _ = Zp
else:
_, zp, frp = Zp[:3]
_ = None
if zm is None or zp is None:
for j in range(len(comps)):
axR[j].axis("off")
axP[j].axis("off")
continue
x = (1.0 / frm) if x_axis == "period" else frm
for j, comp in enumerate(comps):
aR = axR[j]
aP = axP[j]
aR.set_xscale("log")
aP.set_xscale("log")
if grid:
aR.grid(True, alpha=0.25, which="both")
aP.grid(True, alpha=0.25, which="both")
# measured
zm_c = _comp_slice(zm, comp)
ze_c = None
if isinstance(zem, np.ndarray) and zem.shape == zm.shape:
ze_c = _comp_slice(zem, comp)
colm = cmap.get(comp, "k")
if quantity == "impedance":
ym = np.log10(np.abs(zm_c))
yerr = _err_log10_mag(zm_c, ze_c)
ylab = r"$\log_{10}|Z|$"
else:
rm = _rhoa_from(zm_c, frm)
ym = np.log10(rm)
yerr = _err_log10_rhoa(zm_c, ze_c)
ylab = r"$\log_{10}\rho_a$ ($\Omega\,\mathrm{m}$)"
plot_errorbar(
aR,
x,
ym,
y_err=yerr,
color=colm,
marker=marker,
ms=ms,
ls=ls_meas,
lw=lw,
show_error_bars=show_error_bars,
)
phm = _phase_deg(zm_c)
if phase_range is not None:
phm = _wrap_phase(phm, phase_range)
plot_errorbar(
aP,
x,
phm,
y_err=_err_phase_deg(zm_c, ze_c),
color=colm,
marker=marker,
ms=ms,
ls=ls_meas,
lw=lw,
show_error_bars=False,
)
# predicted (aligned to measured freq)
zp_c = _comp_slice(zp, comp)
zp_c = _align_pred(frm, frp, zp_c)
colf = color_fit_te if comp in ("xx", "xy") else color_fit_tm
if quantity == "impedance":
yp = np.log10(np.abs(zp_c))
else:
rp = _rhoa_from(zp_c, frm)
yp = np.log10(rp)
aR.plot(x, yp, ls_fit, color=colf, lw=lw_fit)
php = _phase_deg(zp_c)
if phase_range is not None:
php = _wrap_phase(php, phase_range)
aP.plot(x, php, ls_fit, color=colf, lw=lw_fit)
# per-component header + RMS (one combined label; two
# separate texts at the same height used to overlap on
# narrow panels)
rmsc = _rms_from(zm_c, zp_c, ze_c, frm, quantity=quantity)
aR.text(
0.50,
1.02,
f"Z{comp.upper()} rms={rmsc:.2f}",
ha="center",
va="bottom",
transform=aR.transAxes,
fontsize=8,
)
# axes cosmetics
if ylim_rhoa:
aR.set_ylim(*ylim_rhoa)
if ylim_phase:
aP.set_ylim(*ylim_phase)
elif phase_range is not None:
aP.set_ylim(*phase_range)
if j == 0:
aR.set_ylabel(ylab)
aP.set_ylabel("Phase (°)")
else:
aR.set_yticklabels([])
aP.set_yticklabels([])
if (g // ncols_groups) == (nrows - 1):
aP.set_xlabel(
"Period (s)" if x_axis == "period" else "Freq (Hz)"
)
else:
aP.set_xlabel("")
# global legend (TE/TM fits)
if show_mode_legend:
h_te = plt.Line2D([], [], color=color_fit_te, lw=lw_fit)
h_tm = plt.Line2D([], [], color=color_fit_tm, lw=lw_fit)
fig.legend(
[h_te, h_tm],
["TE fit: xx/xy", "TM fit: yx/yy"],
loc="upper center",
ncol=2,
frameon=False,
bbox_to_anchor=(0.5, 1.02),
fontsize=9,
)
return fig