Source code for pycsamt.z.utils

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

"""
General utilities for working with impedance tensors (Z) and
tippers.

This module provides small, composable helpers that complement
the object APIs in :mod:`pycsamt.z`.
"""

from __future__ import annotations

from collections.abc import Sequence

import numpy as np

from ..exceptions import ZError
from ..log.logger import get_logger
from ..utils.zmath import (
    invertmatrix_incl_errors,
    rotatematrix_incl_errors,
    z_error2r_phi_error,
)

logger = get_logger(__name__)


# Orientation correction
[docs] def correct_for_sensor_orientation( z_prime: np.ndarray, bx: float = 0.0, by: float = 90.0, ex: float = 0.0, ey: float = 90.0, z_prime_err: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray | None]: """ Correct an impedance tensor for sensor misorientation. Given measured fields in non-standard sensor frames, .. math:: \\mathbf{E}' = \\mathbf{Z}' \\, \\mathbf{B}', and change-of-basis matrices :math:`\\mathbf{T}` (for E) and :math:`\\mathbf{U}` (for B), the tensor in standard coordinates is .. math:: \\mathbf{Z} = \\mathbf{T} \\, \\mathbf{Z}' \\, \\mathbf{U}^{-1}. This function builds :math:`\\mathbf{T}` and :math:`\\mathbf{U}` from the sensor orientations and applies the transform. Parameters ---------- z_prime : ndarray Input impedance tensor(s). Shape ``(2, 2)`` or ``(n_freq, 2, 2)``. Complex valued. bx, by : float, default 0, 90 Orientations (deg) of Bx, By relative to geographic North (0°). Positive clockwise. ex, ey : float, default 0, 90 Orientations (deg) of Ex, Ey relative to geographic North (0°). Positive clockwise. z_prime_err : ndarray, optional Standard deviations for :math:`\\mathbf{Z}'`, same shape as ``z_prime`` and real-valued. If provided, a conservative error propagation is applied (see Notes). Returns ------- z : ndarray Orientation-corrected tensor(s), same shape as ``z_prime``. z_err : ndarray or None Propagated standard deviations in the default orientation. ``None`` if ``z_prime_err`` was not given. Notes ----- - Matrices :math:`\\mathbf{T}` and :math:`\\mathbf{U}` are real and built from unit vectors at angles ``ex, ey`` and ``bx, by`` respectively: .. code-block:: text T = [[cos(ex), cos(ey)], [sin(ex), sin(ey)]], U = [[cos(bx), cos(by)], [sin(bx), sin(by)]] - Error propagation uses a simple 1-norm–like bound for :math:`Z = T Z' U^{-1}`: .. math:: \\Delta Z \\approx |T| \\, \\Delta Z' \\, |U^{-1}|, applied element-wise by summation. This is conservative and ignores covariances. Examples -------- >>> import numpy as np >>> from pycsamt.z.utils import correct_for_sensor_orientation >>> Zp = np.array([[0+0j, 1+1j], [-1-1j, 0+0j]]) >>> Z, Zerr = correct_for_sensor_orientation(Zp, bx=5, by=95) """ Zp = np.asarray(z_prime) if Zp.ndim == 2: if Zp.shape != (2, 2): raise ZError( "For 2-D input, 'z_prime' shape must be (2, 2); " f"got {Zp.shape!r}." ) Zp = Zp[None, ...] # promote to (n,2,2) squeeze_output = True elif Zp.ndim == 3 and Zp.shape[1:] == (2, 2): squeeze_output = False else: raise ZError( "'z_prime' must have shape (2, 2) or (n_freq, 2, 2); " f"got {Zp.shape!r}." ) if not np.issubdtype(Zp.dtype, np.complexfloating): # accept real, but cast to complex for safety Zp = Zp.astype(complex, copy=False) if z_prime_err is not None: Ze = np.asarray(z_prime_err) if Ze.ndim == 2: if Ze.shape != (2, 2): raise ZError( "For 2-D input, 'z_prime_err' must be (2, 2); " f"got {Ze.shape!r}." ) Ze = Ze[None, ...] elif Ze.ndim == 3 and Ze.shape[1:] == (2, 2): pass else: raise ZError( "'z_prime_err' must match (2, 2) or (n, 2, 2); " f"got {Ze.shape!r}." ) if Ze.shape[0] != Zp.shape[0]: raise ZError( "'z_prime_err' and 'z_prime' must have matching " f"n_freq: {Ze.shape[0]} vs {Zp.shape[0]}." ) if not np.issubdtype(Ze.dtype, np.floating): Ze = Ze.astype(float, copy=False) else: Ze = None # build T and U transformation def _rotmat(ax: float, ay: float) -> np.ndarray: # angles in degrees (clockwise positive) a = np.deg2rad(ax) b = np.deg2rad(ay) return np.array( [[np.cos(a), np.cos(b)], [np.sin(a), np.sin(b)]], dtype=float ) T = _rotmat(ex, ey) U = _rotmat(bx, by) # Inverse of U try: Uinv = np.linalg.inv(U) except np.linalg.LinAlgError as exc: raise ZError( "Magnetic sensor basis 'U' is singular; cannot invert. " "Check angles 'bx' and 'by'." ) from exc # apply transformation # Z = T * Z' * U^{-1} # vectorized over frequencies Z_left = np.einsum("ik,nkj->nij", T, Zp, optimize=True) Z = np.einsum("nij,jl->nil", Z_left, Uinv, optimize=True) # error propagation if Ze is not None: # ΔZ ≈ |T| ΔZ' |U^{-1}| (elementwise bound by summation) Tabs = np.abs(T) Uabs = np.abs(Uinv) # First contraction over k: (i,k) x (n,k,l) -> (i,n,l) tmp = np.tensordot(Tabs, Ze, axes=([1], [1])) # (2, n, 2) # Then over l: (i,n,l) x (l,j) -> (i,n,j) dZ = np.tensordot(tmp, Uabs, axes=([2], [0])) # (2, n, 2) Zerr = np.transpose(dZ, (1, 0, 2)) # (n, 2, 2) else: Zerr = None # finish if squeeze_output: Z = Z[0] if Zerr is not None: Zerr = Zerr[0] return Z, Zerr
# Frequency / period helpers
[docs] def periods_from_freq( freq: Sequence[float], *, log10: bool = False ) -> np.ndarray: """ Convert frequency (Hz) to period (s). Parameters ---------- freq : array-like of float Frequencies in Hz. log10 : bool, default False If ``True``, return ``log10(period)``. Returns ------- ndarray Periods with the same length as ``freq``. """ f = np.asarray(freq, dtype=float).ravel() if np.any(f <= 0.0): raise ZError("Frequencies must be strictly positive.") T = 1.0 / f return np.log10(T) if log10 else T
[docs] def freq_from_periods( period: Sequence[float], *, log10: bool = False ) -> np.ndarray: """ Convert period (s) to frequency (Hz). Parameters ---------- period : array-like of float Periods in seconds. If ``log10=True``, the input is interpreted as ``log10(period)``. log10 : bool, default False Whether input is in base-10 log scale. Returns ------- ndarray Frequencies in Hz. """ p = np.asarray(period, dtype=float).ravel() P = np.power(10.0, p) if log10 else p if np.any(P <= 0.0): raise ZError("Periods must be strictly positive.") return 1.0 / P
# Shape / validation helpers
[docs] def ensure_z3(z: np.ndarray) -> np.ndarray: """ Normalize an impedance array to shape ``(n_freq, 2, 2)``. Parameters ---------- z : ndarray Array with shape ``(2, 2)`` or ``(n_freq, 2, 2)`` (real or complex). Returns ------- ndarray Complex array of shape ``(n_freq, 2, 2)``. Raises ------ ZError If the shape is not supported. """ arr = np.asarray(z) if arr.ndim == 2 and arr.shape == (2, 2): arr = arr[None, ...] elif not (arr.ndim == 3 and arr.shape[1:] == (2, 2)): raise ZError( f"Z must be shape (2, 2) or (n_freq, 2, 2); got {arr.shape!r}." ) return arr.astype(complex, copy=False)
# Stateless ρ–φ from Z (optionally with errors)
[docs] def rho_phi_from_z( z: np.ndarray, freq: Sequence[float], z_err: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray | None, np.ndarray | None]: """ Compute apparent resistivity and phase from Z. Parameters ---------- z : ndarray Impedance tensor(s), shape ``(2, 2)`` or ``(n, 2, 2)``. freq : array-like of float Frequency vector (Hz). Length must be ``n``. z_err : ndarray, optional Absolute errors for Z, same shape as ``z``. If given, uncertainties are propagated component-wise. Returns ------- rho : ndarray Apparent resistivity (Ohm·m), shape ``(n, 2, 2)``. phi : ndarray Phase (deg), shape ``(n, 2, 2)``. rho_err : ndarray or None Error on resistivity (Ohm·m) or ``None`` if ``z_err`` is ``None``. phi_err : ndarray or None Error on phase (deg) or ``None`` if ``z_err`` is ``None``. """ Z = ensure_z3(z) f = np.asarray(freq, dtype=float).ravel() if f.size != Z.shape[0]: raise ZError( "Length of 'freq' must match number of Z stacks: " f"{f.size} vs {Z.shape[0]}." ) # ρ = |Z|^2 / (μ0 ω) ; use 0.2 / f (μ0≈4πe-7, ω=2πf) rho = (np.abs(Z) ** 2) * (0.2 / f)[:, None, None] phi = np.degrees(np.angle(Z)) if z_err is None: return rho, phi, None, None E = ensure_z3(z_err).astype(float, copy=False) rho_e = np.zeros_like(rho, dtype=float) phi_e = np.zeros_like(phi, dtype=float) for k in range(Z.shape[0]): for i in range(2): for j in range(2): r_rel, p_err = z_error2r_phi_error( Z[k, i, j].real, Z[k, i, j].imag, E[k, i, j] ) rho_e[k, i, j] = rho[k, i, j] * r_rel phi_e[k, i, j] = p_err return rho, phi, rho_e, phi_e
# Antisymmetry enforcement (Zxy ~ -Zyx)
[docs] def enforce_offdiag_antisymmetry( z: np.ndarray, z_err: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray | None]: """ Enforce the magnetotelluric property ``Zxy = -Zyx``. Parameters ---------- z : ndarray Z tensor(s), shape ``(2, 2)`` or ``(n, 2, 2)``. z_err : ndarray, optional Errors with same shape as ``z``. If provided, off-diagonal errors are combined via RMS. Returns ------- z_new : ndarray Copy of Z with enforced antisymmetry on off-diagonals. z_err_new : ndarray or None Propagated errors (off-diagonals via RMS), or ``None`` if ``z_err`` was not given. Notes ----- We set .. math:: m = \\tfrac{1}{2}(Z_{xy} - Z_{yx}),\\; Z_{xy}=m,\\; Z_{yx}=-m, which minimally enforces antisymmetry. """ Z = ensure_z3(z) Zc = Z.copy() xy = Zc[:, 0, 1] yx = Zc[:, 1, 0] m = 0.5 * (xy - yx) Zc[:, 0, 1] = m Zc[:, 1, 0] = -m if z_err is None: return (Zc[0] if z.ndim == 2 else Zc), None E = ensure_z3(z_err).astype(float, copy=False) Ec = E.copy() # combine off-diagonal errors conservatively (RMS) exy = Ec[:, 0, 1] eyx = Ec[:, 1, 0] e_m = np.sqrt(0.5 * (exy**2 + eyx**2)) Ec[:, 0, 1] = e_m Ec[:, 1, 0] = e_m if z.ndim == 2: return Zc[0], Ec[0] return Zc, Ec
# Vectorized rotate / invert with optional errors
[docs] def rotate_z( z: np.ndarray, angle_deg: float | Sequence[float], z_err: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray | None]: """ Rotate Z by ``angle_deg`` (CW positive), vectorized over freq. Parameters ---------- z : ndarray Z tensor(s), shape ``(2, 2)`` or ``(n, 2, 2)``. angle_deg : float or sequence of float Single angle for all stacks or one per frequency. z_err : ndarray, optional Error tensor matching ``z`` shape. Returns ------- z_rot : ndarray Rotated Z with same shape as ``z``. z_err_rot : ndarray or None Rotated error, if ``z_err`` was provided. """ Z = ensure_z3(z) n = Z.shape[0] if np.isscalar(angle_deg) or ( isinstance(angle_deg, (list, tuple)) and len(angle_deg) == 1 ): alphas = np.full( n, float(np.asarray(angle_deg).ravel()[0]), dtype=float ) else: a = np.asarray(angle_deg, dtype=float).ravel() if a.size != n: raise ZError(f"Expected {n} angles, got {a.size}.") alphas = a E = None if z_err is None else ensure_z3(z_err).astype(float, copy=False) Zr = np.empty_like(Z) Er = None if E is None else np.empty_like(E) for k in range(n): if E is None: Zr[k], _ = rotatematrix_incl_errors(Z[k], alphas[k]) else: Zr[k], Er[k] = rotatematrix_incl_errors(Z[k], alphas[k], E[k]) if z.ndim == 2: return (Zr[0], None if Er is None else Er[0]) return Zr, Er
[docs] def invert_z( z: np.ndarray, z_err: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray | None]: """ Invert Z (or each Z slice) with optional error propagation. Parameters ---------- z : ndarray Z tensor(s), shape ``(2, 2)`` or ``(n, 2, 2)``. z_err : ndarray, optional Error tensor matching ``z`` shape. Returns ------- z_inv : ndarray Inverse of Z with same shape as ``z``. z_inv_err : ndarray or None Propagated error of Z inverse, or ``None`` if no input error given. Raises ------ ZError If any slice is singular. """ Z = ensure_z3(z) E = None if z_err is None else ensure_z3(z_err).astype(float, copy=False) Zi = np.empty_like(Z) Ei = None if E is None else np.empty_like(E) for k in range(Z.shape[0]): try: if E is None: Zi[k], _ = invertmatrix_incl_errors(Z[k], np.zeros((2, 2))) else: Zi[k], Ei[k] = invertmatrix_incl_errors(Z[k], E[k]) except np.linalg.LinAlgError as exc: raise ZError("Singular impedance tensor; cannot invert.") from exc if z.ndim == 2: return (Zi[0], None if Ei is None else Ei[0]) return Zi, Ei
# Frequency alignment (generic)
[docs] def align_frequency_stack( ref_freq: Sequence[float], freq: Sequence[float], z: np.ndarray, *, fill_value: complex | float = np.nan, ) -> np.ndarray: """ Align a Z component/vector to a reference frequency grid. This is a generalization of a "fit tensor" operation: values at ``freq`` are placed at the matching indices of ``ref_freq`` and missing locations are filled with ``fill_value``. Parameters ---------- ref_freq : array-like of float Target frequency grid (Hz), length ``N``. freq : array-like of float Source frequency grid (Hz), length ``M`` with values contained in ``ref_freq`` (within exact match). z : ndarray Data to align. Shape must be compatible with the first dimension being ``M`` (e.g., ``(M,)`` or ``(M, 2, 2)``). fill_value : scalar, default NaN Value used to fill missing entries. Returns ------- ndarray Aligned array with shape ``(N, ...)``. Raises ------ ZError If shapes are incompatible. """ f_ref = np.asarray(ref_freq, dtype=float).ravel() f = np.asarray(freq, dtype=float).ravel() Z = np.asarray(z) if f.size != Z.shape[0]: raise ZError( "Data first dimension must match 'freq' length: " f"{Z.shape[0]} vs {f.size}." ) out_shape = (f_ref.size,) + Z.shape[1:] out = np.full(out_shape, fill_value, dtype=Z.dtype) # exact match indices (assumes identical frequency values) idx = {val: i for i, val in enumerate(f_ref)} try: dst = np.array([idx[val] for val in f], dtype=int) except KeyError as exc: raise ZError( "All 'freq' values must be present in 'ref_freq'." ) from exc out[dst, ...] = Z return out
# Simple finite/outlier masks
[docs] def finite_mask(a: np.ndarray) -> np.ndarray: """ Boolean mask where all real and imaginary parts are finite. Parameters ---------- a : ndarray Input array. Returns ------- ndarray of bool Mask with ``True`` for finite entries. """ A = np.asarray(a) if np.iscomplexobj(A): return np.isfinite(A.real) & np.isfinite(A.imag) return np.isfinite(A)
[docs] def sigma_clip_mask( a: np.ndarray, *, nsigma: float = 3.0, axis: int = 0 ) -> np.ndarray: """ Sigma-clip mask for outlier rejection. Parameters ---------- a : ndarray Input array. nsigma : float, default 3.0 Threshold in units of standard deviation. axis : int, default 0 Axis along which statistics are computed. Returns ------- ndarray of bool Mask of inliers (``True``) and outliers (``False``). """ A = np.asarray(a, dtype=float) mu = np.nanmean(A, axis=axis, keepdims=True) sd = np.nanstd(A, axis=axis, keepdims=True) return np.abs(A - mu) <= nsigma * (sd + 1e-12)