Source code for pycsamt.zonge.z

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0-or-later
"""
Impedance tensor (Z) component for Zonge AVG data.

This module provides the `Z` class, which computes the complex
impedance tensor from apparent resistivity and phase data. It
inherits from TensorBase to provide powerful reshaping and
analysis capabilities.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import (
    Any,
)

import numpy as np
import pandas as pd

from ..constants import MU_0, PI
from ..exceptions import AvgDataError
from ..log.logger import get_logger
from ..utils._dependency import import_optional_dependency
from .tensor import TensorBase, _norm_comp
from .utils import _standardise_columns, _to_num

logger = get_logger(__name__)

__all__ = ["Z"]


[docs] class Z(TensorBase): r"""Complex impedance tensor (Z) component. This class reads a tidy AVG table and computes the complex impedance Z from apparent resistivity (:math:`\rho_a`) and impedance phase (:math:`\phi`). It provides properties to access the complex tensor, its real and imaginary parts, and the propagated error. The impedance is calculated using the standard formula [1]_: .. math:: Z = \sqrt{\rho_a \cdot \omega \cdot \mu_0} \cdot e^{i \cdot \phi} where :math:`\omega = 2\pi f`. Attributes ---------- z : pd.Series The complex impedance :math:`Z` in Ohms [Ω]. z_real, z_imag : pd.Series The real and imaginary parts of the impedance tensor. z_err : pd.Series The propagated error in the magnitude of :math:`Z`, :math:`|dZ|`, in Ohms [Ω]. z_xx, z_xy, z_yx, z_yy : pd.Series Convenience properties to access the individual components of the complex impedance tensor. Examples -------- >>> from pycsamt.zonge import Z >>> from pycsamt.zonge.avg import AVG >>> avg = AVG.from_file('data/avg/K2.avg') >>> z_component = avg.z >>> # Get the complex impedance for all measurements >>> complex_z_values = z_component.z >>> # Get only the Z_xy component >>> z_xy = z_component.z_xy References ---------- .. [1] Vozoff, K. (1972). The magnetotelluric method in the exploration of sedimentary basins. Geophysics, 37(1), 98-141. See Also -------- TensorBase : The parent class providing tensor-shaping logic. """
[docs] def read( self, source: pd.DataFrame, meta: Mapping[str, Any] | None = None, **kws: Any, ) -> None: """ Read and prepare data for impedance calculation. """ if not isinstance(source, pd.DataFrame): raise TypeError("Z.read expects a pandas.DataFrame.") # df = source.copy() df = _standardise_columns(source.copy()) self._meta = dict(meta or {}) # # --- Required columns --- # missing = [k for k, v in required.items() if v is None] required = ["rho", "phase", "freq"] missing = [c for c in required if c not in df.columns] if missing: raise AvgDataError(f"Z: missing required columns: {missing}") # --- Optional error columns --- optional_qc = ["pc_rho", "s_phz"] for col in optional_qc: if col not in df.columns: df[col] = np.nan if self.verbose: self._logger.debug( f"'{col}' not in source. Creating empty." ) # Ensure coords exist if "station" not in df.columns: df["station"] = np.nan if "comp" not in df.columns: df["comp"] = "ExHy" # Normalize component labels to uppercase canonical form df["comp"] = df["comp"].map(_norm_comp) df.dropna(subset=["comp"], inplace=True) # Normalize types for col in ["rho", "phase", "freq", "pc_rho", "s_phz"]: if col in df.columns: df[col] = df[col].map(_to_num) keep_cols = [ "station", "freq", "comp", "rho", "phase", "pc_rho", "s_phz", ] self._frame = df.loc[:, [c for c in keep_cols if c in df.columns]] return self
def _get_component_series( self, comp_names: tuple[str, ...], series: pd.Series ) -> pd.Series: """Helper to filter a property series by component.""" if self._frame.empty or "comp" not in self._frame.columns: return pd.Series(dtype=series.dtype) mask = self._frame["comp"].isin(comp_names) return series[mask]
[docs] @property def z(self) -> pd.Series: """ Complex impedance Z [Ω]. """ if self._frame.empty: return pd.Series(dtype="complex128") rho = self._frame["rho"] phase_mrad = self._frame["phase"] freq = self._frame["freq"] # Convert phase from milliradians to radians phase_rad = phase_mrad * 1e-3 omega = 2 * PI * freq # Calculate magnitude of Z z_mag = np.sqrt(rho * omega * MU_0) # Calculate complex impedance return z_mag * np.exp(1j * phase_rad)
[docs] @property def z_real(self) -> pd.Series: """ Real part of the impedance tensor, Z' [Ω]. """ return self.z.apply(np.real)
[docs] @property def z_imag(self) -> pd.Series: """ Imaginary part of the impedance tensor, Z'' [Ω]. """ return self.z.apply(np.imag)
[docs] @property def z_err(self) -> pd.Series: r""" Propagated error in the magnitude of Z, |dZ| [Ω]. Calculated via standard error propagation from the relative error in resistivity (dρ/ρ) and the absolute error in phase (dφ). .. math:: |dZ| \approx \sqrt{ (\frac{\partial |Z|}{\partial \rho} d\rho)^2 + (|Z| d\phi)^2 } Since phase errors are often dominant and uncorrelated, a simpler estimate is often used: .. math:: |dZ| \approx \frac{1}{2} |Z| \frac{d\rho}{\rho} """ if self._frame.empty or "rho" not in self._frame.columns: return pd.Series(dtype="float64") has_rho_err = "pc_rho" in self._frame.columns has_phi_err = "s_phz" in self._frame.columns if not has_rho_err and not has_phi_err: return pd.Series(dtype="float64", index=self._frame.index) z_mag = np.sqrt( self._frame["rho"] * (2 * PI * self._frame["freq"]) * MU_0 ) term_rho_sq = 0.0 if has_rho_err: # Relative error drho/rho rel_err_rho = self._frame["pc_rho"] / 100.0 term_rho_sq = (0.5 * rel_err_rho) ** 2 term_phi_sq = 0.0 if has_phi_err: dphi_rad = self._frame["s_phz"] * 1e-3 term_phi_sq = dphi_rad**2 # Propagated error in magnitude |Z| # d|Z| = 0.5 * |Z| * (drho/rho) return z_mag * np.sqrt(term_rho_sq + term_phi_sq)
[docs] @property def z_xx(self) -> pd.Series: """Complex impedance for the Zxx component.""" return self._get_component_series(("ZXX", "ExHX"), self.z)
[docs] @property def z_xy(self) -> pd.Series: """Complex impedance for the Zxy component.""" return self._get_component_series(("ZXY", "EXHY"), self.z)
[docs] @property def z_yx(self) -> pd.Series: """Complex impedance for the Zyx component.""" return self._get_component_series(("ZYX", "EYHX"), self.z)
[docs] @property def z_yy(self) -> pd.Series: """Complex impedance for the Zyy component.""" return self._get_component_series(("ZYY", "EYHY"), self.z)
[docs] @property def z_xx_err(self) -> pd.Series: """Propagated error for the Zxx component.""" return self._get_component_series(("ZXX", "EXHX"), self.z_err)
[docs] @property def z_xy_err(self) -> pd.Series: """Propagated error for the Zxy component.""" return self._get_component_series(("ZXY", "EXHY"), self.z_err)
[docs] @property def z_yx_err(self) -> pd.Series: """Propagated error for the Zyx component.""" return self._get_component_series(("ZYX", "EYHX"), self.z_err)
[docs] @property def z_yy_err(self) -> pd.Series: """Propagated error for the Zyy component.""" return self._get_component_series(("ZYY", "EYHY"), self.z_err)
[docs] def to_tensor( self, *, var: str = "z", station: int | float | None = None, agg: str | None = "mean", fill_value: float = np.nan, sort_freq: bool = True, align: str = "union", ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Convert impedance data into a 2x2 tensor. """ temp_frame = self._frame.copy() if var == "z": temp_frame["__real"] = self.z_real temp_frame["__imag"] = self.z_imag # Create a temporary instance for base method call tb = TensorBase() tb._frame = temp_frame T_real, freqs, stations = tb.to_tensor( var="__real", station=station, agg=agg, fill_value=fill_value, sort_freq=sort_freq, align=align, ) T_imag, _, _ = tb.to_tensor( var="__imag", station=station, agg=agg, fill_value=fill_value, sort_freq=sort_freq, align=align, ) return T_real + 1j * T_imag, freqs, stations elif var in ("z_real", "z_imag", "z_err"): temp_frame[var] = getattr(self, var) tb = TensorBase() tb._frame = temp_frame return tb.to_tensor( var=var, station=station, agg=agg, fill_value=fill_value, sort_freq=sort_freq, align=align, ) else: return super().to_tensor( var=var, station=station, agg=agg, fill_value=fill_value, sort_freq=sort_freq, align=align, )
[docs] def to_xarray( self, *, var: str = "z", station: int | float | None = None, agg: str | None = "mean", fill_value: float = np.nan, attrs: Mapping[str, Any] | None = None, ): """ Return a 3D or 4D xarray.DataArray. """ import_optional_dependency( "xarray", extra="xarray is required for to_xarray()", errors="raise", ) import xarray as xr # Use the new to_tensor method to get the data T, freqs, stations = self.to_tensor( var=var, station=station, agg=agg, fill_value=fill_value ) e_axis = np.array(["Ex", "Ey"]) h_axis = np.array(["Hx", "Hy"]) merged_attrs = dict(self._meta) if attrs: merged_attrs.update(attrs) if stations.size == 0: da = xr.DataArray( T, dims=("freq", "e", "h"), coords={"freq": freqs, "e": e_axis, "h": h_axis}, attrs=merged_attrs, name=var, ) else: da = xr.DataArray( T, dims=("station", "freq", "e", "h"), coords={ "station": stations, "freq": freqs, "e": e_axis, "h": h_axis, }, attrs=merged_attrs, name=var, ) return da
[docs] def write(self) -> Sequence[str]: """ Serializes the core Z data to a CSV block. """ if self._frame.empty: return ["\\ $Z (Impedance) Block", ""] # Create a temporary frame for writing df_write = self._frame[["station", "freq", "comp"]].copy() df_write["z_real"] = self.z_real df_write["z_imag"] = self.z_imag df_write["z_err"] = self.z_err return self._write_csv_block( cols=list(df_write.columns), title="$Z (Impedance) Block", include_meta=True, stamp=True, )
def __str__(self) -> str: """Provide a concise, human-readable representation.""" if self._frame.empty: return "Z(status=empty)" # Safely get unique counts for the summary n_st = ( self._frame["station"].nunique() if "station" in self._frame.columns else 0 ) n_frq = ( self._frame["freq"].nunique() if "freq" in self._frame.columns else 0 ) n_comp = ( self._frame["comp"].nunique() if "comp" in self._frame.columns else 0 ) return ( f"Z(rows={len(self._frame)}, " f"stations={n_st}, " f"freqs={n_frq}, " f"components={n_comp})" ) def __repr__(self) -> str: """Provide an unambiguous developer representation.""" # For this class, a detailed __str__ is also a good __repr__ return self.__str__()