Source code for pycsamt.z.resphase

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

from __future__ import annotations

from typing import Any

import numpy as np

from ..exceptions import (
    PhaseError,
    ResistivityError,
    ZError,
)
from ..log.logger import get_logger
from ..utils.zmath import (
    propagate_error_polar2rect,
    z_error2r_phi_error,
)
from .base import BaseEM

logger = get_logger(__name__)


[docs] class ResPhase(BaseEM): r""" Resistivity/phase container backed by complex **Z**. ``ResPhase`` computes apparent resistivity :math:`\rho` and phase :math:`\phi` from a complex impedance tensor **Z**, and supports the inverse map :math:`(\rho,\ \phi) \rightarrow \mathbf{Z}` with error propagation. It is a light, independent container; :class:`~pycsamt.z.z.Z` inherits from it to add higher-level conveniences. Parameters ---------- z_array : ndarray, shape (n_freq, 2, 2), optional Complex impedance tensor **Z**. If omitted, call :meth:`compute_resistivity_phase` later. z_err_array : ndarray, shape (n_freq, 2, 2), optional Absolute per-component uncertainty on **Z**. If omitted, uncertainties on :math:`\rho` and :math:`\phi` remain ``None``. freq : ndarray, shape (n_freq,), optional Frequency vector in Hz. Must be 1-D, finite and strictly positive. **kwargs Forwarded to :class:`~pycsamt.z.base.BaseEM` (e.g., ``name``, ``meta``). Attributes ---------- resistivity : ndarray, shape (n_freq, 2, 2) Apparent resistivity :math:`\rho` (Ω·m). Set by :meth:`compute_resistivity_phase` or :meth:`set_res_phase`. phase : ndarray, shape (n_freq, 2, 2) Phase :math:`\phi` in degrees. Set alongside :pyattr:`resistivity`. resistivity_err : ndarray or None, shape (n_freq, 2, 2) Absolute uncertainty on :math:`\rho` (Ω·m) or ``None`` if no **Z** errors were provided. phase_err : ndarray or None, shape (n_freq, 2, 2) Absolute phase uncertainty (deg) or ``None``. z : ndarray or None, shape (n_freq, 2, 2) Complex **Z**, when known (set by the inverse path). z_err : ndarray or None, shape (n_freq, 2, 2) Absolute uncertainty on **Z**, when propagated. freq : ndarray or None, shape (n_freq,) Frequency vector in Hz (1-D, finite, > 0). n_freq : int Inferred number of frequencies (from :pyattr:`freq` or the first dimension of known arrays). Notes ----- **Forward path.** :math:`\rho` and :math:`\phi` are computed from **Z** using .. math:: \rho \;=\; 0.2\,\frac{|Z|^{2}}{f}, \qquad \phi \;=\; \angle Z \quad (\text{in degrees}). **Error propagation (forward).** If **Z** errors are available, the per-entry relative amplitude error is :math:`\Delta Z/|Z|`. We use :func:`~pycsamt.utils.zmath.z_error2r_phi_error` to map this to the :math:`\rho` relative error (×2) and to an absolute phase uncertainty (deg, capped at :math:`90^{\circ}`). **Inverse path.** Given :math:`\rho` and :math:`\phi`, we recover :math:`|Z|` from .. math:: |Z| \;=\; \sqrt{\,5\,f\,\rho\,}, then build **Z** in Euler form. When :math:`\rho` and :math:`\phi` errors are given, the :math:`|Z|` error follows .. math:: \frac{d|Z|}{|Z|} \;=\; \tfrac{1}{2}\,\frac{d\rho}{\rho}, and, together with the phase error, is converted to a single absolute **Z** error per component via :func:`~pycsamt.utils.zmath.propagate_error_polar2rect`. Examples -------- Compute :math:`\rho` and :math:`\phi` from a stack of **Z**:: >>> import numpy as np >>> from pycsamt.z.resphase import ResPhase >>> z = np.ones((2, 2, 2), complex) >>> f = np.array([10.0, 1.0]) >>> rp = ResPhase() >>> rp.compute_resistivity_phase(z_array=z, freq=f) >>> rp.resistivity.shape, rp.phase.shape ((2, 2, 2), (2, 2, 2)) Reconstruct **Z** from :math:`\rho,\ \phi` (no uncertainties):: >>> rho = (0.2 / f)[:, None, None] * np.ones((2, 2, 2)) >>> phi = np.zeros_like(rho) >>> rp.set_res_phase(rho, phi, f) >>> rp._z.shape (2, 2, 2) See Also -------- pycsamt.z.z.Z High-level impedance container built on ``ResPhase``. pycsamt.utils.zmath.z_error2r_phi_error Forward error mapping for :math:`\rho` and :math:`\phi`. pycsamt.utils.zmath.propagate_error_polar2rect Polar → rectangular error propagation for **Z**. References ---------- .. [1] Chave, A. D., & Jones, A. G. (2012). *The Magnetotelluric Method: Theory and Practice*. CUP. .. [2] Simpson, F., & Bahr, K. (2005). *Practical Magnetotellurics*. CUP. """ def __init__( self, z_array: np.ndarray | None = None, z_err_array: np.ndarray | None = None, freq: np.ndarray | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) self._z: np.ndarray | None = None self._z_err: np.ndarray | None = None self._resistivity: np.ndarray | None = None self._phase: np.ndarray | None = None self._resistivity_err: np.ndarray | None = None self._phase_err: np.ndarray | None = None if z_array is not None: self._z = np.asarray(z_array, dtype=complex) if z_err_array is not None: self._z_err = np.asarray(z_err_array, dtype=float) if freq is not None: self.freq = freq @property def resistivity(self) -> np.ndarray: if self._resistivity is None: raise ResistivityError("resistivity not computed/attached") return self._resistivity
[docs] @resistivity.setter def resistivity(self, res_array: np.ndarray) -> None: self._resistivity = np.asarray(res_array, dtype=float)
@property def resistivity_err(self) -> np.ndarray | None: return self._resistivity_err
[docs] @resistivity_err.setter def resistivity_err(self, res_err_array: np.ndarray | None) -> None: if res_err_array is None: self._resistivity_err = None else: self._resistivity_err = np.asarray(res_err_array, dtype=float)
@property def phase(self) -> np.ndarray: if self._phase is None: raise PhaseError("phase not computed/attached") return self._phase
[docs] @phase.setter def phase(self, phase_array: np.ndarray) -> None: self._phase = np.asarray(phase_array, dtype=float)
@property def phase_err(self) -> np.ndarray | None: return self._phase_err
[docs] @phase_err.setter def phase_err(self, phase_err_array: np.ndarray | None) -> None: if phase_err_array is None: self._phase_err = None else: self._phase_err = np.asarray(phase_err_array, dtype=float)
[docs] def compute_resistivity_phase( self, z_array: np.ndarray | None = None, z_err_array: np.ndarray | None = None, freq: np.ndarray | None = None, ) -> None: r""" Compute :math:`rho` and :math:`phi`(and their errors) from complex **Z**. Any provided inputs override the instance state. On success, :pyattr:`resistivity`, :pyattr:`phase`, and, when applicable, :pyattr:`resistivity_err` and :pyattr:`phase_err` are set. Parameters ---------- z_array : ndarray, shape (n_freq, 2, 2), optional Complex impedance tensor **Z**. If given, it replaces the internal value used for the computation. z_err_array : ndarray, shape (n_freq, 2, 2), optional Absolute **Z** error. If omitted, ρ and φ uncertainties are set to ``None``. freq : ndarray, shape (n_freq,), optional Frequency vector in Hz. Must be 1-D, finite and > 0. Returns ------- None Results are stored on the instance. Raises ------ ZError If **Z** is missing, shapes are inconsistent, values are not finite, or frequencies are not strictly positive. Notes ----- We use :math:`\rho = 0.2\,|Z|^2 / f` and :math:`\phi = \angle Z` (in degrees). If ``z_err_array`` is given, per-entry uncertainties are computed via :func:`~pycsamt.utils.zmath.z_error2r_phi_error`. The resistivity error is **absolute** (Ω·m). The phase error is **absolute** (deg) and is capped at :math:`90^\circ`. Examples -------- >>> import numpy as np >>> z = np.ones((2, 2, 2), complex) >>> f = np.array([10.0, 1.0]) >>> rp = ResPhase() >>> rp.compute_resistivity_phase(z_array=z, freq=f) >>> rp.resistivity.shape (2, 2, 2) """ if z_array is not None: self._z = np.asarray(z_array, dtype=complex) if z_err_array is not None: self._z_err = np.asarray(z_err_array, dtype=float) if freq is not None: self.freq = freq if self._z is None or self.freq is None: raise ZError("missing Z and/or 'freq' to compute ρ and φ") z = np.asarray(self._z, dtype=complex) f = np.asarray(self.freq, dtype=float) if z.ndim != 3 or z.shape[1:] != (2, 2): raise ZError(f"Z must have shape (n,2,2); got {z.shape!r}") if f.ndim != 1 or f.shape[0] != z.shape[0]: raise ZError("freq must be 1-D with length equal to Z.shape[0]") if np.any(f <= 0.0) or not np.all(np.isfinite(f)): raise ZError("freq must be finite and > 0") abs_z2 = np.abs(z) ** 2 self._resistivity = 0.2 * abs_z2 / f[:, None, None] self._phase = np.degrees(np.angle(z)) if self._z_err is None: self._resistivity_err = None self._phase_err = None return z_err = np.asarray(self._z_err, dtype=float) if z_err.shape != z.shape: raise ZError( "Z error must have same shape as Z; got " f"{z_err.shape!r} vs {z.shape!r}" ) valid_z = np.isfinite(z.real) & np.isfinite(z.imag) valid_zerr = np.isfinite(z_err) & (z_err >= 0) if np.any(~valid_zerr & valid_z): raise ZError("z_err must be finite and non-negative") rho_e = np.full_like(self._resistivity, np.nan, dtype=float) phi_e = np.full_like(self._phase, np.nan, dtype=float) for k in range(f.size): for i in range(2): for j in range(2): if not (valid_z[k, i, j] and valid_zerr[k, i, j]): continue re = z[k, i, j].real im = z[k, i, j].imag dz = z_err[k, i, j] r_rel, ph_err = z_error2r_phi_error(re, im, dz) val = self._resistivity[k, i, j] rho_e[k, i, j] = ( 0.0 if (val == 0.0) or (not np.isfinite(r_rel)) else val * r_rel ) phi_e[k, i, j] = ph_err self._resistivity_err = rho_e self._phase_err = phi_e
# --------------------------------- # Inverse: (ρ, φ) → Z (+ Z errs) # ---------------------------------
[docs] def set_res_phase( self, res_array: np.ndarray, phase_array: np.ndarray, freq: np.ndarray, res_err_array: np.ndarray | None = None, phase_err_array: np.ndarray | None = None, ) -> None: r""" Attach :math:`rho` and :math:`phi` (with optional errors) and reconstruct **Z**. This inverse path accepts apparent resistivity (ρ) and phase (φ) at each frequency, reconstructs |Z| via ``|Z| = sqrt(5 f ρ)``, and builds the complex tensor **Z**. If both ρ and φ errors are supplied, a per-entry absolute **Z** uncertainty is propagated in polar coordinates and converted to rectangular form. Parameters ---------- res_array : ndarray, shape (n_freq, 2, 2) Apparent resistivity (Ω·m). Must be real and finite. phase_array : ndarray, shape (n_freq, 2, 2) Phase in degrees. Must be real and finite. freq : ndarray, shape (n_freq,) Frequency in Hz (1-D, finite, strictly positive). res_err_array : ndarray, shape (n_freq, 2, 2), optional Absolute error on ρ (Ω·m). If omitted, **Z** error is left ``None``. phase_err_array : ndarray, shape (n_freq, 2, 2), optional Absolute phase error in degrees. Returns ------- None Results are stored on the instance (:pyattr:`_z`, :pyattr:`_z_err`, :pyattr:`resistivity`, :pyattr:`phase`). Raises ------ ResistivityError If ρ contains complex values. PhaseError If φ contains complex values. ZError If shapes are inconsistent, values are non-finite, or frequencies are not strictly positive. Notes ----- The relationship between :math:`|Z|` and :math:`\rho` implies ..math:: \frac{d|Z|}{|Z|} = \tfrac{1}{2}\,\frac{d\rho}{\rho}. When error arrays are supplied, |Z| error follows the above and is combined with phase error by :func:`~pycsamt.utils.zmath.propagate_error_polar2rect` to yield a single absolute **Z** error per component. Examples -------- >>> import numpy as np >>> f = np.array([10.0, 1.0]) >>> rho = (0.2 / f)[:, None, None] * np.ones((2, 2, 2)) >>> phi = np.zeros_like(rho) >>> rp = ResPhase() >>> rp.set_res_phase(rho, phi, f) >>> rp._z.shape (2, 2, 2) """ rho = np.asarray(res_array, dtype=float) phi = np.asarray(phase_array, dtype=float) f = np.asarray(freq, dtype=float) if np.iscomplexobj(res_array): raise ResistivityError("ρ must be real-valued") if np.iscomplexobj(phase_array): raise PhaseError("phase must be real-valued") if f.ndim != 1 or f.size != rho.shape[0]: raise ZError("freq must be 1-D with length equal to ρ.shape[0]") if np.any(f <= 0.0) or not np.all(np.isfinite(f)): raise ZError("freq must be finite and > 0") if not np.all(np.isfinite(rho)) or not np.all(np.isfinite(phi)): raise ZError("ρ and φ must be finite") self._resistivity = rho self._phase = phi self.freq = f self._resistivity_err = ( None if res_err_array is None else np.asarray(res_err_array, dtype=float) ) self._phase_err = ( None if phase_err_array is None else np.asarray(phase_err_array, dtype=float) ) abs_z = np.sqrt(5.0 * f[:, None, None] * rho) self._z = abs_z * np.exp(1j * np.radians(phi)) self._z_err = None if self._resistivity_err is None or self._phase_err is None: return rho_err = np.asarray(self._resistivity_err, dtype=float) phi_err = np.asarray(self._phase_err, dtype=float) if rho_err.shape != rho.shape or phi_err.shape != phi.shape: raise ZError( "error arrays must match shapes of ρ and φ: " f"{rho_err.shape!r} vs {rho.shape!r}, " f"{phi_err.shape!r} vs {phi.shape!r}" ) if not np.all(np.isfinite(rho_err)) or not np.all( np.isfinite(phi_err) ): raise ZError("ρ_err and φ_err must be finite") if np.any(rho_err < 0) or np.any(phi_err < 0): raise ZError("ρ_err and φ_err must be non-negative") with np.errstate(divide="ignore", invalid="ignore"): rel_rho = np.where(rho != 0.0, rho_err / rho, 0.0) abs_z_err = 0.5 * abs_z * rel_rho z_err = np.zeros_like(self._z, dtype=float) for k in range(f.size): for i in range(2): for j in range(2): ex, ey = propagate_error_polar2rect( abs_z[k, i, j], abs_z_err[k, i, j], phi[k, i, j], phi_err[k, i, j], ) z_err[k, i, j] = float(np.hypot(ex, ey)) self._z_err = z_err
# --------------------------- # Convenience component views # --------------------------- def _need_rho(self) -> None: if self._resistivity is None: raise ResistivityError("resistivity not computed/attached") def _need_phi(self) -> None: if self._phase is None: raise PhaseError("phase not computed/attached")
[docs] @property def res_xx(self) -> np.ndarray: self._need_rho() return self._resistivity[:, 0, 0]
[docs] @property def res_xy(self) -> np.ndarray: self._need_rho() return self._resistivity[:, 0, 1]
[docs] @property def res_yx(self) -> np.ndarray: self._need_rho() return self._resistivity[:, 1, 0]
[docs] @property def res_yy(self) -> np.ndarray: self._need_rho() return self._resistivity[:, 1, 1]
[docs] @property def phase_xx(self) -> np.ndarray: self._need_phi() return self._phase[:, 0, 0]
[docs] @property def phase_xy(self) -> np.ndarray: self._need_phi() return self._phase[:, 0, 1]
[docs] @property def phase_yx(self) -> np.ndarray: self._need_phi() return self._phase[:, 1, 0]
[docs] @property def phase_yy(self) -> np.ndarray: self._need_phi() return self._phase[:, 1, 1]
[docs] @property def res_err_xx(self) -> np.ndarray | None: if self._resistivity_err is None: return None return self._resistivity_err[:, 0, 0]
[docs] @property def res_err_xy(self) -> np.ndarray | None: if self._resistivity_err is None: return None return self._resistivity_err[:, 0, 1]
[docs] @property def res_err_yx(self) -> np.ndarray | None: if self._resistivity_err is None: return None return self._resistivity_err[:, 1, 0]
[docs] @property def res_err_yy(self) -> np.ndarray | None: if self._resistivity_err is None: return None return self._resistivity_err[:, 1, 1]
[docs] @property def phase_err_xx(self) -> np.ndarray | None: if self._phase_err is None: return None return self._phase_err[:, 0, 0]
[docs] @property def phase_err_xy(self) -> np.ndarray | None: if self._phase_err is None: return None return self._phase_err[:, 0, 1]
[docs] @property def phase_err_yx(self) -> np.ndarray | None: if self._phase_err is None: return None return self._phase_err[:, 1, 0]
[docs] @property def phase_err_yy(self) -> np.ndarray | None: if self._phase_err is None: return None return self._phase_err[:, 1, 1]
# ------------------------- # Determinant-based metrics # ------------------------- @property def _zdet(self) -> np.ndarray: if self._z is None: raise ZError("Z is not set") return np.array([np.linalg.det(zz) ** 0.5 for zz in self._z]) @property def _zdet_var(self) -> np.ndarray: if self._z_err is not None: return np.array( [abs(np.linalg.det(zzv)) ** 0.5 for zzv in self._z_err] ) return np.ones_like(self._zdet, dtype=float)
[docs] @property def phase_det(self) -> np.ndarray: zd = self._zdet return np.degrees(np.angle(zd))
[docs] @property def phase_det_err(self) -> np.ndarray: zd = self._zdet zv = self._zdet_var with np.errstate(divide="ignore", invalid="ignore"): out = np.arcsin(np.clip(zv / np.abs(zd), -1.0, 1.0)) return np.degrees(out)
[docs] @property def res_det(self) -> np.ndarray: zd = self._zdet f = np.asarray(self.freq, dtype=float) return 0.2 * (np.abs(zd) ** 2) / f
[docs] @property def res_det_err(self) -> np.ndarray: zd = self._zdet zv = self._zdet_var f = np.asarray(self.freq, dtype=float) return 0.2 * (np.abs(zd + zv) ** 2) / f - self.res_det
# Backward-compat aliases ResPhase.compute_rho_phi = ResPhase.compute_resistivity_phase ResPhase.set_rho_phi = ResPhase.set_res_phase