Source code for pycsamt.core.base

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

"""Foundational objects and transfer-function containers for pyCSAMT."""

from __future__ import annotations

from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field, is_dataclass
from dataclasses import fields as dc_fields
from typing import (
    Any,
    Protocol,
)

import numpy as np

from ..api.property import PyCSAMTObject
from . import config as _core_config
from .config import StationNamePolicy, get_config

__all__ = [
    "TFBundle",
    "SupportsToBundle",
    "SupportsFromBundle",
    "ensure_station",
    "pick_adapter_key",
    "to_edi",
]


class CoreObject(PyCSAMTObject):
    r"""
    Minimal base class for PyCSAMT objects and mixins.

    The goal is to provide stable, readable ``__repr__`` and
    ``__str__`` implementations, plus a couple of helpers for
    light introspection and serialization that do *not* pull
    heavy dependencies.

    Notes
    -----
    * ``__repr__`` shows a compact field summary. Large arrays
      or containers are summarized safely (shape/len only).
    * Subclasses may override :meth:`_repr_fields` to control
      which attributes appear in the summary.
    * :meth:`as_dict` is shallow by default and avoids importing
      external libs. It aims to be robust, not exhaustive.

    Examples
    --------
    >>> class P(CoreObject):
    ...     def __init__(self):
    ...         self.name = "S001"
    ...         self.data = [1, 2, 3]
    ...
    >>> P()  # doctest: +ELLIPSIS
    P(name='S001', data=[len=3])

    See Also
    --------
    _repr_fields : Hook to pick fields for ``__repr__``.
    as_dict : Lightweight serialization helper.
    summary : Short single-line description.
    """

    def __repr__(self) -> str:  # noqa: D401
        name = self.__class__.__name__
        items = []
        for k in self._repr_fields():
            try:
                v = getattr(self, k)
            except Exception:  # pragma: no cover
                continue
            items.append(f"{k}={self._short(v)}")
        inside = ", ".join(items)
        return f"{name}({inside})"

    def __str__(self) -> str:  # noqa: D401
        return self.summary()

    def summary(self, *, max_fields: int = 6) -> str:
        r"""
        Return a short one-line description.

        Parameters
        ----------
        max_fields : int, optional
            Maximum number of fields to include.

        Returns
        -------
        str
            A compact summary string.
        """
        name = self.__class__.__name__
        fs = list(self._repr_fields())[:max_fields]
        items = []
        for k in fs:
            try:
                v = getattr(self, k)
            except Exception:
                continue
            items.append(f"{k}={self._short(v)}")
        more = ""
        if len(list(self._repr_fields())) > max_fields:
            more = ", ..."
        return f"{name}({', '.join(items)}{more})"

    def as_dict(
        self,
        *,
        public_only: bool = True,
        max_depth: int = 1,
    ) -> dict:
        r"""
        Serialize to a lightweight ``dict`` safely.

        Parameters
        ----------
        public_only : bool, optional
            If ``True``, include only public attributes (no
            leading underscore). Dataclasses always use their
            declared fields.
        max_depth : int, optional
            Maximum recursion depth for nested mappings and
            sequences. Use small values to avoid deep walks.

        Returns
        -------
        dict
            A JSON-friendly mapping (best-effort).
        """
        return self._to_dict(self, public_only=public_only, depth=max_depth)

    def _repr_fields(self) -> Iterable[str]:
        r"""
        Select attribute names to show in ``__repr__``.

        Returns
        -------
        Iterable[str]
            Field names to display. By default:
            * dataclasses → declared field names
            * objects     → public attributes in ``__dict__``
        """
        exclude = set(getattr(self, "__repr_exclude__", set()))
        if is_dataclass(self):
            return (f.name for f in dc_fields(self) if f.name not in exclude)
        if hasattr(self, "__dict__"):
            return (
                k
                for k in self.__dict__.keys()
                if not k.startswith("_") and k not in exclude
            )
        return ()  # pragma: no cover

    @staticmethod
    def _short(v: object) -> str:
        """Summarize values safely for ``__repr__``."""
        # primitives
        if v is None or isinstance(v, (bool, int, float, complex)):
            return repr(v)
        if isinstance(v, str):
            if len(v) > 32:
                return repr(v[:29] + "…")
            return repr(v)
        # numpy arrays (optional)
        try:  # pragma: no cover for environments w/o numpy
            import numpy as _np  # local import, no hard dep

            if isinstance(v, _np.ndarray):
                return f"ndarray(shape={tuple(v.shape)}, dtype={v.dtype})"
        except Exception:
            pass
        # mappings
        if isinstance(v, Mapping):
            keys = list(v.keys())
            head = ", ".join(map(str, keys[:3]))
            if len(keys) > 3:
                head += ", ..."
            return f"dict(len={len(keys)}, keys=[{head}])"
        # sequences (but not strings/bytes)
        if isinstance(v, (bytes, bytearray)):
            return f"bytes(len={len(v)})"
        if isinstance(v, (list, tuple, set)):
            n = len(v)
            # sample a few items, summarized
            sample = ", ".join(CoreObject._short(x) for x in list(v)[:3])
            if n > 3:
                sample += ", ..."
            typ = type(v).__name__
            return f"{typ}([{sample}])" if n else f"{typ}([])"
        # objects exposing shape/dtype
        try:
            shp = getattr(v, "shape", None)
            dt = getattr(v, "dtype", None)
            if shp is not None:
                return f"array_like(shape={shp}, dtype={dt})"
        except Exception:
            pass
        # fallback
        return repr(v)

    @classmethod
    def _to_dict(
        cls,
        obj: object,
        *,
        public_only: bool,
        depth: int,
    ) -> dict:
        """Shallow, safe dict conversion."""
        if depth < 0:
            return {"_": "max_depth"}
        out: dict = {}
        # dataclass
        if is_dataclass(obj):
            for f in dc_fields(obj):  # type: ignore
                k = f.name
                v = getattr(obj, k, None)
                out[k] = cls._coerce(v, public_only, depth - 1)
            return out
        # generic object with __dict__
        if hasattr(obj, "__dict__"):
            for k, v in obj.__dict__.items():
                if public_only and k.startswith("_"):
                    continue
                out[k] = cls._coerce(v, public_only, depth - 1)
            return out
        # fallback: empty
        return out

    @classmethod
    def _coerce(
        cls,
        v: object,
        public_only: bool,
        depth: int,
    ):
        """Coerce nested values for :meth:`as_dict`."""
        # primitives
        if v is None or isinstance(v, (bool, int, float, complex, str)):
            return v
        # numpy arrays → summary dict (no heavy stats)
        try:  # pragma: no cover
            import numpy as _np

            if isinstance(v, _np.ndarray):
                return {
                    "type": "ndarray",
                    "shape": tuple(v.shape),
                    "dtype": str(v.dtype),
                }
        except Exception:
            pass
        # mapping
        if isinstance(v, Mapping):
            out = {}
            for k, x in list(v.items())[:32]:
                out[str(k)] = cls._coerce(x, public_only, depth - 1)
            return out
        # sequence (not bytes)
        if isinstance(v, (list, tuple, set)):
            head = list(v)[:32]
            return [cls._coerce(x, public_only, depth - 1) for x in head]
        # object → nested dict (one level)
        return cls._to_dict(v, public_only=public_only, depth=depth - 1)


