Source code for pycsamt.emtools.frequency

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any

import numpy as np

# re-use package editors when it saves code
from ..api.station import PYCSAMT_STATION_RENDERING
from ..api.view import (
    PYCSAMT_API_VIEW,
    maybe_wrap_frame,
    wrap_result,
)
from ._core import (
    _apply_each,
    _get_t_block,
    _get_z_block,
    _iter_items,
    _name,
    ensure_sites,
)

_BACKWARD_SINCE = "2.0.0"
_BACKWARD_REMOVE = "2.17.0"


[docs] @dataclass class FrequencyEditResult: """Container returned by confidence-based frequency editing.""" sites: Any report: Any decisions: Any mode: str method: str ci_hi: float ci_lo: float reject: str interpolation: str
[docs] @property def n_dropped(self) -> int: """Total number of dropped station-frequency rows.""" if getattr(self.decisions, "empty", True): return 0 return int((self.decisions["action"] == "dropped").sum())
[docs] @property def n_masked(self) -> int: """Total number of masked station-frequency rows.""" if getattr(self.decisions, "empty", True): return 0 return int((self.decisions["action"] == "masked").sum())
[docs] @property def n_recovered(self) -> int: """Total number of recovered station-frequency rows.""" if getattr(self.decisions, "empty", True): return 0 return int((self.decisions["action"] == "recovered").sum())
[docs] def summary(self) -> str: """Return a compact text summary of the edit result.""" return ( "FrequencyEditResult(" f"mode={self.mode!r}, method={self.method!r}, " f"dropped={self.n_dropped}, masked={self.n_masked}, " f"recovered={self.n_recovered})" )
def __repr__(self) -> str: # noqa: D105 return self.summary()
# ------------------------------- helpers -------------------------------- # def _sorted_unique(fr: np.ndarray) -> np.ndarray: fr = np.asarray(fr, dtype=float) fr = fr[np.isfinite(fr)] if fr.size == 0: return fr fr = np.unique(fr) fr = fr[fr > 0.0] return np.sort(fr) def _nearest_idx(x: np.ndarray, y: np.ndarray) -> np.ndarray: # for each y, nearest index in x idx = np.searchsorted(x, y) idx = np.clip(idx, 1, x.size - 1) left = x[idx - 1] right = x[idx] pick_left = (y - left) <= (right - y) idx[pick_left] -= 1 return idx def _interp_complex( x: np.ndarray, y: np.ndarray, xnew: np.ndarray, *, method: str = "nearest", ) -> np.ndarray: if y.ndim == 1: y = y[:, None] r = y.real im = y.imag if method == "nearest": idx = _nearest_idx(x, xnew) rr = r[idx] ii = im[idx] else: rr = np.vstack( [np.interp(xnew, x, r[:, j]) for j in range(r.shape[1])] ).T ii = np.vstack( [np.interp(xnew, x, im[:, j]) for j in range(im.shape[1])] ).T out = rr + 1j * ii return out.squeeze() def _interp_rows_by_freq( values: np.ndarray, fr: np.ndarray, fill: np.ndarray, good: np.ndarray, *, method: str = "linear", ) -> np.ndarray: """Recover selected rows by interpolating finite trusted rows.""" out_dtype = ( np.result_type(values.dtype, np.complex128) if np.iscomplexobj(values) else values.dtype ) out = values.astype(out_dtype, copy=True) fr = np.asarray(fr, dtype=float) fill = np.asarray(fill, dtype=bool) good = np.asarray(good, dtype=bool) if values.shape[0] != fr.size: return out if not np.any(fill): return out x_good = np.log10(np.maximum(fr[good], 1e-24)) x_fill = np.log10(np.maximum(fr[fill], 1e-24)) if x_good.size < 2 or x_fill.size == 0: out[fill] = np.nan return out flat = values.reshape(values.shape[0], -1) flat_out = out.reshape(out.shape[0], -1) for j in range(flat.shape[1]): y = flat[:, j] valid = good & np.isfinite(y) if np.count_nonzero(valid) < 2: flat_out[fill, j] = np.nan continue interp = _interp_complex( np.log10(np.maximum(fr[valid], 1e-24)), y[valid], x_fill, method=method, ) flat_out[fill, j] = ( interp if np.iscomplexobj(flat_out) else interp.real ) return flat_out.reshape(values.shape) def _confidence_decision_table( sites: Any, *, method: str, ci_hi: float, ci_lo: float, weights: dict[str, float] | None, recursive: bool, on_dup: str, strict: bool, verbose: int, ): from .qc import frequency_confidence_table return frequency_confidence_table( sites, method=method, weights=weights, ci_hi=ci_hi, ci_lo=ci_lo, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) def _apply_station_rendering( ax: Any, positions: Sequence[float], labels: Sequence[Any], *, station_label_step: int | None, station_preset: str, station_style: Any | None, ) -> None: """Apply the package station-axis API to frequency-edit plots.""" import copy from pycsamt.api.station import PYCSAMT_STATION_RENDERING style = station_style or PYCSAMT_STATION_RENDERING.style_for( station_preset, ) style = copy.copy(style) style.side = "top" style.max_labels = max(int(style.max_labels), len(labels)) if station_label_step is None: style.every = 1 else: style.every = int(station_label_step) style.apply( ax, positions, labels, xlim=(-0.5, len(labels) - 0.5), ) def _station_confidence_vectors( table: Any, station: str, fr: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: """Return confidence and matching flags for station frequencies.""" confidence = np.full(fr.size, np.nan, dtype=float) flags = np.full(fr.size, "", dtype=object) if table is None or getattr(table, "empty", True): return confidence, flags sub = table[table["station"].astype(str) == str(station)] if sub.empty: return confidence, flags ftab = sub["frequency_hz"].to_numpy(dtype=float) ctab = sub["confidence"].to_numpy(dtype=float) flagtab = sub["flags"].astype(str).to_numpy() for i, freq in enumerate(fr): if not np.isfinite(freq): continue idx = int(np.nanargmin(np.abs(ftab - freq))) if np.isclose(ftab[idx], freq, rtol=1e-6, atol=1e-12): confidence[i] = ctab[idx] flags[i] = flagtab[idx] return confidence, flags def _apply_row_mask_to_block( obj: Any, fields: Sequence[str], keep: np.ndarray, fr: np.ndarray ) -> None: """Apply a row-keep mask to tensor/tipper arrays and their frequency.""" if _set_masked_strict_block(obj, fields, keep, fr): return for field in fields: value = getattr(obj, field, None) if isinstance(value, np.ndarray) and value.shape[0] == fr.size: _set_array_field(obj, field, value[keep]) _set_block_freq(obj, fr[keep]) def _set_bad_rows_to_nan( obj: Any, fields: Sequence[str], bad: np.ndarray, fr: np.ndarray ) -> None: """Set selected tensor/tipper rows to NaN.""" for field in fields: value = getattr(obj, field, None) if isinstance(value, np.ndarray) and value.shape[0] == fr.size: new = value.copy() new[bad] = np.nan _set_array_field(obj, field, new) def _set_array_field(obj: Any, field: str, value: np.ndarray) -> None: """Set an array field, tolerating NaN-containing values. ``Z``-like containers with ``compute_resistivity_phase`` accept NaN-containing ``z``/``z_err`` writes fine (the setter applies the array first, then logs internally if the derived resistivity/phase recompute can't handle the NaNs) — masking and recovery both rely on writing NaN rows here, so this must not refuse the write. """ try: setattr(obj, field, value) except Exception: pass def _set_masked_strict_block( obj: Any, fields: Sequence[str], keep: np.ndarray, fr: np.ndarray ) -> bool: """Atomically subset strict Z-like containers.""" if not hasattr(obj, "compute_resistivity_phase"): return False if "z" not in fields or not hasattr(obj, "_z"): return False z = getattr(obj, "z", None) if not isinstance(z, np.ndarray) or z.shape[0] != fr.size: return False z_new = z[keep] if not np.isfinite(z_new).all(): return False try: obj._freq = np.asarray(fr[keep], dtype=float) obj._z = z_new.astype(complex, copy=False) z_err = getattr(obj, "z_err", None) if ( "z_err" in fields and isinstance(z_err, np.ndarray) and z_err.shape[0] == fr.size ): obj._z_err = z_err[keep].astype(float, copy=False) obj.compute_resistivity_phase() except Exception: return False return True def _set_block_freq(obj: Any, fr: np.ndarray) -> None: try: obj.freq = fr except Exception: pass def _regrid_z( z: np.ndarray, fr: np.ndarray, fnew: np.ndarray, *, method: str, ) -> np.ndarray: # z: (n,2,2) complex n = fnew.size out = np.empty((n, 2, 2), dtype=complex) x = np.log10(fr) xn = np.log10(fnew) for a in range(2): for b in range(2): y = z[:, a, b] out[:, a, b] = _interp_complex(x, y, xn, method=method) return out def _regrid_t( t: np.ndarray, fr: np.ndarray, fnew: np.ndarray, *, method: str, ) -> np.ndarray: # t: (n,2) complex n = fnew.size out = np.empty((n, 2), dtype=complex) x = np.log10(fr) xn = np.log10(fnew) for k in range(2): y = t[:, k] out[:, k] = _interp_complex(x, y, xn, method=method) return out def _apply_grid_one( ed: Any, fnew: np.ndarray, *, method: str, ) -> None: Z, z, frz = _get_z_block(ed) if Z is not None: z2 = _regrid_z(z, frz, fnew, method=method) try: Z.z = z2 except Exception: pass z_err = getattr(Z, "z_err", None) if isinstance(z_err, np.ndarray) and z_err.shape[0] == frz.size: try: Z.z_err = _regrid_z(z_err, frz, fnew, method=method).real except Exception: try: Z._z_err = _regrid_z(z_err, frz, fnew, method=method).real except Exception: pass _set_block_freq(Z, fnew) T, t, frt = _get_t_block(ed) if T is not None: t2 = _regrid_t(t, frt, fnew, method=method) try: T.tipper = t2 except Exception: pass tipper_err = getattr(T, "tipper_err", None) if ( isinstance(tipper_err, np.ndarray) and tipper_err.shape[0] == frt.size ): try: T.tipper_err = _regrid_t( tipper_err, frt, fnew, method=method ).real except Exception: try: T._tipper_err = _regrid_t( tipper_err, frt, fnew, method=method ).real except Exception: pass _set_block_freq(T, fnew) def _valid_freq_of(ed: Any) -> np.ndarray | None: Z, z, fr = _get_z_block(ed) if isinstance(fr, np.ndarray) and fr.size: return _sorted_unique(fr) T, t, fr = _get_t_block(ed) if isinstance(fr, np.ndarray) and fr.size: return _sorted_unique(fr) return None def _union_grid(SitesObj) -> np.ndarray: frs: list[np.ndarray] = [] for ed in _iter_items(SitesObj): fr = _valid_freq_of(ed) if isinstance(fr, np.ndarray) and fr.size: frs.append(fr) if not frs: return np.array([], dtype=float) g = np.unique(np.concatenate(frs)) return _sorted_unique(g) def _intersect_grid(SitesObj) -> np.ndarray: frs: list[np.ndarray] = [] for ed in _iter_items(SitesObj): fr = _valid_freq_of(ed) if isinstance(fr, np.ndarray) and fr.size: frs.append(fr) if not frs: return np.array([], dtype=float) g = frs[0] for fr in frs[1:]: g = np.intersect1d(g, fr) return _sorted_unique(g) # ------------------------------ public API ------------------------------- #
[docs] def select_band( sites: Any, *, fmin: float | None = None, fmax: float | None = None, band_hz: Sequence[float] | None = None, keep: Sequence[float] | None = None, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): # Resolve band_hz alias → fmin / fmax. # band_hz=(lo, hi) is a convenience form; canonical fmin/fmax always win. if band_hz is not None: _lo, _hi = band_hz if fmin is None: fmin = _lo if fmax is None: fmax = _hi S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) keep_values = None if keep is None else np.asarray(keep, dtype=float) def _keep_mask(fr: np.ndarray) -> np.ndarray: fr = np.asarray(fr, dtype=float) if keep_values is not None: mask = np.zeros(fr.size, dtype=bool) for value in keep_values[np.isfinite(keep_values)]: mask |= np.isclose(fr, value, rtol=1e-6, atol=1e-12) return mask mask = np.ones(fr.size, dtype=bool) if fmin is not None: mask &= fr >= float(fmin) if fmax is not None: mask &= fr <= float(fmax) return mask def _one(Si): ed = next(_iter_items(Si)) Z, z, fr = _get_z_block(ed) if Z is not None: _apply_row_mask_to_block(Z, ("z", "z_err"), _keep_mask(fr), fr) T, t, fr = _get_t_block(ed) if T is not None: _apply_row_mask_to_block( T, ("tipper", "tipper_err"), _keep_mask(fr), fr ) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def drop_duplicates( sites: Any, *, tol: float = 1e-10, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) def _one(Si): ed = next(_iter_items(Si)) for objname in ("Z", "z", "Tipper", "tipper"): O = getattr(ed, objname, None) if O is None: continue fr = getattr(O, "freq", None) if not isinstance(fr, np.ndarray): continue fr = np.asarray(fr, dtype=float) if fr.size == 0: continue frs = _sorted_unique(fr) if frs.size == fr.size and np.allclose(fr, frs): continue # nearest index map idx = _nearest_idx(frs, fr) # keep first occurrence only seen = set() keep_idx = [] for i, j in enumerate(idx): if abs(fr[i] - frs[j]) <= tol and j not in seen: keep_idx.append(i) seen.add(j) keep_idx = np.array(keep_idx, dtype=int) for fld in ("z", "z_err", "tipper", "tipper_err"): val = getattr(O, fld, None) if isinstance(val, np.ndarray) and val.shape[0] == fr.size: setattr(O, fld, val[keep_idx]) _set_block_freq(O, fr[keep_idx]) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def drop_low_confidence_frequencies( sites: Any, *, method: str = "composite", threshold: float = 0.50, weights: dict[str, float] | None = None, also: str = "both", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): """Drop rows whose frequency confidence is below ``threshold``. The confidence scores are computed with :func:`pycsamt.emtools.qc.frequency_confidence_table`. The operation is station-aware: each station keeps or drops its own bad frequency rows. A new :class:`~pycsamt.site.base.Sites` object is returned unless ``inplace=True``. """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) table = _confidence_decision_table( S, method=method, ci_hi=max(float(threshold), 0.50), ci_lo=float(threshold), weights=weights, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) def _one(Si): ed = next(_iter_items(Si)) station = _name(ed, 0) if also in ("z", "both"): Z, z, fr = _get_z_block(ed) if Z is not None: conf, _ = _station_confidence_vectors(table, station, fr) keep = ~np.isfinite(conf) | (conf >= float(threshold)) _apply_row_mask_to_block(Z, ("z", "z_err"), keep, fr) if also in ("tipper", "both"): T, t, fr = _get_t_block(ed) if T is not None: conf, _ = _station_confidence_vectors(table, station, fr) keep = ~np.isfinite(conf) | (conf >= float(threshold)) _apply_row_mask_to_block( T, ("tipper", "tipper_err"), keep, fr, ) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def mask_low_confidence_frequencies( sites: Any, *, method: str = "composite", threshold: float = 0.50, weights: dict[str, float] | None = None, also: str = "both", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): """Set low-confidence frequency rows to NaN without changing the grid.""" S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) table = _confidence_decision_table( S, method=method, ci_hi=max(float(threshold), 0.50), ci_lo=float(threshold), weights=weights, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) def _one(Si): ed = next(_iter_items(Si)) station = _name(ed, 0) if also in ("z", "both"): Z, z, fr = _get_z_block(ed) if Z is not None: conf, _ = _station_confidence_vectors(table, station, fr) bad = np.isfinite(conf) & (conf < float(threshold)) _set_bad_rows_to_nan(Z, ("z", "z_err"), bad, fr) if also in ("tipper", "both"): T, t, fr = _get_t_block(ed) if T is not None: conf, _ = _station_confidence_vectors(table, station, fr) bad = np.isfinite(conf) & (conf < float(threshold)) _set_bad_rows_to_nan(T, ("tipper", "tipper_err"), bad, fr) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def recover_low_confidence_frequencies( sites: Any, *, method: str = "composite", ci_hi: float = 0.90, ci_lo: float = 0.50, weights: dict[str, float] | None = None, interpolation: str = "linear", reject: str = "mask", also: str = "both", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): """Recover recoverable frequency rows using trusted neighboring rows. Rows with confidence in ``[ci_lo, ci_hi)`` are treated as recoverable and are interpolated in log-frequency from rows with confidence ``>= ci_hi``. Rows below ``ci_lo`` are considered rejected and are either masked, dropped, or kept depending on ``reject``. """ reject = str(reject).lower() if reject not in {"mask", "drop", "keep"}: msg = "reject must be one of 'mask', 'drop', or 'keep'." raise ValueError(msg) if interpolation not in {"linear", "nearest"}: msg = "interpolation must be 'linear' or 'nearest'." raise ValueError(msg) S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) table = _confidence_decision_table( S, method=method, ci_hi=ci_hi, ci_lo=ci_lo, weights=weights, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) def _recover_block(obj, fields, fr, conf): trusted = np.isfinite(conf) & (conf >= float(ci_hi)) recover = ( np.isfinite(conf) & (conf >= float(ci_lo)) & (conf < float(ci_hi)) ) reject_mask = np.isfinite(conf) & (conf < float(ci_lo)) if recover.any(): for field in fields: value = getattr(obj, field, None) if ( isinstance(value, np.ndarray) and value.shape[0] == fr.size ): recovered = _interp_rows_by_freq( value, fr, recover, trusted, method=interpolation, ) _set_array_field(obj, field, recovered) if reject == "mask" and reject_mask.any(): _set_bad_rows_to_nan(obj, fields, reject_mask, fr) elif reject == "drop" and reject_mask.any(): keep = ~reject_mask _apply_row_mask_to_block(obj, fields, keep, fr) def _one(Si): ed = next(_iter_items(Si)) station = _name(ed, 0) if also in ("z", "both"): Z, z, fr = _get_z_block(ed) if Z is not None: conf, _ = _station_confidence_vectors(table, station, fr) _recover_block(Z, ("z", "z_err"), fr, conf) if also in ("tipper", "both"): T, t, fr = _get_t_block(ed) if T is not None: conf, _ = _station_confidence_vectors(table, station, fr) _recover_block(T, ("tipper", "tipper_err"), fr, conf) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def edit_frequencies_by_confidence( sites: Any, *, mode: str = "recover", before_sites: Any | None = None, method: str = "composite", threshold: float = 0.50, ci_hi: float = 0.90, ci_lo: float = 0.50, weights: dict[str, float] | None = None, interpolation: str = "linear", reject: str = "drop", also: str = "both", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ) -> Any: """Edit frequency rows and return diagnostics in one workflow. This is the high-level confidence-editing entry point. It applies one of the low-level edit strategies and immediately computes a station report and a station-frequency decision table. Use ``before_sites`` when ``sites`` is already an in-memory object and a reliable before/after comparison is required, because lower-level site editors can mutate the wrapped impedance objects while constructing the edited return value. Parameters ---------- sites : path-like, EDI-like, Sites, or sequence Input data to edit. Path-like inputs are normally safe to use directly because they can be loaded independently by the package. In-memory objects should be paired with ``before_sites`` when the report must preserve an untouched baseline. mode : {'recover', 'drop', 'mask'}, default 'recover' Frequency-editing strategy. ``'recover'`` interpolates recoverable rows in log-frequency and handles rejected rows according to ``reject``. ``'drop'`` removes rows below ``threshold``. ``'mask'`` keeps the frequency grid but replaces low-confidence tensor rows by missing values when the container allows it. before_sites : optional Independent baseline used only for reporting and decision tracking. If omitted, ``sites`` is used as the baseline. method : str, default 'composite' Confidence metric passed to :func:`pycsamt.emtools.qc.frequency_confidence_table`. threshold : float, default 0.50 Confidence threshold used by ``mode='drop'`` and ``mode='mask'``. ci_hi, ci_lo : float, default 0.90 and 0.50 High-confidence and low-confidence limits used by ``mode='recover'`` and by the diagnostic report. weights : dict or None, default None Optional confidence-metric weights. interpolation : {'linear', 'nearest'}, default 'linear' Interpolation strategy for recoverable rows in ``mode='recover'``. reject : {'drop', 'mask', 'keep'}, default 'drop' Handling of rows below ``ci_lo`` in ``mode='recover'``. also : {'z', 'tipper', 'both'}, default 'both' Data blocks edited when present. inplace : bool, default False Forwarded to the low-level edit function. recursive, on_dup, strict, verbose Site-loading options forwarded to :func:`ensure_sites`. Returns ------- FrequencyEditResult Edited sites together with station-level and station-frequency diagnostics. """ mode = str(mode).lower() if mode not in {"recover", "drop", "mask"}: msg = "mode must be one of 'recover', 'drop', or 'mask'." raise ValueError(msg) baseline = before_sites if before_sites is not None else sites if mode == "recover": edited = recover_low_confidence_frequencies( sites, method=method, ci_hi=ci_hi, ci_lo=ci_lo, weights=weights, interpolation=interpolation, reject=reject, also=also, inplace=inplace, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) elif mode == "drop": edited = drop_low_confidence_frequencies( sites, method=method, threshold=threshold, weights=weights, also=also, inplace=inplace, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) else: edited = mask_low_confidence_frequencies( sites, method=method, threshold=threshold, weights=weights, also=also, inplace=inplace, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) report = frequency_edit_report( baseline, edited, method=method, ci_hi=ci_hi, ci_lo=ci_lo, weights=weights, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) decisions = frequency_edit_decision_table( baseline, edited, method=method, ci_hi=ci_hi, ci_lo=ci_lo, weights=weights, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) result = FrequencyEditResult( sites=edited, report=report, decisions=decisions, mode=mode, method=method, ci_hi=float(ci_hi), ci_lo=float(ci_lo), reject=str(reject), interpolation=str(interpolation), ) if api is False: return result if api is None and not PYCSAMT_API_VIEW.enabled(): return result return wrap_result( { "sites": result.sites, "report": result.report, "decisions": result.decisions, "mode": result.mode, "method": result.method, "ci_hi": result.ci_hi, "ci_lo": result.ci_lo, "reject": result.reject, "interpolation": result.interpolation, "n_dropped": result.n_dropped, "n_masked": result.n_masked, "n_recovered": result.n_recovered, }, name="frequency_edit", kind="emtools.frequency.edit", meta={ "mode": result.mode, "method": result.method, "ci_hi": result.ci_hi, "ci_lo": result.ci_lo, "reject": result.reject, "interpolation": result.interpolation, }, )
[docs] def frequency_edit_report( before_sites: Any, after_sites: Any, *, method: str = "composite", ci_hi: float = 0.90, ci_lo: float = 0.50, weights: dict[str, float] | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ): """Summarize station-level changes after frequency editing. The report compares the native frequency rows and finite tensor rows before and after an edit such as dropping, masking, or recovery. It also carries the median confidence from :func:`pycsamt.emtools.qc.frequency_confidence_table`. """ import pandas as pd before = ensure_sites( before_sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) after = ensure_sites( after_sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) tb_before = _confidence_decision_table( before, method=method, ci_hi=ci_hi, ci_lo=ci_lo, weights=weights, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) tb_after = _confidence_decision_table( after, method=method, ci_hi=ci_hi, ci_lo=ci_lo, weights=weights, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) def _counts(sites_obj): rows = [] for i, ed in enumerate(_iter_items(sites_obj)): station = _name(ed, i) Z, z, fr = _get_z_block(ed) if Z is None: rows.append( dict( station=station, n_freq=0, n_finite=0, frac_finite=np.nan, ) ) continue finite_rows = np.isfinite(z.reshape(z.shape[0], -1)).all(axis=1) rows.append( dict( station=station, n_freq=int(fr.size), n_finite=int(np.count_nonzero(finite_rows)), frac_finite=float( np.count_nonzero(finite_rows) / max(1, fr.size), ), ) ) return pd.DataFrame.from_records(rows) def _conf_summary(table, suffix): if table.empty: return pd.DataFrame( columns=[ "station", f"confidence_median_{suffix}", f"safe_fraction_{suffix}", f"recoverable_fraction_{suffix}", f"reject_fraction_{suffix}", ] ) grouped = table.assign( safe=table["confidence"] >= ci_hi, recoverable=(table["confidence"] >= ci_lo) & (table["confidence"] < ci_hi), reject=table["confidence"] < ci_lo, ).groupby("station") return grouped.agg( **{ f"confidence_median_{suffix}": ("confidence", "median"), f"safe_fraction_{suffix}": ("safe", "mean"), f"recoverable_fraction_{suffix}": ("recoverable", "mean"), f"reject_fraction_{suffix}": ("reject", "mean"), } ).reset_index() left = _counts(before).rename( columns={ "n_freq": "n_freq_before", "n_finite": "n_finite_before", "frac_finite": "frac_finite_before", } ) right = _counts(after).rename( columns={ "n_freq": "n_freq_after", "n_finite": "n_finite_after", "frac_finite": "frac_finite_after", } ) out = left.merge(right, on="station", how="outer") out = out.merge( _conf_summary(tb_before, "before"), on="station", how="left" ) out = out.merge( _conf_summary(tb_after, "after"), on="station", how="left" ) out["n_dropped"] = out["n_freq_before"] - out["n_freq_after"] out["n_masked_or_unfinite"] = ( out["n_finite_before"] - out["n_finite_after"] ) out["confidence_delta"] = ( out["confidence_median_after"] - out["confidence_median_before"] ) df = out.sort_values("station").reset_index(drop=True) return maybe_wrap_frame( df, api=api, name="frequency_edit_report", kind="emtools.frequency.edit_report", source=before_sites, description="Station-level changes after frequency editing.", )
[docs] def frequency_edit_decision_table( before_sites: Any, after_sites: Any, *, method: str = "composite", ci_hi: float = 0.90, ci_lo: float = 0.50, weights: dict[str, float] | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ): """Return one row per original station-frequency edit decision.""" import pandas as pd before = ensure_sites( before_sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) after = ensure_sites( after_sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) conf = _confidence_decision_table( before, method=method, ci_hi=ci_hi, ci_lo=ci_lo, weights=weights, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) after_map = {} for i, ed in enumerate(_iter_items(after)): station = _name(ed, i) Z, z, fr = _get_z_block(ed) if Z is None: continue finite = np.isfinite(z.reshape(z.shape[0], -1)).all(axis=1) after_map[station] = (fr, finite, z) rows = [] for i, ed in enumerate(_iter_items(before)): station = _name(ed, i) Z, z, fr = _get_z_block(ed) if Z is None: continue finite_before = np.isfinite(z.reshape(z.shape[0], -1)).all(axis=1) after_fr, after_finite, after_z = after_map.get( station, ( np.asarray([], dtype=float), np.asarray([], dtype=bool), np.asarray([], dtype=complex), ), ) cvec, flagvec = _station_confidence_vectors(conf, station, fr) for j, freq in enumerate(fr): present_after = False finite_after = False changed_after = False if after_fr.size: idx = int(np.nanargmin(np.abs(after_fr - freq))) if np.isclose(after_fr[idx], freq, rtol=1e-6, atol=1e-12): present_after = True finite_after = bool(after_finite[idx]) if after_z.ndim == z.ndim and after_z.shape[0] > idx: changed_after = not np.allclose( z[j], after_z[idx], rtol=1e-7, atol=1e-12, equal_nan=True, ) if not present_after: action = "dropped" elif finite_before[j] and not finite_after: action = "masked" elif (not finite_before[j]) and finite_after: action = "recovered" elif ( np.isfinite(cvec[j]) and float(ci_lo) <= cvec[j] < float(ci_hi) and changed_after ): action = "recovered" else: action = "kept" rows.append( dict( station=station, frequency_hz=float(freq), period_s=float(1.0 / freq) if freq else np.nan, log10_period=( float(np.log10(1.0 / freq)) if freq > 0 else np.nan ), confidence=float(cvec[j]), flags=str(flagvec[j]), finite_before=bool(finite_before[j]), present_after=bool(present_after), finite_after=bool(finite_after), action=action, ) ) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="frequency_edit_decision_table", kind="emtools.frequency.edit_decisions", source=before_sites, description="Per-frequency edit decisions for each station.", )
[docs] def plot_frequency_edit_summary( before_sites: Any, after_sites: Any, *, method: str = "composite", ci_hi: float = 0.90, ci_lo: float = 0.50, figsize: tuple[float, float] = (9.0, 4.0), station_label_step: int | None = 1, station_preset: str = "pseudosection", station_style: Any | None = None, ax: Any | None = None, ): """Plot station-level before/after frequency-edit summary.""" import matplotlib.pyplot as plt from pycsamt.api.style import PYCSAMT_STYLE report = frequency_edit_report( before_sites, after_sites, method=method, ci_hi=ci_hi, ci_lo=ci_lo, ) if ax is None: _, ax = plt.subplots(figsize=figsize) if report.empty: ax.text(0.5, 0.5, "no stations", ha="center", va="center") return ax x = np.arange(len(report)) before_kw = PYCSAMT_STYLE.correction.before.plot_kwargs() after_kw = PYCSAMT_STYLE.correction.after.plot_kwargs() ax.plot( x, report["n_freq_before"].to_numpy(dtype=float), **before_kw, ) ax.plot( x, report["n_freq_after"].to_numpy(dtype=float), **after_kw, ) dropped = report["n_dropped"].fillna(0).to_numpy(dtype=float) masked = report["n_masked_or_unfinite"].fillna(0).to_numpy(dtype=float) ax.bar(x, dropped, color="#d62728", alpha=0.22, label="dropped") ax.bar( x, masked, bottom=dropped, color="#ff99c8", alpha=0.28, label="masked/unfinite delta", ) station_names = report["station"].astype(str).tolist() _apply_station_rendering( ax, x, station_names, station_label_step=station_label_step, station_preset=station_preset, station_style=station_style, ) ax.set_ylabel("Frequency rows") ax.set_title("Frequency edit summary", fontsize=10) ax.grid(True, ls=":", alpha=0.35) ax.legend(fontsize=8) return ax
[docs] def plot_frequency_edit_decisions( before_sites: Any, after_sites: Any, *, method: str = "composite", ci_hi: float = 0.90, ci_lo: float = 0.50, figsize: tuple[float, float] = (10.0, 5.0), station_label_step: int | None = 1, station_preset: str = "pseudosection", station_style: Any | None = None, ax: Any | None = None, ): """Plot dropped, masked, recovered, and kept frequency decisions.""" import matplotlib.pyplot as plt from matplotlib.colors import BoundaryNorm, ListedColormap table = frequency_edit_decision_table( before_sites, after_sites, method=method, ci_hi=ci_hi, ci_lo=ci_lo, ) if ax is None: _, ax = plt.subplots(figsize=figsize) if table.empty: ax.text(0.5, 0.5, "no data", ha="center", va="center") return ax action_codes = { "dropped": 0, "masked": 1, "recovered": 2, "kept": 3, } stations = table["station"].drop_duplicates().astype(str).tolist() periods = np.sort(table["log10_period"].dropna().unique()) matrix = np.full((periods.size, len(stations)), np.nan, dtype=float) for j, station in enumerate(stations): sub = table[table["station"].astype(str) == station] lookup = { float(row.log10_period): action_codes.get(str(row.action), np.nan) for _, row in sub.iterrows() if np.isfinite(row.log10_period) } for i, period in enumerate(periods): matrix[i, j] = lookup.get(float(period), np.nan) cmap = ListedColormap(["#8b0026", "#ff99c8", "#20b455", "#d8d8d8"]) norm = BoundaryNorm([-0.5, 0.5, 1.5, 2.5, 3.5], cmap.N) im = ax.imshow( matrix, aspect="auto", origin="lower", interpolation="nearest", cmap=cmap, norm=norm, extent=(-0.5, len(stations) - 0.5, periods.min(), periods.max()), ) _apply_station_rendering( ax, np.arange(len(stations)), stations, station_label_step=station_label_step, station_preset=station_preset, station_style=station_style, ) ax.set_ylabel(r"$\log_{10}T$ (s)") ax.set_title("Frequency edit decisions", fontsize=10) cbar = ax.figure.colorbar(im, ax=ax, pad=0.015, ticks=[0, 1, 2, 3]) cbar.ax.set_yticklabels(["dropped", "masked", "recovered", "kept"]) return ax
[docs] def regrid_to( sites: Any, target_freq: Sequence[float], *, method: str = "nearest", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) fnew = _sorted_unique(np.asarray(target_freq, dtype=float)) def _one(Si): ed = next(_iter_items(Si)) if fnew.size == 0: return Si _apply_grid_one(ed, fnew, method=method) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def regrid_logspace( sites: Any, *, fmin: float | None = None, fmax: float | None = None, band_hz: Sequence[float] | None = None, per_decade: int = 6, n_per_decade: int | None = None, method: str = "nearest", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): # Resolve band_hz alias → fmin / fmax (canonical wins). if band_hz is not None: _lo, _hi = band_hz if fmin is None: fmin = _lo if fmax is None: fmax = _hi # Resolve n_per_decade alias → per_decade. # n_per_decade only takes effect when per_decade is still at its default (6). if n_per_decade is not None and per_decade == 6: per_decade = int(n_per_decade) S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) # infer band from union U = _union_grid(S) if U.size == 0: return S lo = fmin if fmin is not None else U.min() hi = fmax if fmax is not None else U.max() lo, hi = float(lo), float(hi) if lo <= 0 or hi <= 0 or hi <= lo: return S ndec = np.log10(hi) - np.log10(lo) npts = max(2, int(np.ceil(ndec * per_decade))) fnew = np.logspace(np.log10(lo), np.log10(hi), npts) return regrid_to( S, fnew, method=method, inplace=inplace, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, )
[docs] def decimate_step( sites: Any, *, step: int = 2, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) def _one(Si): ed = next(_iter_items(Si)) Z, z, frz = _get_z_block(ed) if Z is not None: idx = np.arange(0, frz.size, step, dtype=int) Z.z = z[idx] _set_block_freq(Z, frz[idx]) T, t, frt = _get_t_block(ed) if T is not None: idx = np.arange(0, frt.size, step, dtype=int) T.tipper = t[idx] _set_block_freq(T, frt[idx]) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def smooth_mavg( sites: Any, *, k: int = 3, window: int | None = None, on: str = "both", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): # Resolve window alias → k. # window only takes effect when k is still at its default (3). if window is not None and k == 3: k = int(window) S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) k = max(1, int(k)) if k == 1: return S def _roll(y: np.ndarray) -> np.ndarray: if y.ndim == 1: y = y[:, None] out = np.empty_like(y) for j in range(y.shape[1]): v = y[:, j].astype(complex) re = np.convolve(v.real, np.ones(k) / k, "same") im = np.convolve(v.imag, np.ones(k) / k, "same") out[:, j] = re + 1j * im return out.squeeze() def _one(Si): ed = next(_iter_items(Si)) if on in ("z", "both"): Z, z, fr = _get_z_block(ed) if Z is not None: z2 = z.copy() for a in range(2): for b in range(2): z2[:, a, b] = _roll(z[:, a, b]) Z.z = z2 if on in ("tipper", "both"): T, t, fr = _get_t_block(ed) if T is not None: t2 = t.copy() for j in range(2): t2[:, j] = _roll(t[:, j]) T.tipper = t2 return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def align_grid( sites: Any, *, mode: str = "union", ref_station: str | None = None, method: str = "nearest", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if mode == "union": fnew = _union_grid(S) elif mode == "intersection": fnew = _intersect_grid(S) elif mode == "ref": fnew = None for ed in _iter_items(S): nm = getattr(ed, "station", None) or getattr(ed, "name", None) if nm == ref_station: fr = _valid_freq_of(ed) if isinstance(fr, np.ndarray): fnew = fr break if fnew is None: return S else: raise ValueError("mode must be union|intersection|ref") if fnew.size == 0: return S return regrid_to( S, fnew, method=method, inplace=inplace, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, )
# ------------------------- coverage + quality ---------------------------- # import matplotlib.pyplot as plt def _rel_err_block(z: np.ndarray, ze: np.ndarray | None) -> np.ndarray: if ze is None: return np.zeros(z.shape[0], dtype=float) # avg relative err across off-diagonals (fallback: all) zx = z[:, 0, 1] zy = z[:, 1, 0] ex = ze[:, 0, 1] if ze.ndim == 3 else None ey = ze[:, 1, 0] if ze.ndim == 3 else None if ex is not None and ey is not None: rx = np.abs(ex) / (np.abs(zx) + 1e-12) ry = np.abs(ey) / (np.abs(zy) + 1e-12) r = 0.5 * (rx + ry) return np.asarray(r, dtype=float) # fallback to full-tensor mean num = np.linalg.norm(ze.reshape(ze.shape[0], -1), axis=1) den = np.linalg.norm(z.reshape(z.shape[0], -1), axis=1) + 1e-12 return num / den
[docs] def plot_coverage_quality_heatmap( sites: Any, *, 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: S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) # assemble presence & quality per site on union grid labs: list[str] = [] frs: list[np.ndarray] = [] quals: list[np.ndarray] = [] for i, ed in enumerate(_iter_items(S)): nm = getattr(ed, "station", None) or getattr(ed, "name", None) nm = nm or f"site_{i}" Z, z, fr = _get_z_block(ed) if Z is None: continue ze = getattr(Z, "z_err", None) r = _rel_err_block(z, ze) q = 1.0 / (1.0 + r) frs.append(_sorted_unique(fr)) # re-align q to unique set by averaging duplicates uf = frs[-1] if uf.size != fr.size: idx = _nearest_idx(uf, fr) acc = np.zeros(uf.size, dtype=float) cnt = np.zeros(uf.size, dtype=float) for k, j in enumerate(idx): acc[j] += q[k] cnt[j] += 1.0 q = acc / (cnt + 1e-12) else: q = q quals.append(q) labs.append(nm) grid = _union_grid(S) if grid.size == 0 or not labs: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no data", ha="center", va="center") return ax # project site vectors onto union grid M = np.zeros((len(labs), grid.size), dtype=float) for i, (fr, q) in enumerate(zip(frs, quals)): idx = _nearest_idx(grid, fr) M[i, idx] = q if axis == "period": ylab = "period (s)" y_values = 1.0 / np.maximum(grid, 1e-24) row_order = np.argsort(y_values) y_values = y_values[row_order] image = M.T[row_order] else: ylab = "freq (Hz)" y_values = grid image = M.T if ax is None: _, ax = plt.subplots(figsize=figsize) im = ax.imshow( image, aspect="auto", origin="lower", interpolation="nearest", vmin=0.0, vmax=1.0, ) ax.set_ylabel(ylab) PYCSAMT_STATION_RENDERING.apply( ax, np.arange(len(labs)), labs, preset="pseudosection", xlim=(-0.5, len(labs) - 0.5), ) yt = np.linspace(0, image.shape[0] - 1, num=min(8, image.shape[0])) yv = np.linspace(y_values.min(), y_values.max(), num=yt.size) ax.set_yticks(yt) ax.set_yticklabels([f"{v:.2g}" for v in yv]) if axis == "period" and not ax.yaxis_inverted(): ax.invert_yaxis() cb = plt.colorbar(im, ax=ax) cb.set_label("quality (1/(1+rel_err))") return ax
# ------------------------- apparent depth image -------------------------- # def _rho_det_from_z(z: np.ndarray, fr: np.ndarray) -> np.ndarray: # ρa_xy/yx = 0.2 * |Z|^2 / f (ohm·m); det via geom. mean zx = z[:, 0, 1] zy = z[:, 1, 0] rx = 0.2 * (np.abs(zx) ** 2) / (fr + 1e-24) ry = 0.2 * (np.abs(zy) ** 2) / (fr + 1e-24) rdet = np.sqrt(rx * ry) return rdet def _depth_from_rho_f(rho: np.ndarray, fr: np.ndarray) -> np.ndarray: # δ ≈ 503 * sqrt(ρ / f) (meters) with np.errstate(invalid="ignore"): d = 503.0 * np.sqrt(np.maximum(rho, 0.0) / (fr + 1e-24)) return d
[docs] def plot_apparent_depth_psection( sites: Any, *, axis_y: str = "period", agg: str = "median", figsize: tuple[float, float] = (7.5, 4.5), log_color: bool = True, 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, ) # gather rows: station, freq, period, depth rows: list[tuple[str, float, float, float]] = [] for i, ed in enumerate(_iter_items(S)): nm = getattr(ed, "station", None) or getattr(ed, "name", None) nm = nm or f"site_{i}" Z, z, fr = _get_z_block(ed) if Z is None: continue rho = _rho_det_from_z(z, fr) dep = _depth_from_rho_f(rho, fr) per = 1.0 / fr for f, p, d in zip(fr, per, dep): if not np.isfinite(d): continue rows.append((nm, f, p, d)) if not rows: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no data", ha="center", va="center") return ax # pivot to station × axis_y dtype = [ ("station", "U64"), ("freq", "f8"), ("period", "f8"), ("depth", "f8"), ] arr = np.array(rows, dtype=dtype) import pandas as pd df = pd.DataFrame(arr) ykey = "period" if axis_y == "period" else "freq" piv = df.pivot_table( index=ykey, columns="station", values="depth", aggfunc=agg, ) piv = piv.sort_index() if axis_y == "period": piv = piv.sort_index(ascending=True) # piv has shape (n_periods, n_stations); do NOT transpose so that # imshow maps x → stations and y → periods. Zm = piv.to_numpy(dtype=float) if log_color: Zplot = np.log10(np.maximum(Zm, 1e-3)) cblab = "log10 depth (m)" else: Zplot = Zm cblab = "depth (m)" if ax is None: _, ax = plt.subplots(figsize=figsize) im = ax.imshow( Zplot, aspect="auto", origin="lower", interpolation="nearest", ) ax.set_ylabel("Period (s)" if axis_y == "period" else "Frequency (Hz)") PYCSAMT_STATION_RENDERING.apply( ax, np.arange(Zplot.shape[1], dtype=float), list(piv.columns), preset="pseudosection", xlim=(-0.5, Zplot.shape[1] - 0.5), ) yt = np.linspace( 0, Zplot.shape[0] - 1, num=min(8, Zplot.shape[0]) ) # shape[0] = n_periods yv = np.linspace( piv.index.min(), piv.index.max(), num=min(8, len(piv.index)) ) ax.set_yticks(yt) ax.set_yticklabels([f"{v:.3g}" for v in yv]) if axis_y == "period" and not ax.yaxis_inverted(): ax.invert_yaxis() cb = plt.colorbar(im, ax=ax) cb.set_label(cblab) return ax
# ------------------------- band-collapsed microplots --------------------- # def _det_phase_from_z(z: np.ndarray) -> np.ndarray: detz = z[:, 0, 0] * z[:, 1, 1] - z[:, 0, 1] * z[:, 1, 0] return np.degrees(np.angle(detz)) def _tip_amp(t: np.ndarray | None) -> np.ndarray | None: if t is None: return None return np.sqrt(np.abs(t[:, 0]) ** 2 + np.abs(t[:, 1]) ** 2) def _bands_auto(all_period: np.ndarray, n: int) -> np.ndarray: lo = float(np.nanmin(all_period)) hi = float(np.nanmax(all_period)) lo = max(lo, 1e-6) edges = np.logspace(np.log10(lo), np.log10(hi), n + 1) return edges
[docs] def plot_band_microstrips( sites: Any, *, bands: Sequence[tuple[float, float]] | None = None, n_bands: int = 6, figsize: tuple[float, float] = (9.0, 6.0), marker_size: float = 16.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, ) # collect per-site arrays sites_data = [] allP = [] for i, ed in enumerate(_iter_items(S)): nm = getattr(ed, "station", None) or getattr(ed, "name", None) nm = nm or f"site_{i}" Z, z, fr = _get_z_block(ed) T, t, frt = _get_t_block(ed) if Z is None: continue per = 1.0 / fr rho = _rho_det_from_z(z, fr) lgr = np.log10(np.maximum(rho, 1e-12)) ph = _det_phase_from_z(z) ta = _tip_amp(t) if T is not None else None sites_data.append((nm, per, lgr, ph, ta)) allP.append(per) if not sites_data: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no data", ha="center", va="center") return ax allP = np.concatenate(allP) if bands is None: edges = _bands_auto(allP, n_bands) bands = list(zip(edges[:-1], edges[1:])) # aggregate per band rows = [] for nm, per, lgr, ph, ta in sites_data: for bi, (lo, hi) in enumerate(bands): m = (per >= lo) & (per < hi) if not np.any(m): rows.append((nm, bi, np.nan, np.nan, np.nan)) continue r = float(np.nanmedian(lgr[m])) p = float(np.nanmedian(ph[m])) if ta is not None and ta.size == per.size: tt = float(np.nanmedian(ta[m])) else: tt = np.nan rows.append((nm, bi, r, p, tt)) # normalize per metric for color import pandas as pd df = pd.DataFrame( rows, columns=["station", "band", "logrho", "phi", "tamp"] ) # global min/max (robust) for color normalization def _rng(s: pd.Series) -> tuple[float, float]: v = s.to_numpy() v = v[np.isfinite(v)] if v.size == 0: return 0.0, 1.0 return (np.nanpercentile(v, 5), np.nanpercentile(v, 95)) r0, r1 = _rng(df["logrho"]) p0, p1 = _rng(df["phi"]) t0, t1 = _rng(df["tamp"]) def _norm(x, a, b): return np.clip((x - a) / (b - a + 1e-12), 0.0, 1.0) df["cr"] = _norm(df["logrho"], r0, r1) df["cp"] = _norm(df["phi"], p0, p1) df["ct"] = _norm(df["tamp"], t0, t1) # plot: 3 dots per band (circle, square, triangle) st = list(df["station"].unique()) st_map = {s: i for i, s in enumerate(st)} y0 = np.arange(len(st), dtype=float) if ax is None: _, ax = plt.subplots(figsize=figsize) for _, row in df.iterrows(): yi = st_map[row["station"]] xi = float(row["band"]) # offsets for 3 metrics yR = yi - 0.22 yP = yi + 0.00 yT = yi + 0.22 ax.scatter( [xi], [yR], s=marker_size, c=[[row["cr"]]], cmap="viridis", marker="o", vmin=0.0, vmax=1.0, ) ax.scatter( [xi], [yP], s=marker_size, c=[[row["cp"]]], cmap="viridis", marker="s", vmin=0.0, vmax=1.0, ) ax.scatter( [xi], [yT], s=marker_size, c=[[row["ct"]]], cmap="viridis", marker="^", vmin=0.0, vmax=1.0, ) ax.set_xlim(-0.5, len(bands) - 0.5) ax.set_ylim(-0.6, len(st) - 0.4) ax.set_xlabel("band") ax.set_ylabel("station") ax.set_yticks(y0) ax.set_yticklabels(st) # simple mini-legend using text markers ax.text(1.01, 0.85, "o log10 ρ_det", transform=ax.transAxes) ax.text(1.01, 0.70, "s φ_det", transform=ax.transAxes) ax.text(1.01, 0.55, "^ |T|", transform=ax.transAxes) return ax