Source code for pycsamt.zonge.proc_utils

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0-or-later
"""
pycsamt.zonge.proc_utils
------------------------

This module provides the core filtering algorithms used for
static shift correction, mirroring the methods described in the
Zonge ASTATIC program manual.
"""

from __future__ import annotations

import warnings
from collections.abc import Iterable
from typing import (
    Literal,
    overload,
)

import numpy as np
import pandas as pd
from scipy.interpolate import UnivariateSpline, interp1d
from scipy.signal import hilbert
from scipy.signal.windows import hann
from scipy.stats import trim_mean

from ..constants import MU_0, PI
from ..decorators import isdf
from ..exceptions import ProcessingError

__all__ = [
    "tma",
    "flma",
    "ama",
    "interpolate_to_log_space",
    "smooth_rho_from_phase",
    "get_reference_frequency",
    "get_skew",
    "get_strike",
    "prepare_strike_frame",
]


@overload
def tma(
    rho_profile: str,
    *,
    data: pd.DataFrame,
    window_size: int = 5,
    trim_proportion: float = 0.2,
) -> pd.DataFrame: ...


@overload
def tma(
    rho_profile: np.ndarray,
    *,
    data: None = None,
    window_size: int = 5,
    trim_proportion: float = 0.2,
) -> np.ndarray: ...


@overload
def tma(
    rho_profile: pd.Series,
    *,
    data: None = None,
    window_size: int = 5,
    trim_proportion: float = 0.2,
) -> pd.Series: ...


