Source code for pycsamt.site.compute

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0

from __future__ import annotations

import math
from collections.abc import Iterable
from typing import Any

import numpy as np
import pandas as pd

from ..api.view import maybe_wrap_frame
from ..constants import MU_0
from .utils import (
    get_freq,
    iter_edifiles,
    station_name,
)

__all__ = [
    "strike_estimate",
    "res_at_freq",
    "phase_slope",
    "tipper_magnitude",
]


[docs] def strike_estimate( obj: Any, *, method: str = "swift", api: bool | None = None, ) -> Any: r""" Estimate a strike angle from impedance tensors. This computes a 2D geoelectric strike angle in degrees. The routine supports either a single site or many sites. For a single site a scalar angle is returned. For multiple sites a ``pandas.DataFrame`` is returned with one row per site. Parameters ---------- obj : Any A single EDI-like object (e.g. ``EDIFile``) or an iterable of EDI-like objects. Each item must expose a ``.Z`` section of shape ``(n_freq, 2, 2)`` or be convertible to such. method : str, optional Strike method. Allowed values are: - ``"swift"`` (default): grid search over 0..179 degrees that minimizes the diagonal power after rotation. - ``"groom"``: alias of ``"swift"`` in this lightweight mode. - ``"phase_diff"``: heuristic that returns ``0`` or ``90`` degrees based on the relative magnitude of off-diagonals. Returns ------- float or pandas.DataFrame If ``obj`` is a single site, returns a float angle in degrees within ``[0, 180)``. If ``obj`` is iterable, returns a ``DataFrame`` with columns: ``station``, ``method``, ``theta_deg``. Notes ----- The Swift-style criterion rotates the impedance tensor :math:`Z` by a test angle :math:`\\theta` and minimizes .. math:: J(\\theta) = \lvert Z'_{xx} \rvert^2 + \lvert Z'_{yy} \rvert^2 , where :math:`Z'` is the rotated tensor. The returned angle is the argmin over a 1 degree grid in 0..179. The ``"phase_diff"`` fallback returns ``0`` if median :math:`\\lvert Z_{xy} \rvert \ge \lvert Z_{yx} \rvert`, else ``90``. It is intended for degraded or sparse data. This function does not alter data. If you need deterministic behavior for incomplete arrays, consider preparing tensors with :func:`pycsamt.site.edit.fill_missing`. Examples -------- Single site, Swift estimate: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site import compute as cmp, edit as ed >>> edf = EDIFile("S01.edi") # doctest: +SKIP >>> edf = ed.fill_missing(edf, how="zero", ... components=("Z",), inplace=False) >>> ang = cmp.strike_estimate(edf, method="swift") # doctest: +SKIP >>> 0.0 <= ang < 180.0 # doctest: +SKIP True Many sites, returning a DataFrame: >>> e1 = EDIFile("S01.edi") # doctest: +SKIP >>> e2 = EDIFile("S02.edi") # doctest: +SKIP >>> df = cmp.strike_estimate([e1, e2], method="phase_diff") ... # doctest: +SKIP >>> list(df.columns) # doctest: +SKIP ['station', 'method', 'theta_deg'] See Also -------- pycsamt.site.edit.rotate Rotate site tensors by a user angle. pycsamt.site.compute.phase_slope Phase slope diagnostic over a frequency band. References ---------- .. [1] Swift, C. M., 1967. A magnetotelluric investigation of an electrical conductivity anomaly in the southwestern United States. PhD thesis, MIT. .. [2] Groom, R. W., and R. C. Bailey, 1989. Decomposition of magnetotelluric impedance tensors in the presence of local three dimensional galvanic distortion. JGR. """ rows = [] for st, ed in _as_sites_iter(obj): Z = _get_z(ed) f = get_freq(ed) if Z is None or f is None or Z.ndim != 3 or Z.shape[1:] != (2, 2): ang = float("nan") else: m = (method or "swift").lower() if m in {"swift", "groom"}: ang = _swift_theta(Z) elif m == "phase_diff": ang = _phase_diff_theta(Z) else: ang = _swift_theta(Z) rows.append((st, method, ang)) if len(rows) == 1 and not isinstance(obj, Iterable): return rows[0][2] df = pd.DataFrame(rows, columns=["station", "method", "theta_deg"]) return maybe_wrap_frame( df, api=api, name="strike_estimate", kind="site.compute.strike", source=obj, description="Estimated geoelectric strike angle by station.", )
[docs] def res_at_freq( obj: Any, freq: float, *, how: str = "nearest", api: bool | None = None, ) -> Any: r""" Evaluate apparent resistivity at a target frequency. Computes apparent resistivity for the :math:`Z_{xy}` and :math:`Z_{yx}` components at a requested frequency. Works with a single site or a collection. For a single site, a dict is returned. For multiple sites, a ``pandas.DataFrame`` is returned. Parameters ---------- obj : Any A single EDI-like object (e.g. ``EDIFile``) or an iterable of such objects. Each item must expose a ``.Z`` section of shape ``(n_freq, 2, 2)`` and a frequency vector. freq : float Query frequency in Hz. how : str, optional Selection mode: - ``"nearest"`` (default): choose the nearest available frequency in the site data and report that value. - ``"interp"``: linearly interpolate resistivity versus frequency using ``numpy.interp``. Interpolation occurs on linear frequency, not log frequency. Returns ------- dict or pandas.DataFrame If ``obj`` is a single site, returns a dictionary with keys ``"res_xy"``, ``"res_yx"``, ``"f_used"``. If ``obj`` is iterable, returns a ``DataFrame`` with columns ``station``, ``res_xy``, ``res_yx``, ``f_used``. Notes ----- Apparent resistivity :math:`\\rho_a` is computed as .. math:: \rho_a = \frac{\lvert Z \rvert^2} {\mu_0\,2\pi\\,f} , where :math:`Z` is the complex impedance for the selected component, :math:`\\mu_0` is the magnetic permeability of free space, and :math:`f` is frequency in Hz. When ``how="interp"``, the function first computes :math:`\rho_a` at all native frequencies, then interpolates the result to the query frequency using linear interpolation in frequency. If the frequency vector or impedance is missing, ``NaN`` values are returned. Examples -------- Single site, nearest selection: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site import compute as cmp, edit as ed >>> edf = EDIFile("S01.edi") # doctest: +SKIP >>> edf = ed.fill_missing(edf, how="zero", ... components=("Z",), inplace=False) >>> out = cmp.res_at_freq(edf, 150.0, how="nearest") ... # doctest: +SKIP >>> set(out.keys()) == {"res_xy", "res_yx", "f_used"} ... # doctest: +SKIP True Single site, interpolated: >>> out = cmp.res_at_freq(edf, 150.0, how="interp") ... # doctest: +SKIP >>> out["f_used"] # doctest: +SKIP 150.0 Many sites, DataFrame: >>> e1 = EDIFile("S01.edi") # doctest: +SKIP >>> e2 = EDIFile("S02.edi") # doctest: +SKIP >>> df = cmp.res_at_freq([e1, e2], 1.0, how="interp") ... # doctest: +SKIP >>> list(df.columns) # doctest: +SKIP ['station', 'res_xy', 'res_yx', 'f_used'] See Also -------- pycsamt.site.compute.strike_estimate Estimate 2D strike angle from Z. pycsamt.site.edit.select_freq Subset site data by frequency criteria. References ---------- .. [1] Vozoff, K., 1991. The magnetotelluric method. In Electromagnetic methods in applied geophysics. .. [2] Simpson, F., and K. Bahr, 2005. Practical Magnetotellurics. Cambridge University Press. """ rows = [] fq = float(freq) for st, ed in _as_sites_iter(obj): f = get_freq(ed) Z = _get_z(ed) if f is None or Z is None: rows.append((st, np.nan, np.nan, np.nan)) continue if how.lower().startswith("near"): k = _nearest_idx(f, fq) fx = float(f[k]) zxy = Z[k, 0, 1] zyx = Z[k, 1, 0] rxy = _rho_from_z(zxy, fx) ryx = _rho_from_z(zyx, fx) rows.append((st, rxy, ryx, fx)) else: rxy, ryx = _rho_xy_yx(Z, f) rxyv = _interp(f, rxy, fq) ryxv = _interp(f, ryx, fq) rows.append((st, rxyv, ryxv, fq)) if len(rows) == 1 and not isinstance(obj, Iterable): _, rxy, ryx, fx = rows[0] return {"res_xy": rxy, "res_yx": ryx, "f_used": fx} df = pd.DataFrame(rows, columns=["station", "res_xy", "res_yx", "f_used"]) return maybe_wrap_frame( df, api=api, name="res_at_freq", kind="site.compute.resistivity", source=obj, meta={"freq": float(freq), "how": how}, description="Apparent resistivity evaluated at a target frequency.", )
[docs] def phase_slope( obj: Any, band: tuple[float, float], *, api: bool | None = None, ) -> Any: r""" Estimate phase slopes within a frequency band. For each site, this computes the least-squares slope of phase (degrees) versus :math:`\log_{10}(f)` over the requested band. Two slopes are reported, one for :math:`Z_{xy}` and one for :math:`Z_{yx}`. If a single site is provided, a dictionary is returned. If an iterable of sites is provided, a ``pandas.DataFrame`` is returned with one row per station. Parameters ---------- obj : Any A single EDI-like object (e.g. ``EDIFile``) or an iterable of such objects. band : tuple of float Inclusive frequency band as ``(fmin, fmax)`` in Hz. The order does not matter; the function uses the numeric min and max. Returns ------- dict or pandas.DataFrame Single site -> ``{"slope_xy": float, "slope_yx": float}``. Multi-site -> DataFrame with columns ``["station", "slope_xy", "slope_yx"]``. Notes ----- The phase series for each off-diagonal component is computed as .. math:: \phi(f) = \operatorname{angle}(Z(f)) \times 180/\\pi , then a straight line is fit .. math:: \phi(f) \approx a\\,\\log_{10}(f) + b using ``numpy.polyfit(x, y, 1)`` where :math:`x=\\log_{10}(f)`. The reported slope is :math:`a` in units of degrees per decade. Rows or sites with missing data in the band are reported as ``NaN``. The function does not unwrap phase. Examples -------- Single site: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site import compute as cmp, edit as ed >>> edf = EDIFile("S01.edi") # doctest: +SKIP >>> edf = ed.fill_missing(edf, how="zero", ... components=("Z",), inplace=False) >>> out = cmp.phase_slope(edf, band=(1.0, 1000.0)) ... # doctest: +SKIP >>> set(out.keys()) == {"slope_xy", "slope_yx"} ... # doctest: +SKIP True Many sites: >>> e1 = EDIFile("S01.edi") # doctest: +SKIP >>> e2 = EDIFile("S02.edi") # doctest: +SKIP >>> df = cmp.phase_slope([e1, e2], band=(0.1, 10.0)) ... # doctest: +SKIP >>> list(df.columns) # doctest: +SKIP ['station', 'slope_xy', 'slope_yx'] See Also -------- pycsamt.site.compute.strike_estimate Strike angle by Swift-style criterion. pycsamt.site.compute.res_at_freq Apparent resistivity at a target frequency. References ---------- .. [1] Simpson, F., and K. Bahr, 2005. Practical Magnetotellurics. Cambridge University Press. .. [2] Vozoff, K., 1991. The magnetotelluric method. In Electromagnetic methods in applied geophysics. """ fmin, fmax = float(band[0]), float(band[1]) rows = [] for st, ed in _as_sites_iter(obj): f = get_freq(ed) Z = _get_z(ed) if f is None or Z is None: rows.append((st, np.nan, np.nan)) continue m = (f >= min(fmin, fmax)) & (f <= max(fmin, fmax)) if not np.any(m): rows.append((st, np.nan, np.nan)) continue xf = np.log10(f[m]) ph_xy = _phase_deg(Z[m, 0, 1]) ph_yx = _phase_deg(Z[m, 1, 0]) try: sx = float(np.polyfit(xf, ph_xy, 1)[0]) except Exception: sx = float("nan") try: sy = float(np.polyfit(xf, ph_yx, 1)[0]) except Exception: sy = float("nan") rows.append((st, sx, sy)) if len(rows) == 1 and not isinstance(obj, Iterable): _, sx, sy = rows[0] return {"slope_xy": sx, "slope_yx": sy} df = pd.DataFrame(rows, columns=["station", "slope_xy", "slope_yx"]) return maybe_wrap_frame( df, api=api, name="phase_slope", kind="site.compute.phase_slope", source=obj, meta={"band": tuple(float(v) for v in band)}, description="Phase slope diagnostics by station.", )
[docs] def tipper_magnitude( obj: Any, *, per_freq: bool = False, api: bool | None = None, ) -> Any: r""" Summarize or tabulate tipper magnitudes. Computes the magnitude of the tipper vector per frequency as .. math:: \lVert \mathbf{T} \rVert = \sqrt{\lvert T_x \rvert^2 + \lvert T_y \rvert^2} , where :math:`T_x, T_y` are the complex tipper components. The result can be returned as per-frequency values or summarized statistics. For a single site, returns a dict. For an iterable of sites, returns a ``pandas.DataFrame``. Parameters ---------- obj : Any A single EDI-like object (e.g. ``EDIFile``) or an iterable of such objects. The tipper may be attached as ``ed.Tip``, ``ed.T``, or ``ed.TIP`` and must expose a 2-component array shaped ``(n_freq, 2)`` or ``(n_freq, 1, 2)``. per_freq : bool, optional If ``False`` (default), return summary statistics (mean, median, max). If ``True``, return per-frequency values. Returns ------- dict or pandas.DataFrame Single site: * ``per_freq=False`` -> ``{"mean", "median", "max"}`` * ``per_freq=True`` -> ``{"freq", "mag"}`` Multi-site: * ``per_freq=False`` -> DataFrame with columns ``["station", "mean", "median", "max"]`` * ``per_freq=True`` -> DataFrame with columns ``["station", "freq", "mag"]`` Notes ----- If the site has no tipper section, summary statistics are ``NaN`` and per-frequency mode yields an empty result for that site. To initialize missing arrays, consider :func:`pycsamt.site.edit.fill_missing` with ``components=("Tip",)``. Frequencies are reported from the site frequency vector. The function assumes the tipper array and frequency vector are aligned along their first dimension. Examples -------- Single site, summary stats: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site import compute as cmp, edit as ed >>> edf = EDIFile("S01.edi") # doctest: +SKIP >>> edf = ed.fill_missing(edf, how="zero", ... components=("Tip",), inplace=False) >>> s = cmp.tipper_magnitude(edf, per_freq=False) ... # doctest: +SKIP >>> set(s.keys()) == {"mean", "median", "max"} ... # doctest: +SKIP True Single site, per-frequency: >>> out = cmp.tipper_magnitude(edf, per_freq=True) ... # doctest: +SKIP >>> list(out.keys()) # doctest: +SKIP ['freq', 'mag'] Many sites, summary: >>> e1 = EDIFile("S01.edi") # doctest: +SKIP >>> e2 = EDIFile("S02.edi") # doctest: +SKIP >>> df = cmp.tipper_magnitude([e1, e2], per_freq=False) ... # doctest: +SKIP >>> list(df.columns) # doctest: +SKIP ['station', 'mean', 'median', 'max'] See Also -------- pycsamt.site.edit.fill_missing Initialize or sanitize Z/Tip arrays in a site. pycsamt.site.compute.res_at_freq Apparent resistivity at a target frequency. References ---------- .. [1] Simpson, F., and K. Bahr, 2005. Practical Magnetotellurics. Cambridge University Press. """ rows = [] long_rows = [] for st, ed in _as_sites_iter(obj): f = get_freq(ed) T = _tip_arr(ed) if f is None or T is None: if per_freq: continue rows.append((st, np.nan, np.nan, np.nan)) continue mag = np.sqrt(np.sum(np.abs(T) ** 2, axis=-1)) if per_freq: for ff, mm in zip(f, mag): long_rows.append((st, float(ff), float(mm))) else: rows.append( ( st, float(np.nanmean(mag)), float(np.nanmedian(mag)), float(np.nanmax(mag)), ) ) if not isinstance(obj, Iterable): if per_freq: if not long_rows: return { "freq": np.array([]), "mag": np.array([]), } fa = np.array([r[1] for r in long_rows], float) ma = np.array([r[2] for r in long_rows], float) return {"freq": fa, "mag": ma} if not rows: return { "mean": np.nan, "median": np.nan, "max": np.nan, } _, m, md, mx = rows[0] return {"mean": m, "median": md, "max": mx} if per_freq: df = pd.DataFrame(long_rows, columns=["station", "freq", "mag"]) return maybe_wrap_frame( df, api=api, name="tipper_magnitude", kind="site.compute.tipper", source=obj, meta={"per_freq": True}, description="Per-frequency tipper magnitude by station.", ) df = pd.DataFrame(rows, columns=["station", "mean", "median", "max"]) return maybe_wrap_frame( df, api=api, name="tipper_magnitude", kind="site.compute.tipper", source=obj, meta={"per_freq": False}, description="Tipper magnitude summary by station.", )
# Local helpers def _get_z(ed: Any) -> np.ndarray | None: """ Return Z as ndarray of shape (n, 2, 2) or None. Looks under ed.Z.{z, impedance, _z}. Tolerates missing. """ Z = getattr(ed, "Z", None) if Z is None: return None for nm in ("z", "impedance", "_z"): try: a = getattr(Z, nm) except Exception: a = None if a is None: continue arr = np.asarray(a) if arr.ndim == 3 and arr.shape[-2:] == (2, 2): return arr return None def _as_sites_iter(obj: Any) -> Iterable[tuple[str, Any]]: if hasattr(obj, "as_list"): for ed in obj.as_list(): yield station_name(ed), ed return for ed in iter_edifiles(obj): yield station_name(ed), ed def _rho_from_z(z: complex, f: float) -> float: if not np.isfinite(f) or f <= 0: return np.nan return (abs(z) ** 2) / (MU_0 * 2.0 * math.pi * f) def _rho_xy_yx(Z: np.ndarray, f: np.ndarray) -> tuple: zxy = Z[..., 0, 1] zyx = Z[..., 1, 0] rho_xy = np.array( [_rho_from_z(z, ff) for z, ff in zip(zxy, f)], float, ) rho_yx = np.array( [_rho_from_z(z, ff) for z, ff in zip(zyx, f)], float, ) return rho_xy, rho_yx def _phase_deg(a: np.ndarray) -> np.ndarray: return np.degrees(np.angle(a)) def _nearest_idx(f: np.ndarray, fx: float) -> int: return int(np.argmin(np.abs(f - fx))) def _interp(x: np.ndarray, y: np.ndarray, xq: float) -> float: try: return float(np.interp(xq, x, y)) except Exception: return float("nan") def _rotmat(theta_deg: float) -> np.ndarray: t = math.radians(theta_deg) c, s = math.cos(t), math.sin(t) return np.array([[c, s], [-s, c]], float) def _rotate_tensor(Z: np.ndarray, theta: float) -> np.ndarray: R = _rotmat(theta) RT = R.T return np.einsum("ab,nbc,cd->nad", R, Z, RT) def _swift_cost(Z: np.ndarray, theta: float) -> float: Zr = _rotate_tensor(Z, theta) d = np.abs(Zr[:, 0, 0]) ** 2 + np.abs(Zr[:, 1, 1]) ** 2 if d.size == 0: return float("inf") return float(np.nanmedian(d)) def _swift_theta(Z: np.ndarray) -> float: thetas = np.arange(0.0, 180.0, 1.0, float) costs = np.array([_swift_cost(Z, th) for th in thetas], float) if not np.isfinite(costs).any(): return float("nan") k = int(np.nanargmin(costs)) return float(thetas[k]) def _phase_diff_theta(Z: np.ndarray) -> float: zxy = np.nanmedian(np.abs(Z[:, 0, 1])) zyx = np.nanmedian(np.abs(Z[:, 1, 0])) if not np.isfinite(zxy + zyx): return float("nan") return 0.0 if zxy >= zyx else 90.0 def _tip_arr(ed: Any) -> np.ndarray | None: """ Return tipper array with shape (n, 2) or None. Checks ed.T, ed.TIP, ed.Tip, and ed.Z.tipper. """ tip_obj = None for cand in ("T", "TIP", "Tip"): tip_obj = getattr(ed, cand, None) if tip_obj is not None: break if tip_obj is not None: for nm in ("tipper", "_tipper"): try: a = getattr(tip_obj, nm) except Exception: a = None if a is not None: arr = np.asarray(a) if arr.ndim == 2 and arr.shape[1] == 2: return arr if arr.ndim == 3 and arr.shape[1:] == (1, 2): return arr[:, 0, :] if arr.ndim == 1: return arr[..., None] return None Z = getattr(ed, "Z", None) if Z is not None: for nm in ("tipper", "tip", "_tipper"): try: a = getattr(Z, nm) except Exception: a = None if a is None: continue arr = np.asarray(a) if arr.ndim == 2 and arr.shape[1] == 2: return arr if arr.ndim == 3 and arr.shape[1:] == (1, 2): return arr[:, 0, :] if arr.ndim == 1: return arr[..., None] return None