[docs] @dataclass class TFBundle(CoreObject): r""" Lightweight, neutral payload for transfer functions. The bundle holds frequency-indexed arrays and optional site metadata. It is intentionally permissive about array types (e.g., lists or NumPy arrays) so that backends can populate and consume it without hard dependencies. Parameters ---------- freq : array_like or None Frequency vector, length ``n``. z : array_like or None Impedance tensor, shape ``(n, 2, 2)`` (complex). z_err : array_like or None Errors for ``z``, shape ``(n, 2, 2)`` (float). tipper : array_like or None Magnetic tipper. Common shapes are ``(n, 2)`` or ``(n, 1, 2)``; callers may normalize as needed. tipper_err : array_like or None Errors for the tipper, shape like ``tipper``. rho : array_like or None Apparent resistivity, length ``n`` (float). phase : array_like or None Phase, length ``n``. Units are backend-specific; the Zonge path often stores milliradians. station : str or None Preferred station/site name. station_id : str, int or None Identifier used to synthesize a name when missing. lat, lon, elev : float or None Optional geographic location and elevation. azimuth : float or None Sensor azimuth or site orientation (degrees). attrs : dict Free-form attributes that accompany the data. Notes ----- Only one of ``z`` or ``(rho, phase)`` is required for most workflows. Transformers may fill the missing part using package settings (see :mod:`pycsamt.core.config`). Examples -------- >>> from pycsamt.core.base import TFBundle >>> TFBundle(freq=[1.0], z=[[ [0+1j, 0], [0, 0+1j] ]], ... station=\"S001\") TFBundle(...) """ # neutral payload used across backends freq: Any | None = None z: Any | None = None z_err: Any | None = None tipper: Any | None = None tipper_err: Any | None = None rho: Any | None = None phase: Any | None = None station: str | None = None station_id: str | int | None = None lat: float | None = None lon: float | None = None elev: float | None = None azimuth: float | None = None attrs: dict[str, Any] = field(default_factory=dict)
[docs] def is_empty(self) -> bool: r""" Return True if there is no usable TF content. A bundle is considered empty when neither ``z`` nor the pair ``(rho, phase)`` is present. Returns ------- bool ``True`` if empty, ``False`` otherwise. Examples -------- >>> TFBundle().is_empty() True >>> TFBundle(rho=[100], phase=[45]).is_empty() False """ have_z = self.z is not None have_rp = (self.rho is not None) and (self.phase is not None) return not (have_z or have_rp)
[docs] class SupportsToBundle(Protocol): r""" Protocol for objects that can export a :class:`TFBundle`. Any class implementing: ``to_bundle(self) -> TFBundle`` is considered compatible. This keeps interop light while avoiding a hard dependency on specific backends. """
[docs] def to_bundle(self) -> TFBundle: ... # noqa: E701
[docs] class SupportsFromBundle(Protocol): r""" Protocol for classes that can be built from a :class:`TFBundle`. Any class implementing the classmethod: ``from_bundle(cls, bundle: TFBundle) -> Any`` is considered compatible. This is commonly used by test doubles or thin wrappers in frontends. """
[docs] @classmethod def from_bundle(cls, bundle: TFBundle): ... # noqa: E701
[docs] def ensure_station( name: str | None, station_id: str | int | None, *, policy: StationNamePolicy | None = None, ) -> str: r""" Return a valid station name using policy rules. Validation is performed by the active :class:`~pycsamt.core.config.StationNamePolicy`. If the given ``name`` is invalid or missing, a synthetic one is derived from ``station_id``. Parameters ---------- name : str or None Preferred station/site name. station_id : str, int or None Identifier used to synthesize a fallback name. policy : StationNamePolicy, optional Custom policy. Defaults to the global policy from :func:`~pycsamt.core.config.get_config`. Returns ------- str Validated or synthesized station name. See Also -------- pycsamt.core.config.StationNamePolicy Normalization, synthesis and limits. Examples -------- >>> ensure_station(None, 7) 'S007' """ pol = policy or get_config().station_policy return pol.ensure(name, station_id)
[docs] def pick_adapter_key( obj: Any, *, hint: str | None = None, ) -> str | None: r""" Infer an adapter key (``'avg'``, ``'j'``, ``'edi'``) from an object. Heuristics look at the object's module and class name. If a ``hint`` is provided, it is returned verbatim (lower-cased). Parameters ---------- obj : Any Source object whose kind should be inferred. hint : str, optional Explicit override for the key. Returns ------- str or None Inferred key or ``None`` if it cannot be decided. Notes ----- This function does not validate that an adapter exists for the returned key. Use :func:`to_edi` to dispatch safely. Examples -------- >>> class X: pass >>> X.__module__ = 'pycsamt.zonge.avg' >>> pick_adapter_key(X()) # zonge path 'avg' """ if hint: return hint.lower() try: mod = obj.__class__.__module__.lower() cls = obj.__class__.__name__.lower() except Exception: return None if ("zonge" in mod) or ("avg" in cls): return "avg" if ("jones" in mod) or (cls in {"jfile", "jcollection"}): return "j" if ("seg" in mod) and ("edi" in (mod + cls)): return "edi" return None
[docs] def to_edi( source: Any, *, key: str | None = None, **kwargs: Any, ) -> Any: r""" Dispatch ``source`` to a registered adapter and return EDI. This function consults the adapter registry managed by :mod:`pycsamt.core.config`. It attempts to infer the adapter key when not provided and calls the associated factory. Parameters ---------- source : Any The object to convert (AVG, Jones, etc.). key : str, optional Adapter key (e.g., ``'avg'``, ``'j'``, ``'edi'``). If omitted, :func:`pick_adapter_key` is used. **kwargs : Any Extra keyword arguments forwarded to the adapter. Returns ------- Any An EDI object or an EDI collection, depending on the adapter. Raises ------ RuntimeError If the key cannot be inferred or no adapter is registered for it. See Also -------- pycsamt.core.config.register_adapter Register new adapters at runtime. pick_adapter_key Lightweight key inference from object metadata. Examples -------- Register a trivial adapter and convert an object: >>> from pycsamt.core.config import register_adapter >>> class Dummy: pass >>> Dummy.__module__ = 'pycsamt.zonge.avg' >>> register_adapter('avg', lambda src, **k: {'edi': True}) >>> to_edi(Dummy()) {'edi': True} """ k = key or pick_adapter_key(source) if not k: raise RuntimeError("Cannot infer adapter key") factory = _core_config.get_adapter(k) if not factory: raise RuntimeError(f"No adapter registered for: {k}") return factory(source, **kwargs)
class MTBase(CoreObject): r""" Common electromagnetic and MT utilities. This mixin-like base provides numerically safe, vectorized helpers built on NumPy. All methods accept array-like inputs and return NumPy arrays with broadcast semantics. Notes ----- Shapes are preserved and operations are element-wise unless stated otherwise. For impedance tensors shaped ``(..., 2, 2)``, "determinant" operations collapse the last two axes. **Unit conventions.** Two equivalent formulas are common for apparent resistivity from impedance magnitude ``|Z|``: * **SI (E in V/m, H in A/m):** ``ρₐ = |Z|² / (μ₀·ω) = |Z|² · RHO_FACTOR / f``, with ``ω = 2π f`` and ``RHO_FACTOR = 1/(μ₀·2π)``. * **Field units (E in mV/km, B in nT):** ``ρₐ ≈ (0.2 / f) · |E/H|²`` — a legacy Zonge-style form. This is reflected by :data:`ZONGE_RHO_FACTOR`, i.e. ``0.2``. Be consistent. Converting mV/km → V/m and nT → T, and B → H via ``H = B/μ₀``, recovers the SI formulation. Constants --------- MU0 : float Magnetic permeability of free space (H/m). ``4π × 10⁻⁷``. EPS0 : float Electric permittivity of free space (F/m). ``8.854187817 × 10⁻¹²``. C : float Speed of light in vacuum (m/s). From CODATA. C0 : float ``1/√(μ₀·ε₀)`` (m/s). Equal to :data:`C` by definition. ETA0 : float Wave impedance of free space (Ω). ``√(μ₀/ε₀)``. TWO_PI : float ``2π``. Handy for ``ω = 2π f`` conversions. DEG2RAD : float Degrees to radians. ``π/180``. RAD2DEG : float Radians to degrees. ``180/π``. RHO_FACTOR : float ``1/(μ₀·2π)``. For ``ρₐ = |Z|² · RHO_FACTOR / f`` (SI). ZONGE_RHO_FACTOR : float ``0.2``. Legacy factor in ``ρₐ ≈ (0.2/f)·|E/H|²`` when using E in mV/km and B/H in nT without explicit SI conversion in the formula. B_TO_H : float Conversion factor from magnetic flux density to field intensity (A/m per Tesla). ``1/μ₀``. # Unit scales (field practice) MICROVOLTS_TO_VOLTS : float ``1e-6``. PICOTESLA_TO_TESLA : float ``1e-12``. NANOTESLA_TO_TESLA : float ``1e-9``. MV_PER_KM_TO_V_PER_M : float ``1e-6``. Since ``1 mV/km = 1e-3 V / 1e3 m``. METERS_TO_KILOMETERS : float ``1e-3``. PERCENT_FACTOR : float ``100.0``. For percent amplitudes or coherence. Z_UNIT_MVK_NT_TO_SI : float ``(1e-6) / (1e-9) = 1e3``. Converts ``Z`` expressed as ``(mV/km)/nT`` into SI-consistent ``(V/m)/T``. See Also -------- rho_phase_from_z : Apparent resistivity and phase from ``Z``. z_from_rho_phase : Build ``Z`` magnitude/phase from ``ρ, φ``. determinant_z : Rotation-invariant impedance. rho_phase_from_det : Invariants from determinant impedance. rotate_impedance : Rotate 2×2 impedance tensors. skin_depth : Diffusive skin depth. tipper_amp_phase : Amplitude and phase of tipper vector. Examples -------- Compute SI apparent resistivity from ``|Z|`` and frequency:: >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> Zmag = np.asarray([5.0, 10.0]) # |Z| in ohms >>> f = np.asarray([1.0, 0.1]) # Hz >>> rho_a = (Zmag**2) * MTBase.RHO_FACTOR / f >>> rho_a.shape (2,) Convert field units to SI and compare formulas:: >>> E_mVkm = 100.0 # mV/km >>> B_nT = 50.0 # nT (≈ μ0·H if not converted) >>> Z_field = (E_mVkm * MTBase.MV_PER_KM_TO_V_PER_M) / \ ... (B_nT * MTBase.NANOTESLA_TO_TESLA) >>> Z_si_like = Z_field # (V/m)/T >>> f = 1.0 # Hz >>> rho_si = (Z_si_like**2) * MTBase.RHO_FACTOR / f >>> rho_zonge = (MTBase.ZONGE_RHO_FACTOR / f) * (E_mVkm / B_nT)**2 >>> rho_si > 0 and rho_zonge > 0 True Rotate an impedance tensor by a site azimuth:: >>> Z = np.zeros((3, 2, 2), dtype=complex) # demo only >>> phi = 30.0 * MTBase.DEG2RAD >>> # rotate_impedance would apply R(φ) Z Rᵀ(φ) >>> # Z_rot = MTBase.rotate_impedance(Z, phi) # hypothetical References ---------- .. [1] Chave & Jones (2012). *The Magnetotelluric Method*. CUP. .. [2] Simpson & Bahr (2005). *Practical MT*. Cambridge Univ. .. [3] Wait (1982). *Geo-Electromagnetism*. Academic Press. .. [4] Constable (2016). *Scaling relations in EM*. SEG Notes. """ # --- Fundamental EM constants (CODATA) ----------------------------- MU0: float = 4.0e-7 * float(np.pi) # H/m (μ0) EPS0: float = 8.854_187_817e-12 # F/m (ε0) C: float = 299_792_458.0 # m/s (speed of light) # Derived free-space constants C0: float = 1.0 / np.sqrt(MU0 * EPS0) # m/s (= C by definition) ETA0: float = np.sqrt(MU0 / EPS0) # Ω (wave impedance ~376.73 Ω) # Handy math constants TWO_PI: float = 2.0 * float(np.pi) # 2π DEG2RAD: float = float(np.pi) / 180.0 # deg → rad RAD2DEG: float = 180.0 / float(np.pi) # rad → deg # Apparent resistivity factors # ρa = |Z|² / (μ0·ω) = |Z|² * [1/(μ0·2π)] / f (SI form) RHO_FACTOR: float = 1.0 / (MU0 * 2.0 * float(np.pi)) # SI factor # Zonge field-units convention (E in mV/km, B or H in nT): # ρa ≈ (0.2 / f) * |E/H|² → 0.2 = 1/5 ZONGE_RHO_FACTOR: float = 0.2 # legacy factor # --- Unit conversions ------------------------------------------------ # Voltage MICROVOLTS_TO_VOLTS: float = 1e-6 # μV → V MILLIVOLTS_TO_VOLTS: float = 1e-3 # mV → V # Electric field MV_PER_KM_TO_V_PER_M: float = 1e-6 # (mV/km) → (V/m) # Magnetic field / flux density PICOTESLA_TO_TESLA: float = 1e-12 # pT → T NANOTESLA_TO_TESLA: float = 1e-9 # nT → T MICROTESLA_TO_TESLA: float = 1e-6 # μT → T H_TO_B: float = MU0 # H (A/m) → B (T): B = μ0·H B_TO_H: float = 1.0 / MU0 # B (T) → H (A/m): H = B/μ0 # Length METERS_TO_KILOMETERS: float = 1e-3 # m → km KILOMETERS_TO_METERS: float = 1e3 # km → m # Percent scaling PERCENT_FACTOR: float = 100.0 # ×100 for % # Mixed-unit convenience: # Convert Z in (mV/km)/nT → (V/m)/T (SI-consistent) # factor = (mV/km→V/m) / (nT→T) = 1e-6 / 1e-9 = 1e3 Z_UNIT_MVK_NT_TO_SI: float = 1e3 # (mV/km)/nT → (V/m)/T @staticmethod def _as_c(x: object) -> np.ndarray: return np.asarray(x, dtype=np.complex128) @staticmethod def _as_f(x: object) -> np.ndarray: return np.asarray(x, dtype=np.float64) @staticmethod def _ang( z: np.ndarray, *, unit: str = "deg", ) -> np.ndarray: phi = np.angle(z) if unit == "deg": return np.degrees(phi) if unit == "mrad": return phi * 1.0e3 return phi # "rad" @staticmethod def _to_rad( phi: object, *, unit: str, ) -> np.ndarray: p = np.asarray(phi, dtype=np.float64) if unit == "deg": return np.radians(p) if unit == "mrad": return p * 1.0e-3 return p # "rad" @staticmethod def omega(f: object) -> np.ndarray: r""" Angular frequency ``ω = 2π f``. """ ff = np.asarray(f, dtype=np.float64) return 2.0 * float(np.pi) * ff # Z ↔ ρa, φ def rho_phase_from_z( self, z: object, f: object, *, phase_unit: str = "deg", ) -> tuple[np.ndarray, np.ndarray]: r""" Apparent resistivity and phase from impedance. Parameters ---------- z : array_like Impedance, scalar or tensor ``(..., 2, 2)``. f : array_like Frequency (Hz), broadcastable with ``z``. phase_unit : {'deg', 'rad', 'mrad'}, optional Output unit for phase. Default is degrees. Returns ------- rho : ndarray Apparent resistivity (Ω·m), element-wise. phi : ndarray Phase in requested unit, element-wise. Notes ----- Uses ``ρa = |Z|^2 / (μ0 ω)`` per component. For tensor invariants see :meth:`rho_phase_from_det`. """ zz = self._as_c(z) w = self.omega(f) rho = (np.abs(zz) ** 2) / (self.MU0 * w) phi = self._ang(zz, unit=phase_unit) return rho, phi def z_from_rho_phase( self, rho: object, phi: object, f: object, *, phase_unit: str = "deg", ) -> np.ndarray: r""" Build impedance magnitude/phase from ``ρa`` and ``φ``. Parameters ---------- rho : array_like Apparent resistivity (Ω·m). phi : array_like Phase in ``phase_unit``. f : array_like Frequency (Hz). phase_unit : {'deg', 'rad', 'mrad'}, optional Unit of ``phi``. Default is degrees. Returns ------- Z : ndarray Complex impedance with ``|Z| = sqrt(μ0 ω ρa)`` and angle = ``φ``. Shape follows broadcasting rules. """ w = self.omega(f) amp = np.sqrt(self.MU0 * w * self._as_f(rho)) ang = self._to_rad(phi, unit=phase_unit) return amp * (np.cos(ang) + 1j * np.sin(ang)) # determinant invariant @staticmethod def determinant_z(z: object) -> np.ndarray: r""" Determinant impedance ``Z_det = sqrt(-Z_xy Z_yx)``. Parameters ---------- z : array_like, shape (..., 2, 2) Impedance tensor(s). Returns ------- Z_det : ndarray, shape (...) Rotation-invariant complex impedance. Notes ----- The minus sign assumes the common MT sign convention. """ zz = np.asarray(z, dtype=complex) zxy = zz[..., 0, 1] zyx = zz[..., 1, 0] return np.sqrt(-zxy * zyx) def rho_phase_from_det( self, z: object, f: object, *, phase_unit: str = "deg", ) -> tuple[np.ndarray, np.ndarray]: r""" Invariant apparent resistivity and phase from ``Z``. Parameters ---------- z : array_like, shape (..., 2, 2) Impedance tensor(s). f : array_like Frequency (Hz). phase_unit : {'deg', 'rad', 'mrad'}, optional Output unit for phase. Returns ------- rho_det : ndarray Determinant apparent resistivity (Ω·m). phi_det : ndarray Determinant phase in requested unit. """ zd = self.determinant_z(z) return self.rho_phase_from_z(zd, f, phase_unit=phase_unit) # rotation @staticmethod def rotate_impedance( z: object, theta_deg: float, ) -> np.ndarray: r""" Rotate 2×2 impedance tensor(s) by ``theta_deg``. Parameters ---------- z : array_like, shape (..., 2, 2) Impedance tensor(s). theta_deg : float Rotation angle in degrees. Positive rotates the coordinate frame clockwise (convention). Returns ------- Z_rot : ndarray, shape (..., 2, 2) Rotated impedance tensors. Notes ----- Uses ``Z' = R Z Rᵀ`` with ``R = [[cos θ, sin θ], [-sin θ, cos θ]]``. """ zz = np.asarray(z, dtype=complex) th = np.deg2rad(theta_deg) c = np.cos(th) s = np.sin(th) r = np.array([[c, s], [-s, c]]) rt = r.T return r @ zz @ rt # diffusion / scales def skin_depth( self, f: object, rho: object, ) -> np.ndarray: r""" Skin depth ``δ = sqrt(2ρ / (μ0 ω))`` in meters. Parameters ---------- f : array_like Frequency (Hz). rho : array_like Resistivity (Ω·m). Returns ------- δ : ndarray Skin depth in meters. Notes ----- For quick estimates, ``δ ≈ 503 √(ρ/f)`` (ρ in Ω·m, f in Hz). """ w = self.omega(f) return np.sqrt(2.0 * self._as_f(rho) / (self.MU0 * w)) # time-frequency @staticmethod def freq_to_period(f: object) -> np.ndarray: r""" Convert frequency (Hz) to period (s), safe at zero. """ ff = np.asarray(f, dtype=float) out = np.empty_like(ff) with np.errstate(divide="ignore", invalid="ignore"): out = np.where(ff == 0.0, np.inf, 1.0 / ff) return out @staticmethod def period_to_freq(t: object) -> np.ndarray: r""" Convert period (s) to frequency (Hz), safe at inf. """ tt = np.asarray(t, dtype=float) out = np.empty_like(tt) with np.errstate(divide="ignore", invalid="ignore"): out = np.where(np.isinf(tt), 0.0, 1.0 / tt) return out # tipper @staticmethod def tipper_amp_phase( t: object, *, phase_unit: str = "deg", ) -> tuple[np.ndarray, np.ndarray]: r""" Amplitude and phase of tipper vectors. Parameters ---------- t : array_like, shape (..., 2) or (..., 1, 2) Tipper components ``(Tx, Ty)``. phase_unit : {'deg', 'rad', 'mrad'}, optional Output unit for phase. Default is degrees. Returns ------- amp : ndarray Vector magnitude ``sqrt(Tx² + Ty²)``. phi : ndarray Phase of the complex vector ``Tx + i Ty``. Notes ----- If shape is ``(..., 1, 2)`` it is squeezed to ``(..., 2)`` prior to computation. """ tt = np.asarray(t, dtype=complex) if tt.ndim >= 2 and tt.shape[-2:] == (1, 2): tt = tt[..., 0, :] tx = tt[..., 0] ty = tt[..., 1] vec = tx + 1j * ty amp = np.abs(vec) phi = np.angle(vec) if phase_unit == "deg": phi = np.degrees(phi) elif phase_unit == "mrad": phi = phi * 1.0e3 return amp, phi def phase_tensor(self, z: object) -> np.ndarray: r""" Phase tensor ``Φ = X⁺ Y`` from impedance ``Z = X + iY``. This computes a real 2×2 phase tensor using a robust pseudoinverse ``X⁺`` of the real part of ``Z``. Shapes are broadcast over the leading axes. Parameters ---------- z : array_like, shape (..., 2, 2) Impedance tensor(s) ``Z = X + iY``. Returns ------- Phi : ndarray, shape (..., 2, 2) Real phase tensor. Notes ----- If ``X`` is ill-conditioned, the pseudoinverse stabilizes the solution. ``Φ`` is invariant to site gain and captures the 2-D/3-D character of the response. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> Z = np.array([[[0+1j, 2+3j],[4+5j, 6+7j]]], dtype=complex) >>> Phi = MTBase().phase_tensor(Z) >>> Phi.shape (1, 2, 2) See Also -------- phase_tensor_params phase_tensor_azimuth """ zz = np.asarray(z, dtype=complex) X = zz.real Y = zz.imag # Use pseudoinverse for robustness X_pinv = np.linalg.pinv(X) return np.einsum("...ij,...jk->...ik", X_pinv, Y) def phase_tensor_params( self, z: object, *, angle_unit: str = "deg", ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: r""" Principal phase angles, azimuth, skew, and ellipticity. Derives quick-look parameters from the phase tensor ``Φ``: principal phase angles (``φ_max``, ``φ_min``), phase tensor azimuth ``α``, skew angle ``β``, and ellipticity. Parameters ---------- z : array_like, shape (..., 2, 2) Impedance tensor(s). angle_unit : {'deg', 'rad', 'mrad'}, optional Output unit for angular quantities. Default is degrees. Returns ------- phi_max : ndarray, shape (...) Larger principal phase angle derived from ``Φ``. phi_min : ndarray, shape (...) Smaller principal phase angle derived from ``Φ``. alpha : ndarray, shape (...) Phase tensor azimuth. beta : ndarray, shape (...) Phase tensor skew angle. ellipt : ndarray, shape (...) Ellipticity proxy ``(λ_max - λ_min)/(λ_max + λ_min)``, where ``λ`` are the eigenvalues used internally. Notes ----- This implementation obtains parameters from an eigen decomposition of ``Φ`` and simple angle formulas (Caldwell- style azimuth/skew heuristics). It is suitable for quality control and strike reconnaissance. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> Z = np.zeros((5, 2, 2), complex) >>> z = MTBase() >>> pmax, pmin, az, skew, eps = z.phase_tensor_params(Z) >>> pmax.shape == pmin.shape == az.shape == skew.shape == eps.shape True See Also -------- phase_tensor phase_tensor_azimuth """ Phi = self.phase_tensor(z).astype(float) a, b = Phi[..., 0, 0], Phi[..., 0, 1] c, d = Phi[..., 1, 0], Phi[..., 1, 1] # Caldwell et al. 2004 parameterization alpha = 0.5 * np.arctan2(b - c, a + d) # azimuth beta = 0.5 * np.arctan2(b + c, a - d) # skew # Eigenvalues (real for real Phi) # vals = np.linalg.eigvals(Phi) # vals = np.real_if_close(vals, tol=1e5) # lam_max = np.maximum(vals[..., 0], vals[..., 1]) # lam_min = np.minimum(vals[..., 0], vals[..., 1]) # phi_max = np.arctan(lam_max) # phi_min = np.arctan(lam_min) # existing azimuth/skew (alpha, beta) formulas may stay as-is # but for principal phases use singular values: s = np.linalg.svd(Phi, compute_uv=False) # shape (..., 2) lam_max = np.maximum(s[..., 0], s[..., 1]) lam_min = np.minimum(s[..., 0], s[..., 1]) phi_max = np.arctan(lam_max) phi_min = np.arctan(lam_min) ellipt = (lam_max - lam_min) / (lam_max + lam_min + 1e-18) if angle_unit == "deg": phi_max = np.degrees(phi_max) phi_min = np.degrees(phi_min) alpha = np.degrees(alpha) beta = np.degrees(beta) elif angle_unit == "mrad": k = 1e3 phi_max = phi_max * k phi_min = phi_min * k alpha = alpha * k beta = beta * k return phi_max, phi_min, alpha, beta, ellipt def phase_tensor_azimuth( self, z: object, *, unit: str = "deg", ) -> np.ndarray: r""" Azimuth of the phase tensor principal axis. Parameters ---------- z : array_like, shape (..., 2, 2) Impedance tensor(s). unit : {'deg', 'rad', 'mrad'}, optional Output unit. Default is degrees. Returns ------- alpha : ndarray, shape (...) Phase tensor azimuth. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> Z = np.zeros((3, 2, 2), complex) >>> MTBase().phase_tensor_azimuth(Z).shape (3,) See Also -------- phase_tensor phase_tensor_params """ _, _, az, _, _ = self.phase_tensor_params(z, angle_unit=unit) return az @staticmethod def tipper_rotate(t: object, theta_deg: float) -> np.ndarray: r""" Rotate tipper vectors by ``theta_deg`` in the horizontal plane. Parameters ---------- t : array_like, shape (..., 2) or (..., 1, 2) Tipper components ``(T_x, T_y)``. A trailing ``(1, 2)`` is squeezed. theta_deg : float Rotation angle in degrees. Positive angles rotate the coordinates clockwise (same convention as :meth:`rotate_impedance`). Returns ------- t_rot : ndarray, shape (..., 2) Rotated tipper vectors. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> T = np.array([[0.2+0.1j, -0.1+0.3j]]) >>> MTBase.tipper_rotate(T, 30.0).shape (1, 2) See Also -------- rotate_impedance induction_arrows """ tt = np.asarray(t, dtype=complex) if tt.ndim >= 2 and tt.shape[-2:] == (1, 2): tt = tt[..., 0, :] th = np.deg2rad(theta_deg) c, s = np.cos(th), np.sin(th) R = np.array([[c, s], [-s, c]]) return np.einsum("ij,...j->...i", R, tt) @staticmethod def induction_arrows( t: object, *, convention: str = "wiese", use_imag: bool = False, ) -> tuple[np.ndarray, np.ndarray]: r""" Compute 2-D induction arrows from tipper components. Parameters ---------- t : array_like, shape (..., 2) or (..., 1, 2) Tipper components ``(T_x, T_y)``. convention : {'wiese', 'parkinson'}, optional Arrow convention. ``'wiese'`` uses a 90° rotation from the real (or imaginary) tipper; ``'parkinson'`` uses the components directly. Default ``'wiese'``. use_imag : bool, optional If ``True``, use imaginary parts; else use real parts. Default ``False``. Returns ------- ax : ndarray, shape (...) X-component of the arrow. ay : ndarray, shape (...) Y-component of the arrow. Notes ----- Wiese arrows often highlight current channeling (2-D/3-D). Use imaginary arrows (``use_imag=True``) for deeper structure emphasis at higher periods. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> T = np.array([[0.2+0.1j, -0.1+0.3j]]) >>> ax, ay = MTBase.induction_arrows(T, convention='wiese') >>> ax.shape == ay.shape True See Also -------- tipper_rotate """ tt = np.asarray(t, dtype=complex) if tt.ndim >= 2 and tt.shape[-2:] == (1, 2): tt = tt[..., 0, :] tx = tt[..., 0] ty = tt[..., 1] rx = tx.imag if use_imag else tx.real ry = ty.imag if use_imag else ty.real if convention.lower() == "wiese": ax, ay = -ry, rx else: # "parkinson" or fallback ax, ay = rx, ry return ax, ay def swift_skew( self, z: object, *, unit: str = "deg", ) -> tuple[np.ndarray, np.ndarray]: r""" Swift skew parameter from the impedance tensor. Computes the complex Swift skew ratio ``s = (Z_xx + Z_yy) / (Z_xy - Z_yx)``, returning its magnitude and angle. Parameters ---------- z : array_like, shape (..., 2, 2) Impedance tensor(s). unit : {'deg', 'rad', 'mrad'}, optional Unit for the returned angle. Default is degrees. Returns ------- amp : ndarray, shape (...) Magnitude ``|s|`` (dimensionless). ang : ndarray, shape (...) Argument of ``s`` in the requested unit. Notes ----- Low ``|s|`` values are consistent with 1-D/2-D structure; an elevated skew suggests 3-D effects or distortion. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> Z = np.ones((4, 2, 2), dtype=complex) >>> amp, ang = MTBase().swift_skew(Z) >>> amp.shape == ang.shape True """ zz = np.asarray(z, dtype=complex) zxx, zxy = zz[..., 0, 0], zz[..., 0, 1] zyx, zyy = zz[..., 1, 0], zz[..., 1, 1] s = (zxx + zyy) / (zxy - zyx + 1e-30) amp = np.abs(s) ang = np.angle(s) if unit == "deg": ang = np.degrees(ang) elif unit == "mrad": ang = ang * 1e3 return amp, ang def apparent_conductivity_from_z( self, z: object, f: object, ) -> np.ndarray: r""" Apparent conductivity ``σₐ = μ₀ ω / |Z|²`` (S/m). Parameters ---------- z : array_like Impedance (per component or invariant). f : array_like Frequency (Hz), broadcastable to ``z``. Returns ------- sigma_a : ndarray Apparent conductivity (S/m). Notes ----- This is the reciprocal of the common SI resistivity formula ``ρₐ = |Z|² / (μ₀ ω)``. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> Z = np.array([5.0, 10.0]) # Ω >>> f = np.array([1.0, 0.1]) # Hz >>> MTBase().apparent_conductivity_from_z(Z, f).shape (2,) See Also -------- rho_phase_from_z rho_phase_from_det """ zz = np.asarray(z, dtype=complex) w = self.omega(f) return (self.MU0 * w) / (np.abs(zz) ** 2 + 1e-30) def halfspace_impedance( self, f: object, rho: object, ) -> np.ndarray: r""" Plane-wave impedance over a uniform half-space. For a homogeneous half-space of resistivity ``ρ``, the surface impedance is ``Z = (1 + i) / √2 · √(μ₀ ω ρ)``, with a constant 45° phase and amplitude ``∝ √(ω ρ)``. Parameters ---------- f : array_like Frequency (Hz). rho : array_like Resistivity (Ω·m). Returns ------- Z : ndarray Complex half-space impedance. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> f = np.logspace(-3, 3, 5) >>> Z = MTBase().halfspace_impedance(f, 100.0) >>> Z.shape (5,) """ w = self.omega(f) amp = np.sqrt(self.MU0 * w * self._as_f(rho)) return (1.0 + 1.0j) * (amp / np.sqrt(2.0)) def z_mvk_nt_to_ohms(self, z: object) -> np.ndarray: r""" Convert ``Z`` from ``(mV/km)/nT`` to ohms (Ω). This helper assumes the denominator is magnetic flux density ``B`` in nT (not ``H``). It converts to SI ``(V/m)/T`` and then multiplies by ``μ₀`` to obtain ``V/A = Ω``. Parameters ---------- z : array_like Impedance in mixed field units ``(mV/km)/nT``. Returns ------- Z_ohms : ndarray Impedance in ohms (Ω). Notes ----- If your denominator is already the magnetic field intensity ``H`` in A/m, you *should not* multiply by ``μ₀``. Use careful unit bookkeeping in mixed-unit workflows. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> z_field = np.array([50.0]) # (mV/km)/nT >>> Z_ohm = MTBase().z_mvk_nt_to_ohms(z_field) >>> Z_ohm.shape (1,) """ zz = np.asarray(z, dtype=complex) return zz * self.Z_UNIT_MVK_NT_TO_SI * self.MU0 @staticmethod def rotate_fields( e: object, h: object, theta_deg: float, ) -> tuple[np.ndarray, np.ndarray]: r""" Rotate horizontal electric and magnetic field vectors. Parameters ---------- e : array_like, shape (..., 2) Horizontal electric field components ``(E_x, E_y)``. h : array_like, shape (..., 2) Horizontal magnetic field components ``(H_x, H_y)``. theta_deg : float Rotation angle in degrees. Positive rotates the coordinate frame clockwise. Returns ------- e_rot : ndarray, shape (..., 2) Rotated electric field components. h_rot : ndarray, shape (..., 2) Rotated magnetic field components. Examples -------- >>> from pycsamt.core.base import MTBase >>> import numpy as np >>> e = np.array([[1.0, 0.0]]) >>> h = np.array([[0.0, 1.0]]) >>> e_r, h_r = MTBase.rotate_fields(e, h, 45.0) >>> e_r.shape == h_r.shape True See Also -------- rotate_impedance tipper_rotate """ ee = np.asarray(e) hh = np.asarray(h) th = np.deg2rad(theta_deg) c, s = np.cos(th), np.sin(th) R = np.array([[c, s], [-s, c]]) e_rot = np.einsum("ij,...j->...i", R, ee) h_rot = np.einsum("ij,...j->...i", R, hh) return e_rot, h_rot