[docs] def tma( rho_profile: pd.Series | np.ndarray | str, *, data: pd.DataFrame | None = None, window_size: int = 5, trim_proportion: float = 0.2, ) -> pd.Series | np.ndarray | pd.DataFrame: r"""Apply a Trimmed Moving Average (TMA) filter. This filter is designed to remove single-station static offsets while preserving broader geological trends. It can operate on a pandas Series, a NumPy array, or a column within a pandas DataFrame. Parameters ---------- rho_profile : pd.Series, np.ndarray, or str The apparent resistivity data to be filtered. This can be: - A pandas Series of resistivity values. - A 1D NumPy array of resistivity values. - The string name of the column to use if `data` is provided. data : pd.DataFrame, optional A DataFrame containing the resistivity data. Required if `rho_profile` is provided as a string. window_size : int, default 5 The size of the moving window for the filter. Must be an odd integer. trim_proportion : float, default 0.2 The proportion of observations to trim from each end of the window before computing the mean. For a window of 5, 0.2 trims 1 value from each end (the min and max). Returns ------- pd.Series, np.ndarray, or pd.DataFrame The smoothed resistivity profile, returned in the same format as the input (`rho_profile`). If a DataFrame was passed to `data`, the function returns a new DataFrame with an added column for the smoothed data. Raises ------ ValueError If `rho_profile` is a string but `data` is not provided, or if `window_size` is not an odd integer. """ if window_size % 2 == 0: raise ValueError("`window_size` must be an odd integer.") input_type = "series" # Default if isinstance(rho_profile, str): if data is None: raise ValueError( "A DataFrame must be provided via the 'data' " "parameter when 'rho_profile' is a string." ) series = data[rho_profile] input_type = "dataframe" elif isinstance(rho_profile, np.ndarray): series = pd.Series(rho_profile) input_type = "array" else: series = rho_profile # Perform the rolling trimmed mean calculation smoothed_series = series.rolling( window=window_size, center=True, min_periods=window_size // 2 + 1 ).apply(lambda x: trim_mean(x.dropna(), trim_proportion)) # Return the data in the same format as the input if input_type == "dataframe": df_out = data.copy() df_out[f"{rho_profile}_tma"] = smoothed_series return df_out elif input_type == "array": return smoothed_series.to_numpy() return smoothed_series
@overload def flma( z_profile: str, stations: str, dipole_length: float, *, data: pd.DataFrame, filter_width_dipoles: float = 5.0, ) -> pd.DataFrame: ... @overload def flma( z_profile: np.ndarray, stations: np.ndarray, dipole_length: float, *, data: None = None, filter_width_dipoles: float = 5.0, ) -> np.ndarray: ... @overload def flma( z_profile: pd.Series, stations: pd.Series, dipole_length: float, *, data: None = None, filter_width_dipoles: float = 5.0, ) -> pd.Series: ...
[docs] def flma( z_profile: pd.Series | np.ndarray | str, stations: pd.Series | np.ndarray | str, dipole_length: float, *, data: pd.DataFrame | None = None, filter_width_dipoles: float = 5.0, ) -> pd.Series | np.ndarray | pd.DataFrame: r"""Apply a Fixed-Length Moving Average (FLMA) filter. This filter smooths complex impedance data using a spatial Hanning window whose width is a fixed multiple of the receiver dipole length. Parameters ---------- z_profile : pd.Series, np.ndarray, or str The complex impedance data (Z) to be filtered. Can be: - A pandas Series of complex impedance values. - A 1D NumPy array of complex impedance values. - The string name of the column if `data` is provided. stations : pd.Series, np.ndarray, or str The station locations (coordinates). Must correspond to the `z_profile` data. Can be a Series, array, or column name if `data` is provided. dipole_length : float The length of the E-field receiver dipole in the same units as the station locations. data : pd.DataFrame, optional A DataFrame containing the impedance and station data. Required if `z_profile` or `stations` are strings. filter_width_dipoles : float, default 5.0 The total width of the Hanning window, expressed in multiples of the `dipole_length`. Returns ------- pd.Series, np.ndarray, or pd.DataFrame The smoothed complex impedance profile, returned in the same format as the input. If a DataFrame was passed to `data`, a new DataFrame with an added column for the smoothed data is returned. """ # --- Input Validation and Standardization --- input_type = "series" z_series: pd.Series stn_series: pd.Series if isinstance(z_profile, str): if data is None or not isinstance(data, pd.DataFrame): raise ValueError( "A DataFrame must be provided via the 'data' " "parameter when 'z_profile' is a string." ) z_series = data[z_profile] stn_series = data[stations] input_type = "dataframe" elif isinstance(z_profile, np.ndarray): z_series = pd.Series(z_profile) stn_series = pd.Series(stations) input_type = "array" else: z_series = z_profile stn_series = stations # --- Core Filtering Logic --- smoothed_z = pd.Series(index=z_series.index, dtype="complex128") filter_radius = (filter_width_dipoles / 2.0) * dipole_length for i, stn_center in enumerate(stn_series): window_min = stn_center - filter_radius window_max = stn_center + filter_radius window_mask = (stn_series >= window_min) & (stn_series <= window_max) z_in_window = z_series[window_mask] if z_in_window.empty or z_in_window.isnull().all(): smoothed_z.iloc[i] = np.nan continue weights = hann(len(z_in_window)) smoothed_z.iloc[i] = np.average(z_in_window.dropna(), weights=weights) # --- Return data in the original format --- if input_type == "dataframe": df_out = data.copy() df_out[f"{z_profile}_flma"] = smoothed_z return df_out elif input_type == "array": return smoothed_z.to_numpy() return smoothed_z
@overload def ama( z_profile: str, stations: str, dipole_length: float, frequency: float, *, data: pd.DataFrame, skin_depth_factor: float = 2.0, iterations: int = 3, ) -> pd.DataFrame: ... @overload def ama( z_profile: np.ndarray, stations: np.ndarray, dipole_length: float, frequency: float, *, data: None = None, skin_depth_factor: float = 2.0, iterations: int = 3, ) -> np.ndarray: ... @overload def ama( z_profile: pd.Series, stations: pd.Series, dipole_length: float, frequency: float, *, data: None = None, skin_depth_factor: float = 2.0, iterations: int = 3, ) -> pd.Series: ...
[docs] def ama( z_profile: pd.Series | np.ndarray | str, stations: pd.Series | np.ndarray | str, dipole_length: float, frequency: float, *, data: pd.DataFrame | None = None, skin_depth_factor: float = 2.0, iterations: int = 3, ) -> pd.Series | np.ndarray | pd.DataFrame: r"""Apply an Adaptive Moving Average (AMA) filter. This filter smooths complex impedance data using a spatial Hanning window whose width is adapted iteratively based on the local skin depth. Parameters ---------- z_profile : pd.Series, np.ndarray, or str The complex impedance data (Z) to be filtered. stations : pd.Series, np.ndarray, or str The station locations (coordinates). dipole_length : float The length of the E-field receiver dipole. frequency : float The frequency of the data in Hz. data : pd.DataFrame, optional A DataFrame containing the impedance and station data. Required if `z_profile` or `stations` are strings. skin_depth_factor : float, default 2.0 The filter width, expressed as a multiple of the local skin depth. iterations : int, default 3 The number of iterations to perform to allow the filter width to adapt to the smoothed data. Returns ------- pd.Series, np.ndarray, or pd.DataFrame The smoothed complex impedance profile, returned in the same format as the input. """ # --- Input Validation and Standardization --- input_type = "series" z_series: pd.Series stn_series: pd.Series if isinstance(z_profile, str): if data is None or not isinstance(data, pd.DataFrame): raise ValueError( "A DataFrame must be provided via 'data' when " "'z_profile' is a string." ) z_series = data[z_profile] stn_series = data[stations] input_type = "dataframe" elif isinstance(z_profile, np.ndarray): z_series = pd.Series(z_profile) stn_series = pd.Series(stations) input_type = "array" else: z_series = z_profile stn_series = stations # --- Core Filtering Logic --- omega = 2 * PI * frequency smoothed_z = z_series.copy() for _ in range(iterations): rho_local = (np.abs(smoothed_z) ** 2) / (omega * MU_0) skin_depth = 503 * np.sqrt(rho_local / frequency) filter_radius = (skin_depth_factor / 2.0) * skin_depth current_pass_z = pd.Series(index=z_series.index, dtype="complex128") for i, stn_center in enumerate(stn_series): radius = filter_radius.iloc[i] window_min = stn_center - radius window_max = stn_center + radius window_mask = (stn_series >= window_min) & ( stn_series <= window_max ) z_in_window = smoothed_z[window_mask] if z_in_window.empty or z_in_window.isnull().all(): current_pass_z.iloc[i] = np.nan continue weights = hann(len(z_in_window)) current_pass_z.iloc[i] = np.average( z_in_window.dropna(), weights=weights ) smoothed_z = current_pass_z # --- Return data in the original format --- if input_type == "dataframe": df_out = data.copy() df_out[f"{z_profile}_ama"] = smoothed_z return df_out elif input_type == "array": return smoothed_z.to_numpy() return smoothed_z
[docs] @isdf def interpolate_to_log_space( df: pd.DataFrame, *, freq_min: float | None = None, freq_max: float | None = None, num_points: int = 50, interp_kind: str = "cubic", ) -> pd.DataFrame: r"""Interpolate AVG data onto a regular log-spaced grid. This function resamples the apparent resistivity and phase data for each station and component onto a new, logarithmically spaced frequency axis. This is a common preprocessing step for inversion and modeling. Parameters ---------- df : pandas.DataFrame The input DataFrame containing the AVG data. Must include 'station', 'freq', 'rho', and 'phase' columns. freq_min : float, optional The minimum frequency for the new grid. If ``None``, it is inferred from the minimum frequency in the data. freq_max : float, optional The maximum frequency for the new grid. If ``None``, it is inferred from the maximum frequency in the data. num_points : int, default 50 The number of logarithmically spaced points to create between `freq_min` and `freq_max`. interp_kind : str, default 'cubic' The kind of interpolation to perform, passed to `scipy.interpolate.interp1d`. Common options are 'slinear', 'quadratic', and 'cubic'. Returns ------- pandas.DataFrame A new DataFrame with the data interpolated onto the specified logarithmic frequency grid. """ required_cols = ["station", "freq", "rho", "phase", "comp"] if not all(col in df.columns for col in required_cols): raise ProcessingError( "Input DataFrame must contain 'station', 'freq', 'rho', " "'phase', and 'comp' columns." ) # Determine frequency range for the new grid f_min = freq_min or df["freq"].min() f_max = freq_max or df["freq"].max() new_freq_log = np.logspace(np.log10(f_min), np.log10(f_max), num_points) interpolated_data = [] # Group by each unique sounding curve for (station, comp), group in df.groupby(["station", "comp"]): # Sort by frequency to ensure correct interpolation order sounding = group.sort_values("freq").dropna(subset=["rho", "phase"]) if len(sounding) < 2: continue # Cannot interpolate with fewer than 2 points log_freq = np.log(sounding["freq"]) log_rho = np.log(sounding["rho"]) phase_rad = sounding["phase"] * 1e-3 # to radians # Create interpolation functions interp_rho = interp1d( log_freq, log_rho, kind=interp_kind, bounds_error=False, fill_value=np.nan, ) interp_phase = interp1d( log_freq, phase_rad, kind=interp_kind, bounds_error=False, fill_value=np.nan, ) # Evaluate at the new frequency points new_rho_log = interp_rho(np.log(new_freq_log)) new_phase_rad = interp_phase(np.log(new_freq_log)) # Assemble the new DataFrame for this sounding interp_sounding = pd.DataFrame( { "station": station, "comp": comp, "freq": new_freq_log, "rho": np.exp(new_rho_log), "phase": new_phase_rad * 1000.0, # back to mrad } ) interpolated_data.append(interp_sounding) if not interpolated_data: return pd.DataFrame() # Return empty if no data was processed return pd.concat(interpolated_data, ignore_index=True)
[docs] @isdf def smooth_rho_from_phase( df: pd.DataFrame, *, smoothing_factor: float = 0.1, ) -> pd.DataFrame: r"""Smooth apparent resistivity using the Hilbert transform. This function reconstructs a smoother apparent resistivity curve from the impedance phase data, leveraging the causal nature of magnetotelluric data. Parameters ---------- df : pandas.DataFrame The input DataFrame. Must include 'station', 'freq', 'rho', and 'phase' columns. smoothing_factor : float, default 0.1 The smoothing factor for the `UnivariateSpline` applied to the phase data before the Hilbert transform. A smaller value results in less smoothing. Returns ------- pandas.DataFrame A new DataFrame with a 'rho_smoothed' column containing the reconstructed, smoother apparent resistivity values. """ required_cols = ["station", "freq", "rho", "phase", "comp"] if not all(col in df.columns for col in required_cols): raise ProcessingError( "Input DataFrame must contain 'station', 'freq', 'rho', " "'phase', and 'comp' columns." ) df_out = df.copy() df_out["rho_smoothed"] = np.nan # Group by each unique sounding curve for (_station, _comp), group in df.groupby(["station", "comp"]): sounding = group.sort_values("freq").dropna(subset=["rho", "phase"]) if len(sounding) < 5: # Need enough points for spline continue log_freq = np.log(sounding["freq"]) phase_rad = sounding["phase"] * 1e-3 # to radians # 1. Smooth the phase data with a spline spline = UnivariateSpline(log_freq, phase_rad, s=smoothing_factor) phase_smooth = spline(log_freq) # 2. Apply Hilbert transform to get the imaginary part of ln(Z) # The imaginary part of log(Z) is the phase imag_log_z = phase_smooth # 3. The real part of log(Z) is the Hilbert transform of the phase real_log_z = -np.imag(hilbert(imag_log_z)) # 4. Reconstruct the impedance magnitude log_z_mag = real_log_z z_mag_smooth = np.exp(log_z_mag) # 5. Calculate the new, smoother apparent resistivity omega = 2 * PI * sounding["freq"] rho_smooth = (z_mag_smooth**2) / (omega * MU_0) # Place the smoothed values back into the output DataFrame df_out.loc[sounding.index, "rho_smoothed"] = rho_smooth return df_out
[docs] @isdf def get_reference_frequency( df: pd.DataFrame, mode: str | float = "auto", *, qc_column: str = "pc_rho", qc_threshold: float = 20.0, ) -> float: r"""Determine a suitable reference frequency for static shift. This utility automatically selects a reference frequency based on the principle of using the "highest frequency with clean data," as recommended in the ASTATIC manual. Parameters ---------- df : pandas.DataFrame The input DataFrame containing the AVG data. Must include 'station', 'freq', and a quality control column. mode : {'auto'} or float, default 'auto' The method for determining the reference frequency. - 'auto': Automatically select the frequency based on QC metrics. - float: A specific frequency value to use, which will be returned directly. qc_column : str, default 'pc_rho' The canonical name of the quality control column to use for filtering "clean" data points. qc_threshold : float, default 20.0 The threshold for the `qc_column`. Data points where the value in this column is *below* the threshold are considered clean. Returns ------- float The determined reference frequency in Hz. Notes ----- The 'auto' mode follows a robust, data-driven approach: 1. It filters the dataset to include only "clean" data points (where `qc_column` < `qc_threshold`). 2. For each station, it finds the highest frequency that has clean data. 3. It then returns the **median** of these highest frequencies. The median is used to provide a stable estimate that is robust against outlier stations. If no data points meet the clean criteria, the function will issue a warning and fall back to using the absolute maximum frequency present in the entire dataset. """ if isinstance(mode, (int, float)): return float(mode) required_cols = ["station", "freq", qc_column] if not all(col in df.columns for col in required_cols): raise ProcessingError( "Input DataFrame must contain 'station', 'freq', and " f"the specified qc_column ('{qc_column}')." ) # 1. Filter for "clean" data points clean_df = df[df[qc_column] < qc_threshold] if clean_df.empty: warnings.warn( f"No data found with '{qc_column}' < {qc_threshold}. " "Falling back to the absolute maximum frequency.", stacklevel=2, ) return df["freq"].max() # 2. Find the highest clean frequency for each station max_freq_per_station = clean_df.groupby("station")["freq"].max() # 3. Return the median of these frequencies for robustness return max_freq_per_station.median()
[docs] @isdf def get_strike(df: pd.DataFrame) -> pd.DataFrame: r"""Calculate the geoelectric strike angle. This function determines the principal axis direction of the impedance tensor for each station and frequency. The strike is the angle to which the coordinate system must be rotated to minimize the diagonal components of the impedance tensor (:math:`Z_{xx}`, :math:`Z_{yy}`), which is a common assumption for 2D geological structures. Parameters ---------- df : pandas.DataFrame A tidy DataFrame containing the impedance tensor components. Must include 'station', 'freq', 'comp', and a complex 'z' column from which the tensor elements can be pivoted. Returns ------- pandas.DataFrame A DataFrame with columns for 'station', 'freq', and the calculated 'strike_angle' in degrees clockwise from North. Notes ----- The calculation is performed using a common tensor decomposition method based on the real parts of the impedance tensor components [1]_. The formula used is: .. math:: \theta_s = \frac{1}{2} \arctan\left( \frac{\text{Re}(Z_{xy} + Z_{yx})} {\text{Re}(Z_{xx} - Z_{yy})} \right) This angle represents the orientation of the primary 2D geological structure. Examples -------- >>> from pycsamt.zonge import AMTAVG >>> from pycsamt.zonge.proc_utils import get_strike >>> avg = AMTAVG.from_file('data/avg/K2.avg') >>> # The Z component's frame already has the required columns >>> strike_df = get_strike(avg.z.frame) >>> print(strike_df.head()) References ---------- .. [1] Simpson, F., & Bahr, K. (2005). *Practical Magnetotellurics*. Cambridge University Press. See Also -------- get_skew : Calculate the dimensionality indicator (skew). pycsamt.zonge.avg.AMTAVG.rotate : Rotate the tensor by a given angle. """ required = ["station", "freq", "comp", "z"] if not all(c in df.columns for c in required): raise ProcessingError( f"Input DataFrame is missing one of required columns: {required}" ) # Pivot to get tensor components for each measurement point # tensor_df = df.pivot_table( # index=['station', 'freq'], columns='comp', values='z' # ).reset_index() # Split complex data before pivoting --- df_copy = df.copy() df_copy["z_real"] = np.real(df_copy["z"]) df_copy["z_imag"] = np.imag(df_copy["z"]) tensor_df_real = df_copy.pivot_table( index=["station", "freq"], columns="comp", values="z_real" ) tensor_df_imag = df_copy.pivot_table( index=["station", "freq"], columns="comp", values="z_imag" ) # Recombine into a complex tensor after pivoting tensor_df = (tensor_df_real + 1j * tensor_df_imag).reset_index() # Ensure all four components are present for comp in ["ExHx", "ExHy", "EyHx", "EyHy"]: if comp not in tensor_df.columns: tensor_df[comp] = 0 + 0j zxx, zxy = tensor_df["ExHx"], tensor_df["ExHy"] zyx, zyy = tensor_df["EyHx"], tensor_df["EyHy"] # Calculate strike angle using tensor decomposition real_a = np.real(zxy + zyx) real_b = np.real(zxx - zyy) strike_rad = 0.5 * np.arctan2(real_a, real_b) strike_deg = np.rad2deg(strike_rad) return pd.DataFrame( { "station": tensor_df["station"], "freq": tensor_df["freq"], "strike_angle": strike_deg, } )
[docs] @isdf def get_skew(df: pd.DataFrame) -> pd.DataFrame: r"""Calculate Swift's skew for the impedance tensor. Skew is a dimensionless parameter that quantifies the three-dimensionality of the subsurface conductivity structure. It is a crucial diagnostic tool for determining whether a 1D or 2D interpretation is appropriate for the data. Parameters ---------- df : pandas.DataFrame A tidy DataFrame containing the impedance tensor components. Must include 'station', 'freq', 'comp', and a complex 'z' column. Returns ------- pandas.DataFrame A DataFrame with columns for 'station', 'freq', and the calculated 'skew'. Notes ----- The function calculates the conventional skew definition proposed by Swift [1]_. The formula is: .. math:: \text{skew} = \frac{|Z_{xx} + Z_{yy}|}{|Z_{xy} - Z_{yx}|} Generally, skew values are interpreted as follows: - **skew < 0.1**: Data can be considered 1D. - **0.1 <= skew <= 0.3**: Data is likely 2D. - **skew > 0.3**: Data is likely 3D or affected by significant local distortion. Examples -------- >>> from pycsamt.zonge import AMTAVG >>> from pycsamt.zonge.proc_utils import get_skew >>> avg = AMTAVG.from_file('data/avg/K2.avg') >>> skew_df = get_skew(avg.z.frame) >>> print(skew_df.head()) References ---------- .. [1] Swift, C. M. (1967). A magnetotelluric investigation of an electrical conductivity anomaly in the southwestern United States. Ph.D. Thesis, MIT. See Also -------- get_strike : Calculate the geoelectric strike angle. """ required = ["station", "freq", "comp", "z"] if not all(c in df.columns for c in required): raise ProcessingError( f"Input DataFrame is missing one of required columns: {required}" ) # Pivot to get tensor components for each measurement point df_copy = df.copy() df_copy["z_real"] = np.real(df_copy["z"]) df_copy["z_imag"] = np.imag(df_copy["z"]) tensor_df_real = df_copy.pivot_table( index=["station", "freq"], columns="comp", values="z_real" ) tensor_df_imag = df_copy.pivot_table( index=["station", "freq"], columns="comp", values="z_imag" ) # Recombine into a complex tensor after pivoting tensor_df = (tensor_df_real + 1j * tensor_df_imag).reset_index() # Ensure all four components are present for comp in ["ExHx", "ExHy", "EyHx", "EyHy"]: if comp not in tensor_df.columns: tensor_df[comp] = 0 + 0j zxx, zxy = tensor_df["ExHx"], tensor_df["ExHy"] zyx, zyy = tensor_df["EyHx"], tensor_df["EyHy"] # Calculate Swift's skew numerator = np.abs(zxx + zyy) denominator = np.abs(zxy - zyx) # Avoid division by zero skew = np.divide( numerator, denominator, out=np.full_like(numerator, np.nan, dtype=float), where=(denominator != 0), ) return pd.DataFrame( { "station": tensor_df["station"], "freq": tensor_df["freq"], "skew": skew, } )
[docs] def prepare_strike_frame( *, z_frame: pd.DataFrame | None = None, df: pd.DataFrame | None = None, prefer: Literal["z", "df"] = "z", station_col: str = "station", freq_col: str = "freq", comp_col: str = "comp", z_col: str = "z", rho_col: str = "rho", phase_col: str = "phase", phase_unit: Literal["auto", "deg", "mrad", "rad"] = "auto", drop_na: bool = True, na_policy: Literal["any", "all"] = "any", components: Iterable[str] | None = None, ensure_sorted: bool = True, copy: bool = True, mu0: float = 4.0 * np.pi * 1e-7, ) -> pd.DataFrame: r""" Build a strike-ready frame with complex ``z``. Prefers an input frame that already contains a complex impedance column. If not available, derives ``z`` from ``rho``, ``phase`` and ``freq``: .. math:: |Z| = \sqrt{\mu_0 \, \omega \, \rho_a},\quad Z = |Z|\,[\cos\phi + i\sin\phi] Parameters ---------- z_frame : DataFrame, optional Frame that already holds a complex column ``z``. df : DataFrame, optional Long frame with columns for apparent resistivity, phase and frequency (``rho``, ``phase``, ``freq``). prefer : {"z","df"}, default "z" Source priority when both frames are given. station_col, freq_col, comp_col, z_col, rho_col, phase_col Column names in provided frames. phase_unit : {"auto","deg","mrad","rad"}, default "auto" Unit for ``phase``. ``"auto"`` guesses: - ``|phase|max <= π*1.5`` → rad - else if ``<= 180`` → deg - else → mrad drop_na : bool, default True Drop NA rows before computing. na_policy : {"any","all"}, default "any" Row-drop policy when ``drop_na=True``. components : iterable of str, optional If given, keep only these components. ensure_sorted : bool, default True Sort output by (station, freq, comp). copy : bool, default True Work on a copy of the source frame(s). mu0 : float, default 4π·1e−7 Magnetic permeability of free space. Returns ------- DataFrame Columns: ``station``, ``freq``, ``comp``, ``z``. Raises ------ ProcessingError If required columns are missing or inputs are not provided. """ need_base = {station_col, freq_col, comp_col} def _subset(fr: pd.DataFrame, cols: list[str]) -> pd.DataFrame: miss = [c for c in cols if c not in fr.columns] if miss: raise ProcessingError(f"Missing columns: {miss}") out = fr.loc[:, cols] return out.copy() if copy else out def _mask_components(fr: pd.DataFrame) -> pd.DataFrame: if components is None: return fr return fr[fr[comp_col].isin(list(components))] # -------------------- try z_frame path -------------------- # if z_frame is not None and prefer == "z": cols = [station_col, freq_col, comp_col, z_col] try: out = _subset(z_frame, cols) out = _mask_components(out) if drop_na: out = out.dropna(how=na_policy) # try to ensure complex dtype if not np.issubdtype(out[z_col].dtype, np.complexfloating): out[z_col] = out[z_col].astype(complex) out = out.rename( columns={ station_col: "station", freq_col: "freq", comp_col: "comp", z_col: "z", } ) if ensure_sorted: out = out.sort_values(["station", "freq", "comp"]) return out except ProcessingError: pass # fall back to df path # --------------------- try df (derive) -------------------- # if df is not None: cols = list(need_base | {rho_col, phase_col, freq_col}) out = _subset(df, cols) out = _mask_components(out) if drop_na: out = out.dropna(how=na_policy) # coerce numerics for c in (rho_col, phase_col, freq_col): out[c] = pd.to_numeric(out[c], errors="coerce") if drop_na: out = out.dropna(how=na_policy) if out.empty: raise ProcessingError("No data after NA drop.") # magnitude from apparent resistivity (SI) f = out[freq_col].to_numpy(float) r = out[rho_col].to_numpy(float) # avoid negative / zero f = np.clip(f, 1e-12, np.inf) r = np.clip(r, 0.0, np.inf) omega = 2.0 * np.pi * f zmag = np.sqrt(mu0 * omega * r) # phase → radians ph = out[phase_col].to_numpy(float) if phase_unit == "deg": ph_rad = np.deg2rad(ph) elif phase_unit == "mrad": ph_rad = ph / 1000.0 elif phase_unit == "rad": ph_rad = ph else: # auto max_abs = np.nanmax(np.abs(ph)) if ph.size else 0.0 if max_abs <= np.pi * 1.5: ph_rad = ph # already radians elif max_abs <= 180.0: ph_rad = np.deg2rad(ph) else: ph_rad = ph / 1000.0 zc = zmag * (np.cos(ph_rad) + 1j * np.sin(ph_rad)) ret = out.loc[:, [station_col, freq_col, comp_col]] ret = ret.copy() if copy else ret ret["z"] = zc ret = ret.rename( columns={ station_col: "station", freq_col: "freq", comp_col: "comp", } ) if ensure_sorted: ret = ret.sort_values(["station", "freq", "comp"]) return ret # ------------------------ fallback z_frame ----------------- # if z_frame is not None: cols = [station_col, freq_col, comp_col, z_col] out = _subset(z_frame, cols) out = _mask_components(out) if drop_na: out = out.dropna(how=na_policy) if not np.issubdtype(out[z_col].dtype, np.complexfloating): out[z_col] = out[z_col].astype(complex) out = out.rename( columns={ station_col: "station", freq_col: "freq", comp_col: "comp", z_col: "z", } ) if ensure_sorted: out = out.sort_values(["station", "freq", "comp"]) return out raise ProcessingError( "Provide 'z_frame' with a complex 'z' column or " "'df' with 'rho','phase','freq' to derive z." )