# pycsamt/emtools/inspect.py
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
import matplotlib.gridspec as gridspec
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from ..api.style import PYCSAMT_STYLE
from ..api.view import maybe_wrap_frame
from ._core import (
_axes_list,
_get_t_block,
_get_z_block,
ensure_sites,
)
# ------------------------- small helpers --------------------------------- #
def _is_df(x: Any) -> bool:
return isinstance(x, pd.DataFrame)
def _name(ed: Any, idx: int) -> str:
for k in ("station", "name", "site", "id"):
v = getattr(ed, k, None)
if isinstance(v, str) and v:
return v
return f"site_{idx}"
def _coords(ed: Any) -> tuple[float | None, float | None]:
coords = getattr(ed, "coords", None)
if coords is not None:
try:
lat, lon = float(coords[0]), float(coords[1])
if np.isfinite(lat) and np.isfinite(lon):
return lat, lon
except Exception:
pass
lat = getattr(ed, "lat", None)
lon = getattr(ed, "lon", None)
if lat is None:
lat = getattr(ed, "latitude", None)
if lon is None:
lon = getattr(ed, "longitude", None)
try:
lat = float(lat) if lat is not None else None
lon = float(lon) if lon is not None else None
except Exception:
lat, lon = None, None
return lat, lon
def _has(ed: Any, sect: str) -> bool:
sect_l = str(sect).lower()
# Real Site/EDIFile objects always expose a .tipper/.Tipper (and
# .Z) attribute even when no such data was ever parsed, so plain
# hasattr() below is not a reliable presence check for these -- use
# the same block extraction the rest of emtools relies on instead.
if sect_l in ("tipper", "t"):
T, t, _ = _get_t_block(ed)
return T is not None and t is not None
if sect_l in ("mt", "z", "impedance"):
Z, z, _ = _get_z_block(ed)
return Z is not None and z is not None
f = getattr(ed, "has_section", None)
if callable(f):
try:
return bool(f(sect))
except Exception:
pass
# duck-typing fallback
return hasattr(ed, sect) or hasattr(ed, sect.capitalize())
def _get_freq(ed: Any) -> np.ndarray | None:
# try Z first
for a in ("Z", "z", "ResPhase", "resphase", "Tipper", "tipper"):
obj = getattr(ed, a, None)
if obj is None:
continue
fr = getattr(obj, "freq", None)
if fr is not None:
try:
ar = np.asarray(fr, dtype=float)
if ar.ndim == 1 and ar.size > 0:
return ar
except Exception:
pass
# last resort
fr = getattr(ed, "freq", None)
if fr is not None:
try:
ar = np.asarray(fr, dtype=float)
if ar.ndim == 1 and ar.size > 0:
return ar
except Exception:
pass
return None
def _iter_items(sites: Any) -> Iterable[Any]:
# Sites is iterable in most builds
try:
for it in sites:
yield it
return
except Exception:
pass
# fallback: dict-like
items = getattr(sites, "items", None)
if isinstance(items, dict):
for _, it in items.items():
yield it
def _period(fr: np.ndarray) -> np.ndarray:
with np.errstate(divide="ignore", invalid="ignore"):
p = 1.0 / fr
return p
def _union_freq(freqs: list[np.ndarray]) -> np.ndarray:
if not freqs:
return np.array([], dtype=float)
allf = np.unique(np.concatenate(freqs))
allf = allf[np.isfinite(allf) & (allf > 0.0)]
return allf
def _presence_vec(fr: np.ndarray, grid: np.ndarray) -> np.ndarray:
if fr.size == 0 or grid.size == 0:
return np.zeros(grid.size, dtype=bool)
idx = np.searchsorted(grid, fr)
idx = np.clip(idx, 0, grid.size - 1)
# mark nearest only when close
tol = 1e-10
near = np.abs(grid[idx] - fr) <= tol
m = np.zeros(grid.size, dtype=bool)
m[idx[near]] = True
return m
def _df_from_kv(
rows: list[dict[str, Any]],
cols: list[str],
) -> pd.DataFrame:
if not rows:
return pd.DataFrame(columns=cols)
return pd.DataFrame.from_records(rows, columns=cols)
# --------------------------- public API ---------------------------------- #
[docs]
def sites_summary(
sites: Any,
*,
fields: tuple[str, ...] = (
"station",
"n_freq",
"has_tipper",
"period_min",
"period_max",
"lat",
"lon",
),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
api: bool | None = None,
) -> Any:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
rows: list[dict[str, Any]] = []
for i, ed in enumerate(_iter_items(S)):
fr = _get_freq(ed)
nm = _name(ed, i)
lat, lon = _coords(ed)
has_tip = _has(ed, "tipper")
nf = int(fr.size) if isinstance(fr, np.ndarray) else 0
pmin = None
pmax = None
if isinstance(fr, np.ndarray) and fr.size > 0:
pe = _period(fr)
pmin = float(np.nanmin(pe))
pmax = float(np.nanmax(pe))
rec = dict(
station=nm,
n_freq=nf,
has_tipper=bool(has_tip),
period_min=pmin,
period_max=pmax,
lat=lat,
lon=lon,
)
rows.append(rec)
df = _df_from_kv(rows, list(fields))
df = df[list(fields)]
return maybe_wrap_frame(
df,
api=api,
name="sites_summary",
kind="edi.summary",
source=sites,
description=(
"Per-site EDI frequency, tipper, and coordinate summary."
),
)
[docs]
def list_missing_sections(
sites: Any,
*,
require: tuple[str, ...] = ("mt", "tipper"),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> dict[str, list[str]]:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
out: dict[str, list[str]] = {}
for i, ed in enumerate(_iter_items(S)):
nm = _name(ed, i)
miss: list[str] = []
for sec in require:
if not _has(ed, sec):
miss.append(sec)
if miss:
out[nm] = miss
return out
[docs]
def frequency_coverage(
sites: Any,
*,
mode: str = "per-site",
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Any:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
freqs: dict[str, np.ndarray] = {}
for i, ed in enumerate(_iter_items(S)):
nm = _name(ed, i)
fr = _get_freq(ed)
if isinstance(fr, np.ndarray):
freqs[nm] = fr
if mode == "per-site":
return freqs
grid = _union_freq(list(freqs.values()))
if mode == "union":
return grid
if mode == "intersection":
if not freqs:
return np.array([], dtype=float)
# numeric set-intersection with tol
g = None
for fr in freqs.values():
if g is None:
g = fr
else:
a = np.intersect1d(g, fr)
g = a
return g if g is not None else np.array([], dtype=float)
raise ValueError("mode must be per-site|union|intersection")
[docs]
def plot_coverage(
sites: Any,
*,
axis: str = "period",
show_mask: bool = True,
figsize: tuple[float, float] = (7.0, 4.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
data = frequency_coverage(
S,
mode="per-site",
recursive=False,
on_dup=on_dup,
strict=False,
verbose=verbose,
)
labs = list(data.keys())
arr = list(data.values())
grid = _union_freq(arr)
if grid.size == 0:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no data", ha="center", va="center")
return ax
M = np.vstack([_presence_vec(fr, grid) for fr in arr])
_period(grid) if axis == "period" else grid
if ax is None:
_, ax = plt.subplots(figsize=figsize)
im = ax.imshow(
M.T,
aspect="auto",
origin="lower",
interpolation="nearest",
)
ax.set_xlabel("site")
ax.set_ylabel(axis)
ax.set_xticks(np.arange(len(labs)))
ax.set_xticklabels(labs, rotation=90)
cb = ax.get_figure().colorbar(im, ax=ax)
cb.set_label("presence")
return ax
# ------------------------ impedance curves -------------------------------- #
_RESPHASE_RENAME = {
# Site.to_dataframe() uses z-prefix (rho_zxy); plot functions expect rho_xy
"rho_zxx": "rho_xx",
"phi_zxx": "phi_xx",
"rho_zxy": "rho_xy",
"phi_zxy": "phi_xy",
"rho_zyx": "rho_yx",
"phi_zyx": "phi_yx",
"rho_zyy": "rho_yy",
"phi_zyy": "phi_yy",
}
def _df_resphase(
sites: Any,
*,
kind: str = "resphase",
recursive: bool = False,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame | None:
# Try the object's own to_dataframe first (works for a single Site)
get_df = getattr(sites, "to_dataframe", None)
if callable(get_df):
try:
tdf = get_df(kind=kind)
except TypeError:
try:
tdf = get_df(kind)
except Exception:
tdf = None
else:
pass
normed = _normalise_resphase_df(tdf, station=_name(sites, 0))
if normed is not None:
return normed
# Fallback: sites is a collection (Sites is iterable, yields Site objects)
dfs: list[pd.DataFrame] = []
try:
for i, item in enumerate(_iter_items(sites)):
item_get = getattr(item, "to_dataframe", None)
if not callable(item_get):
continue
try:
idf = item_get(kind=kind)
except Exception:
continue
normed = _normalise_resphase_df(idf, station=_name(item, i))
if normed is not None:
dfs.append(normed)
except Exception:
pass
if dfs:
return pd.concat(dfs, ignore_index=True)
return None
def _unwrap_frame(obj: Any) -> pd.DataFrame | None:
"""Return a plain pd.DataFrame from obj, unwrapping APIFrame if needed."""
if isinstance(obj, pd.DataFrame):
return obj
# APIFrame stores the real DataFrame in .df
inner = getattr(obj, "df", None)
if isinstance(inner, pd.DataFrame):
return inner
return None
def _normalise_resphase_df(raw: Any, station: str) -> pd.DataFrame | None:
"""Add station/freq columns and normalise column names from to_dataframe output."""
df = _unwrap_frame(raw)
if df is None:
return None
df = df.copy()
# Flatten freq index produced by Site.to_dataframe (index.name == 'f')
if df.index.name in ("f", "freq") and "freq" not in df.columns:
df = df.reset_index()
df = df.rename(columns={"f": "freq"})
if "station" not in df.columns:
df["station"] = station
# Rename rho_zxy → rho_xy etc.
df = df.rename(columns=_RESPHASE_RENAME)
return df
[docs]
def plot_rhoa_phi(
sites: Any,
*,
components: tuple[str, ...] = ("xy", "yx"),
axis: str = "period",
errorbar: bool = True,
figsize: tuple[float, float] = (7.5, 6.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax_r: plt.Axes | None = None,
ax_p: plt.Axes | None = None,
) -> tuple[plt.Axes, plt.Axes]:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
df = _df_resphase(S, kind="resphase")
if df is None or df.empty:
# Fallback: try z dataframe and convert later if needed
df = _df_resphase(S, kind="z")
if df is None or df.empty:
if ax_r is None or ax_p is None:
fig, (ax_r, ax_p) = plt.subplots(
2, 1, figsize=figsize, sharex=True
)
ax_r.text(0.5, 0.5, "no data", ha="center", va="center")
ax_p.text(0.5, 0.5, "no data", ha="center", va="center")
return ax_r, ax_p
if ax_r is None or ax_p is None:
fig, (ax_r, ax_p) = plt.subplots(2, 1, figsize=figsize, sharex=True)
# Expected columns are package-defined; be defensive
# We accept generic patterns: station, freq, period,
# rho_xy, rho_yx, phi_xy, phi_yx, and maybe errors.
xkey = "period" if axis == "period" else "freq"
if xkey not in df.columns and "freq" in df.columns:
df["period"] = 1.0 / df["freq"].replace(0, np.nan)
xkey = "period" if axis == "period" else "freq"
stations = sorted(df.get("station", pd.Series()).unique())
if not stations:
# try without station split
stations = ["__all__"]
df = df.copy()
df["station"] = stations[0]
for st in stations:
sdf = df[df["station"] == st]
for comp in components:
rk = f"rho_{comp}"
pk = f"phi_{comp}"
rke = f"{rk}_err"
pke = f"{pk}_err"
if rk in sdf.columns and pk in sdf.columns:
x = sdf[xkey].to_numpy()
r = sdf[rk].to_numpy()
p = sdf[pk].to_numpy()
if errorbar and rke in sdf.columns:
re = sdf[rke].to_numpy()
ax_r.errorbar(x, r, yerr=re, label=f"{st}:{comp}")
else:
ax_r.plot(x, r, ".", label=f"{st}:{comp}")
if errorbar and pke in sdf.columns:
pe = sdf[pke].to_numpy()
ax_p.errorbar(x, p, yerr=pe, label=f"{st}:{comp}")
else:
ax_p.plot(x, p, ".", label=f"{st}:{comp}")
ax_r.set_xscale("log")
ax_r.set_yscale("log")
ax_r.set_ylabel("rho_a")
ax_p.set_xscale("log")
ax_p.set_ylabel("phi (deg)")
ax_p.set_xlabel(axis)
ax_r.legend(ncol=2, fontsize=8)
ax_p.legend(ncol=2, fontsize=8)
return ax_r, ax_p
# ----------------------------- tipper ------------------------------------- #
[docs]
def plot_tipper_components(
sites: Any,
*,
kind: tuple[str, ...] = ("real", "imag"),
axis: str = "period",
figsize: tuple[float, float] = (7.5, 4.5),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
from ._core import _get_t_block, _iter_items, _name
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
_STYLES = {
("tx", "real"): dict(
color="#1f77b4", ls="-", marker="o", ms=3, lw=0.9
),
("tx", "imag"): dict(
color="#1f77b4", ls="--", marker="o", ms=3, lw=0.9
),
("ty", "real"): dict(
color="#d62728", ls="-", marker="s", ms=3, lw=0.9
),
("ty", "imag"): dict(
color="#d62728", ls="--", marker="s", ms=3, lw=0.9
),
}
n_plotted = 0
for i, ed in enumerate(_iter_items(S)):
nm = _name(ed, i)
out = _get_t_block(ed)
_, t, fr = out[0], out[1], out[2]
if t is None or fr is None:
continue
t = np.asarray(t, complex) # (n_freq, 2) — Tx, Ty
fr = np.asarray(fr, float)
if t.ndim == 3 and t.shape[1] == 1 and t.shape[2] == 2:
t = t[:, 0, :]
if t.ndim != 2 or t.shape[1] < 2:
continue
x = 1.0 / np.where(fr == 0, np.nan, fr) if axis == "period" else fr
for ci, comp in enumerate(("tx", "ty")):
col_t = t[:, ci]
for k in kind:
vals = col_t.real if k == "real" else col_t.imag
sty = _STYLES.get((comp, k), {})
ax.plot(x, vals, label=f"{nm}:{comp}_{k}", **sty)
n_plotted += 1
ax.set_xscale("log")
ax.set_xlabel(
"Period (s)" if axis == "period" else "Frequency (Hz)", fontsize=9
)
ax.set_ylabel("Tipper", fontsize=9)
ax.axhline(0, color="k", lw=0.5, ls=":")
ax.tick_params(labelsize=8)
if n_plotted:
ax.legend(ncol=2, fontsize=7)
else:
ax.text(
0.5,
0.5,
"no tipper data",
ha="center",
va="center",
transform=ax.transAxes,
color="0.5",
)
return ax
# --------------------------- pseudosection -------------------------------- #
[docs]
def pseudosection(
sites: Any,
*,
quantity: str = "rho_xy",
axis_x: str = "station",
axis_y: str = "period",
period_range: tuple[float, float] | None = None,
vmin: float | None = None,
vmax: float | None = None,
figsize: tuple[float, float] = (7.5, 4.5),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
topo: bool | None = None,
dark: bool = True,
) -> plt.Axes:
"""Draw a period-vs-station pseudosection.
Parameters
----------
sites : Sites or compatible
Station data.
quantity : str
Column name to plot (e.g. ``"rho_xy"``, ``"phi_xy"``).
topo : bool or None
Override the global :data:`~pycsamt.topo.PYCSAMT_TOPO` setting.
``None`` (default) reads the global singleton.
dark : bool
Use dark-palette styling for the topo strip.
"""
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
df = _df_resphase(S, kind="resphase")
if df is None or df.empty:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no resphase", ha="center", va="center")
return ax
# ensure axes keys
if axis_y not in df.columns:
if "freq" in df.columns and axis_y == "period":
df = df.copy()
df["period"] = 1.0 / df["freq"].replace(0, np.nan)
else:
raise ValueError("axis_y not available")
if axis_x not in df.columns:
raise ValueError("axis_x not available")
# optional period-range filter (T in seconds; applied before pivot)
if period_range is not None and axis_y == "period":
T_min, T_max = float(period_range[0]), float(period_range[1])
df = df[(df["period"] >= T_min) & (df["period"] <= T_max)]
if df.empty:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(
0.5,
0.5,
"No data in selected period range",
ha="center",
va="center",
fontsize=9,
)
return ax
# pivot
piv = df.pivot_table(
index=axis_y,
columns=axis_x,
values=quantity,
aggfunc="median",
)
# Sort periods ascending; shallow (short period) will go to the top.
piv = piv.sort_index()
Y = piv.index.to_numpy() # period values, ascending
Z = piv.to_numpy(dtype=float) # shape (n_period, n_station)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
# Use Z directly (NOT Z.T) so that:
# x-axis = station index (cols 0..n_station-1) ← matches set_xticks
# y-axis = period index (rows 0..n_period-1)
# origin="upper" → row 0 (smallest period, shallowest) at the TOP,
# which is the standard MT pseudosection convention.
im = ax.imshow(
Z,
aspect="auto",
origin="upper",
vmin=vmin,
vmax=vmax,
interpolation="nearest",
)
# ── X-axis: station labels at the TOP (MT convention) ────────────
station_labels = list(piv.columns)
ax.set_xticks(np.arange(len(station_labels)))
ax.set_xticklabels(station_labels, rotation=90, fontsize=7, ha="center")
ax.xaxis.tick_top()
ax.xaxis.set_label_position("top")
ax.set_xlabel(axis_x, fontsize=9, labelpad=4)
# ── Y-axis: period values ─────────────────────────────────────────
n_yticks = min(8, len(Y))
yt = np.linspace(0, len(Y) - 1, num=n_yticks)
ax.set_yticks(yt)
ax.set_yticklabels([f"{Y[int(i)]:.3g}" for i in yt], fontsize=8)
ax.set_ylabel(axis_y, fontsize=9)
# ── Colorbar ──────────────────────────────────────────────────────
cb = ax.get_figure().colorbar(im, ax=ax)
cb.set_label(quantity, fontsize=8)
# ── Topography strip (optional) ───────────────────────────────────
_topo_enabled = topo if topo is not None else _topo_cfg().enabled
if _topo_enabled:
try:
from pycsamt.topo.extract import (
extract_chainage,
extract_elevation,
extract_station_names,
)
from pycsamt.topo.overlay import draw_topo_strip
chain_km = extract_chainage(S)
elev_m = extract_elevation(S)
names = extract_station_names(S)
# Reorder to match pivot column order (station labels)
name_to_idx = {n: i for i, n in enumerate(names)}
ordered_idx = [
name_to_idx.get(str(s), i)
for i, s in enumerate(station_labels)
]
chain_ord = chain_km[ordered_idx] if len(chain_km) else chain_km
elev_ord = elev_m[ordered_idx] if len(elev_m) else elev_m
strip_names = [str(s) for s in station_labels]
draw_topo_strip(
ax.get_figure(),
ax,
chain_ord,
elev_ord,
strip_names,
dark=dark,
)
except Exception:
pass # topo strip is optional; never break the main plot
return ax
def _topo_cfg():
"""Return global TopoConfig singleton (lazy import to avoid circular deps)."""
from pycsamt.topo.config import PYCSAMT_TOPO
return PYCSAMT_TOPO
# ──────────────────────────────────────────────────────────────────────────────
# plot_station_response — full 4-component impedance tensor + tipper view
# ──────────────────────────────────────────────────────────────────────────────
_COMP_IDX: dict = {"xx": (0, 0), "xy": (0, 1), "yx": (1, 0), "yy": (1, 1)}
_TIP_PANELS: list = [
("Re($T_x$)", "tx", "real"),
("Im($T_x$)", "tx", "imag"),
("Re($T_y$)", "ty", "real"),
("Im($T_y$)", "ty", "imag"),
]
def _rho_phase_err(rho, z, z_err):
"""Return (rho_err, phase_err_deg) from impedance absolute errors."""
z_abs = np.abs(z)
with np.errstate(divide="ignore", invalid="ignore"):
rel = np.where(z_abs > 0, np.abs(z_err) / z_abs, 0.0)
rho_err = 2.0 * rho * rel
phase_err = np.degrees(rel)
return rho_err, phase_err
def _rms_log(obs, mod):
"""Root-mean-square misfit in log10(rho) space."""
with np.errstate(divide="ignore", invalid="ignore"):
r = np.log10(obs / np.where(mod > 0, mod, np.nan))
r = r[np.isfinite(r)]
return float(np.sqrt(np.mean(r**2))) if r.size else np.nan
def _pick_station(S, station):
"""Return the first (or named) Site object from S."""
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
if station is None or st == station:
return st, ed
raise RuntimeError(
f"Station {station!r} not found."
if station
else "No sites in collection."
)
[docs]
def plot_station_response(
sites: Any,
*,
station: str | None = None,
sites_model: Any | None = None,
components: tuple[str, ...] = ("xx", "xy", "yx", "yy"),
period_range: tuple[float, float] | None = None,
rho_lim: tuple[float, float] | None = None,
phase_lim: tuple[float, float] | None = None,
tipper_lim: tuple[float, float] = (-0.5, 0.5),
show_tipper: bool = True,
show_error_bars: bool = True,
show_rms: bool = True,
title: str = "",
axes=None,
figsize: tuple[float, float] | None = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> plt.Figure:
"""Full four-component impedance tensor + tipper response for one station.
Renders a 3-row × N-column figure (N = len(*components*)) following the
:class:`~pycsamt.api.style.MTComponentStyle` colour scheme:
* **Row 0** — apparent resistivity ρa (Ω·m) vs period, log–log.
* **Row 1** — phase φ (°) vs period, semilog-x.
* **Row 2** — tipper magnitude: Re(Tx), Im(Tx), Re(Ty), Im(Ty)
vs period, semilog-x. Hidden when *show_tipper* is ``False`` or when
no tipper data are found.
Each column corresponds to one impedance tensor component (Zxx, Zxy,
Zyx, Zyy). The tipper row always uses four fixed sub-panels regardless
of which Z components are selected.
An optional *sites_model* argument overlays a second (model/forward)
dataset on the same axes as dotted lines. When both observed and model
data are present, a per-component RMS is computed in log10(ρa) space and
appended to each column header.
Parameters
----------
sites : any
Observed EDI data — path, :class:`~pycsamt.core.base.SitesCollection`,
or anything accepted by :func:`~pycsamt.emtools._core.ensure_sites`.
station : str or None
Name of the station to plot. ``None`` picks the first available.
sites_model : any or None
Optional forward-model or inversion-response EDI data (same API as
*sites*). When provided, overlay dashed lines and show RMS.
components : tuple of {"xx","xy","yx","yy"}, default all four
Z-tensor components to display. Order determines column order.
period_range : (T_min, T_max) or None
Clip period axis to this window (seconds).
rho_lim : (vmin, vmax) or None
ρa y-axis limits. ``None`` → matplotlib auto.
phase_lim : (lo, hi) or None
Phase y-axis limits in degrees. ``None`` → auto.
tipper_lim : (lo, hi), default (-0.5, 0.5)
Tipper y-axis limits.
show_tipper : bool, default True
Whether to add the tipper row.
show_error_bars : bool, default True
Draw error bars on observed data.
show_rms : bool, default True
Append per-component RMS to column titles when *sites_model* is set.
title : str
Override the auto-derived figure title.
figsize : (float, float) or None
Figure size. Auto-computed when ``None``.
recursive, on_dup, strict, verbose
Passed to :func:`~pycsamt.emtools._core.ensure_sites`.
Returns
-------
matplotlib.figure.Figure
Examples
--------
Observed data only:
>>> from pycsamt.emtools import plot_station_response
>>> fig = plot_station_response("path/to/edis/", station="S07")
With model overlay:
>>> fig = plot_station_response(
... obs_edis, station="HBH03_IMP",
... sites_model=model_edis,
... period_range=(1e-4, 1.0),
... )
"""
mt = PYCSAMT_STYLE.mt
# ── 1. load observed site ─────────────────────────────────────────────
S_obs = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
st_name, ed_obs = _pick_station(S_obs, station)
Z_obs, z_obs, fr_obs = _get_z_block(ed_obs)
rho_obs = getattr(ed_obs, "rho", None)
phase_obs = getattr(ed_obs, "phase", None)
z_err_obs = getattr(Z_obs, "z_err", None) if Z_obs else None
T_obs, t_obs, _ = _get_t_block(ed_obs)
tip_err_obs = getattr(T_obs, "tipper_err", None) if T_obs else None
if (
isinstance(tip_err_obs, np.ndarray)
and tip_err_obs.ndim == 3
and tip_err_obs.shape[1] == 1
):
# .tipper is squeezed to (n_freq, 2) by _get_t_block, but the
# raw .tipper_err attribute keeps its native (n_freq, 1, 2)
# shape -- match it so downstream indexing stays aligned.
tip_err_obs = tip_err_obs[:, 0, :]
axes_given = _axes_list(axes, 1) if axes is not None else None
if rho_obs is None or phase_obs is None or z_obs is None:
if axes_given is None:
fig, ax = plt.subplots(figsize=figsize or (10, 6))
else:
ax = axes_given[0]
fig = ax.figure
ax.text(
0.5,
0.5,
"no impedance data",
ha="center",
va="center",
transform=ax.transAxes,
)
return fig
per_obs = 1.0 / np.where(fr_obs == 0, np.nan, fr_obs)
# ── 2. period mask ────────────────────────────────────────────────────
if period_range is not None:
lo, hi = float(period_range[0]), float(period_range[1])
mask = np.isfinite(per_obs) & (per_obs >= lo) & (per_obs <= hi)
else:
mask = np.isfinite(per_obs)
per = per_obs[mask]
rho = rho_obs[mask]
phase = phase_obs[mask]
z_m = z_obs[mask]
z_e = z_err_obs[mask] if z_err_obs is not None else None
tip = t_obs[mask] if t_obs is not None else None
tip_e = tip_err_obs[mask] if tip_err_obs is not None else None
rho_err, phase_err = (
_rho_phase_err(rho, z_m, z_e)
if z_e is not None
else (np.zeros_like(rho), np.zeros_like(phase))
)
# ── 3. optional model site ────────────────────────────────────────────
rho_mod = phase_mod = per_mod = tip_mod = None
if sites_model is not None:
S_mod = ensure_sites(
sites_model,
recursive=recursive,
on_dup=on_dup,
strict=False,
verbose=0,
)
try:
_, ed_mod = _pick_station(S_mod, st_name)
Z_mod, z_mod_arr, fr_mod = _get_z_block(ed_mod)
rho_mod_raw = getattr(ed_mod, "rho", None)
phase_mod_raw = getattr(ed_mod, "phase", None)
if rho_mod_raw is not None and fr_mod is not None:
per_m = 1.0 / np.where(fr_mod == 0, np.nan, fr_mod)
msk_m = np.isfinite(per_m)
if period_range is not None:
msk_m &= (per_m >= lo) & (per_m <= hi)
per_mod = per_m[msk_m]
rho_mod = rho_mod_raw[msk_m]
phase_mod = phase_mod_raw[msk_m]
T_mod, t_mod_arr, _ = _get_t_block(ed_mod)
if t_mod_arr is not None:
tip_mod = t_mod_arr[msk_m] if period_range else t_mod_arr
except Exception:
pass
# ── 4. figure layout ──────────────────────────────────────────────────
n_comp = len(components)
has_tipper = show_tipper and tip is not None
n_rows = 3 if has_tipper else 2
h_ratios = [2.2, 1.6, 1.0] if has_tipper else [2.2, 1.6]
if figsize is None:
figsize = (
3.4 * max(n_comp, 4) + 0.6,
4.5 + (1.8 if has_tipper else 0),
)
n_required_axes = (2 * n_comp) + (4 if has_tipper else 0)
axes_given = (
_axes_list(axes, n_required_axes) if axes is not None else None
)
if axes_given is None:
fig = plt.figure(figsize=figsize, constrained_layout=True)
gs = gridspec.GridSpec(
n_rows,
max(n_comp, 4) if has_tipper else n_comp,
figure=fig,
height_ratios=h_ratios,
hspace=0.08,
wspace=0.28,
)
# Build axes: [rho row, phase row] share x across columns
ax_rho = [fig.add_subplot(gs[0, c]) for c in range(n_comp)]
ax_phase = [
fig.add_subplot(gs[1, c], sharex=ax_rho[c]) for c in range(n_comp)
]
if has_tipper:
ax_tip = [fig.add_subplot(gs[2, c]) for c in range(4)]
else:
ax_tip = []
else:
fig = axes_given[0].figure
ax_rho = axes_given[:n_comp]
ax_phase = axes_given[n_comp : 2 * n_comp]
ax_tip = axes_given[2 * n_comp : 2 * n_comp + 4] if has_tipper else []
# ── 5. per-component RMS store ────────────────────────────────────────
rms_dict: dict = {}
# ── 6. draw ρa and φ panels ───────────────────────────────────────────
for ci, comp in enumerate(components):
if comp.lower() not in _COMP_IDX:
continue
ri, ci_mat = _COMP_IDX[comp.lower()]
cs = mt.component(comp.lower())
ekw = cs.errorbar_kwargs()
ax_r = ax_rho[ci]
ax_p = ax_phase[ci]
# ── observed ────────────────────────────────────────────────
rho_c = rho[:, ri, ci_mat]
phi_c = phase[:, ri, ci_mat]
rerr_c = rho_err[:, ri, ci_mat] if show_error_bars else None
perr_c = phase_err[:, ri, ci_mat] if show_error_bars else None
# mask non-positive ρa
valid = rho_c > 0
xp = per[valid]
rr = rho_c[valid]
pp = phi_c[valid]
if rerr_c is not None:
re = rerr_c[valid]
yerr_r = re if np.any(re > 0) else None
else:
yerr_r = None
if perr_c is not None:
pe = perr_c[valid]
yerr_p = pe if np.any(pe > 0) else None
else:
yerr_p = None
obs_label = f"$Z_{{\\rm {comp.upper()}}}$"
ax_r.errorbar(xp, rr, yerr=yerr_r, **{**ekw, "label": obs_label})
ax_p.errorbar(xp, pp, yerr=yerr_p, **{**ekw, "label": obs_label})
# ── model overlay ────────────────────────────────────────────
if rho_mod is not None and per_mod is not None:
rho_mc = rho_mod[:, ri, ci_mat]
phi_mc = phase_mod[:, ri, ci_mat]
valid_m = rho_mc > 0
# compute RMS
rms_val = _rms_log(
rr,
np.interp(
xp,
per_mod[valid_m],
rho_mc[valid_m],
left=np.nan,
right=np.nan,
),
)
if np.isfinite(rms_val):
rms_dict[comp] = rms_val
mod_lbl = f"$Z^m_{{\\rm {comp.upper()}}}$"
if show_rms and np.isfinite(rms_val):
mod_lbl += f" rms={rms_val:.2f}"
mk = cs.plot_kwargs(
ls="--", lw=1.6, alpha=0.85, marker="", label=mod_lbl
)
ax_r.plot(per_mod[valid_m], rho_mc[valid_m], **mk)
ax_p.plot(per_mod, phi_mc, **{**mk, "label": ""})
# ── axes styling ─────────────────────────────────────────────
ax_r.set_xscale("log")
ax_r.set_yscale("log")
ax_r.grid(True, which="both", alpha=0.2, lw=0.5)
ax_r.grid(True, which="major", alpha=0.4, lw=0.7)
if rho_lim:
ax_r.set_ylim(*rho_lim)
plt.setp(ax_r.get_xticklabels(), visible=False)
ax_r.tick_params(labelsize=7)
ax_r.legend(
fontsize=7,
framealpha=0.85,
loc="best",
handlelength=1.5,
borderpad=0.4,
)
ax_p.set_xscale("log")
ax_p.grid(True, which="both", alpha=0.2, lw=0.5)
ax_p.grid(True, which="major", alpha=0.4, lw=0.7)
if phase_lim:
ax_p.set_ylim(*phase_lim)
ax_p.tick_params(labelsize=7)
ax_p.set_xlabel("Period (s)", fontsize=8)
# column header
rms_str = (
f" rms={rms_dict[comp]:.2f}"
if (show_rms and rms_dict.get(comp) is not None)
else ""
)
ax_r.set_title(
f"$Z_{{\\rm {comp.upper()}}}${rms_str}",
fontsize=9,
pad=6,
fontweight="bold",
)
# shared y labels on leftmost column only
if ci == 0:
ax_r.set_ylabel(r"App. Res. (Ω·m)", fontsize=8)
ax_p.set_ylabel("Phase (deg)", fontsize=8)
# ── 7. tipper panels ──────────────────────────────────────────────────
if has_tipper and len(ax_tip) == 4:
# Tx=t[:,0], Ty=t[:,1]
tip_series = {
"tx": tip[:, 0] if tip is not None else None,
"ty": tip[:, 1] if tip is not None else None,
}
tip_err_series = {
"tx": (
tip_e[:, 0]
if (tip_e is not None and tip_e.shape[1] > 0)
else None
),
"ty": (
tip_e[:, 1]
if (tip_e is not None and tip_e.shape[1] > 1)
else None
),
}
tip_mod_series = {
"tx": tip_mod[:, 0] if tip_mod is not None else None,
"ty": tip_mod[:, 1] if tip_mod is not None else None,
}
tip_colors = {"tx": "#1f77b4", "ty": "#d62728"} # blue, red
for ti, (lbl, tc_key, part) in enumerate(_TIP_PANELS):
ax_t = ax_tip[ti]
tv = tip_series.get(tc_key)
if tv is None:
ax_t.text(
0.5,
0.5,
"—",
ha="center",
va="center",
transform=ax_t.transAxes,
color="0.55",
)
continue
col = tip_colors[tc_key]
vals = np.real(tv) if part == "real" else np.imag(tv)
te_v = tip_err_series.get(tc_key)
if te_v is not None and show_error_bars:
err_v = np.abs(te_v)
err_v = err_v if np.any(err_v > 0) else None
else:
err_v = None
ax_t.errorbar(
per,
vals,
yerr=err_v,
fmt="o",
ms=3.5,
color=col,
elinewidth=0.8,
capsize=2,
alpha=0.9,
zorder=3,
)
# model overlay
tm_v = tip_mod_series.get(tc_key)
if tm_v is not None and per_mod is not None:
mod_vals = np.real(tm_v) if part == "real" else np.imag(tm_v)
ax_t.plot(
per_mod,
mod_vals,
"--",
color=col,
lw=1.5,
alpha=0.85,
zorder=2,
)
ax_t.axhline(0.0, color="0.65", lw=0.7, ls=":", zorder=1)
ax_t.set_xscale("log")
ax_t.set_ylim(*tipper_lim)
ax_t.grid(True, which="both", alpha=0.2, lw=0.5)
ax_t.grid(True, which="major", alpha=0.4, lw=0.7)
ax_t.tick_params(labelsize=7)
ax_t.set_xlabel("Period (s)", fontsize=8)
ax_t.set_title(lbl, fontsize=8, pad=4)
if ti == 0:
ax_t.set_ylabel("Tipper", fontsize=8)
# hide unused tipper panels (when n_comp < 4)
for ti in range(4, len(ax_tip)):
ax_tip[ti].set_visible(False)
# ── 8. figure title ───────────────────────────────────────────────────
fig.suptitle(
title or st_name,
fontsize=11,
fontweight="bold",
y=1.01,
)
return fig