Source code for pycsamt.emtools.gb

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import numpy as np
import pandas as pd

from ..api.view import maybe_wrap_frame
from ._core import (
    _apply_each,
    _get_z_block,
    _iter_items,
    _name,
    ensure_sites,
)


def _gb_items(sites: Any, resolved: Any) -> list[Any]:
    """Return resolved sites, or direct EDI-like items if coercion was empty."""
    items = list(_iter_items(resolved))
    if items:
        return items
    if isinstance(sites, (str, bytes)):
        return items
    direct = list(_iter_items(sites))
    return [ed for ed in direct if _get_z_block(ed)[0] is not None]


[docs] @dataclass class GroomBaileyResult: """Container returned by :func:`groom_bailey_decomposition`.""" sites: Any table: pd.DataFrame applied: bool method: str
[docs] @property def n_station(self) -> int: """Number of stations with fitted distortion parameters.""" return int(len(self.table))
[docs] def summary(self) -> str: """Return a compact text summary.""" med = ( float(np.nanmedian(self.table["rms_fit"])) if not self.table.empty and "rms_fit" in self.table else np.nan ) return ( "GroomBaileyResult(" f"stations={self.n_station}, applied={self.applied}, " f"median_rms={med:.4g})" )
def __repr__(self) -> str: # noqa: D105 return self.summary()
def _rotmat(deg: float) -> np.ndarray: th = np.radians(float(deg)) c, s = np.cos(th), np.sin(th) return np.array([[c, s], [-s, c]], dtype=float) def _rotate_tensor(z: np.ndarray, deg: float) -> np.ndarray: R = _rotmat(deg) Rt = R.T return R[None, :, :] @ z @ Rt[None, :, :] def _select_band( z: np.ndarray, fr: np.ndarray, band: tuple[float, float] | None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: fr = np.asarray(fr, dtype=float).ravel() n = min(z.shape[0], fr.size) z = np.asarray(z[:n], dtype=np.complex128) fr = fr[:n] mask = np.isfinite(fr) & (fr > 0.0) mask &= np.isfinite(z).all(axis=(1, 2)) if band is not None: per = 1.0 / fr lo, hi = float(band[0]), float(band[1]) mask &= (per >= lo) & (per <= hi) return z[mask], fr[mask], mask def _anti_diagonal_from_distortion( z: np.ndarray, D: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Best anti-diagonal regional tensor for fixed real distortion D.""" c00, c01 = D[0, 0], D[0, 1] c10, c11 = D[1, 0], D[1, 1] den_u = max(c00 * c00 + c10 * c10, 1e-24) den_v = max(c01 * c01 + c11 * c11, 1e-24) u = (c00 * z[:, 0, 1] + c10 * z[:, 1, 1]) / den_u v = (c01 * z[:, 0, 0] + c11 * z[:, 1, 0]) / den_v z2 = np.zeros_like(z) z2[:, 0, 1] = u z2[:, 1, 0] = v return z2, u, v def _solve_real_row( obs0: np.ndarray, obs1: np.ndarray, u: np.ndarray, v: np.ndarray, weights: np.ndarray, ) -> np.ndarray: rows = [] rhs = [] for a, b, uu, vv, w in zip(obs0, obs1, u, v, weights): sw = float(np.sqrt(max(w, 0.0))) vals = ( (np.array([0.0, np.real(vv)]), np.real(a)), (np.array([0.0, np.imag(vv)]), np.imag(a)), (np.array([np.real(uu), 0.0]), np.real(b)), (np.array([np.imag(uu), 0.0]), np.imag(b)), ) for row, val in vals: rows.append(sw * row) rhs.append(sw * val) A = np.asarray(rows, dtype=float) y = np.asarray(rhs, dtype=float) if A.size == 0 or np.linalg.matrix_rank(A) < 2: return np.array([1.0, 0.0], dtype=float) sol, *_ = np.linalg.lstsq(A, y, rcond=None) return sol.astype(float) def _normalise_distortion( D: np.ndarray, u: np.ndarray, v: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: det = float(np.linalg.det(D)) if not np.isfinite(det) or abs(det) < 1e-12: return D, u, v scale = np.sqrt(abs(det)) if not np.isfinite(scale) or scale <= 0: return D, u, v return D / scale, u * scale, v * scale def _fit_gb_distortion( z: np.ndarray, *, max_iter: int, tol: float, robust: bool, ) -> dict[str, Any]: D = np.eye(2, dtype=float) weights = np.ones(z.shape[0], dtype=float) last = np.inf z2 = np.zeros_like(z) for _ in range(max(1, int(max_iter))): z2, u, v = _anti_diagonal_from_distortion(z, D) row0 = _solve_real_row(z[:, 0, 0], z[:, 0, 1], u, v, weights) row1 = _solve_real_row(z[:, 1, 0], z[:, 1, 1], u, v, weights) D = np.vstack([row0, row1]) D, u, v = _normalise_distortion(D, u, v) z2 = np.zeros_like(z) z2[:, 0, 1] = u z2[:, 1, 0] = v model = D[None, :, :] @ z2 resid = z - model denom = np.sqrt(np.nanmean(np.abs(z) ** 2)) + 1e-24 rms = float(np.sqrt(np.nanmean(np.abs(resid) ** 2)) / denom) if abs(last - rms) <= float(tol): break last = rms if robust: r = np.sqrt(np.nanmean(np.abs(resid) ** 2, axis=(1, 2))) med = np.nanmedian(r) mad = 1.4826 * np.nanmedian(np.abs(r - med)) + 1e-24 c = 1.345 * mad weights = np.ones_like(r) high = r > c weights[high] = c / np.maximum(r[high], 1e-24) weights = np.clip(weights, 0.05, 1.0) try: corrected = np.linalg.inv(D)[None, :, :] @ z except np.linalg.LinAlgError: corrected = np.full_like(z, np.nan) return dict(D=D, regional=z2, corrected=corrected, rms_fit=rms) def _gb_parameters(D: np.ndarray) -> dict[str, float]: gain = float(np.sqrt(abs(np.linalg.det(D)))) Dn = D / gain if np.isfinite(gain) and gain > 0 else D.copy() twist_rad = np.arctan2( Dn[0, 1] - Dn[1, 0], Dn[0, 0] + Dn[1, 1], ) twist_deg = float(np.degrees(twist_rad)) M = _rotmat(-twist_deg) @ Dn denom = float(M[0, 0] + M[1, 1]) if abs(denom) < 1e-12: shear = np.nan anisotropy = np.nan else: shear = float((M[0, 1] + M[1, 0]) / denom) anisotropy = float((M[0, 0] - M[1, 1]) / denom) shear = ( float(np.clip(shear, -0.99, 0.99)) if np.isfinite(shear) else shear ) anisotropy = ( float(np.clip(anisotropy, -0.99, 0.99)) if np.isfinite(anisotropy) else anisotropy ) return dict( gain=gain, twist_deg=twist_deg, shear=shear, shear_angle_deg=float(np.degrees(np.arctan(shear))) if np.isfinite(shear) else np.nan, anisotropy=anisotropy, ) def _diag_ratio(z: np.ndarray) -> float: diag = np.sqrt(np.abs(z[:, 0, 0]) ** 2 + np.abs(z[:, 1, 1]) ** 2) off = np.sqrt(np.abs(z[:, 0, 1]) ** 2 + np.abs(z[:, 1, 0]) ** 2) val = diag / np.maximum(off, 1e-24) return float(np.nanmedian(val)) if np.isfinite(val).any() else np.nan
[docs] def groom_bailey_table( sites: Any, *, band: tuple[float, float] | None = None, rotate_deg: float | None = None, min_freq: int = 4, max_iter: int = 30, tol: float = 1e-6, robust: bool = True, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ) -> Any: r"""Estimate Groom-Bailey-style galvanic distortion parameters. The fitted model is .. math:: Z_\mathrm{obs}(f) \approx D\,Z_{2D}(f), where ``D`` is a real, frequency-independent 2x2 distortion matrix and ``Z_2D`` is anti-diagonal at each frequency. The fitted matrix is decomposed into gain, twist, shear, and anisotropy-style parameters. """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) rows: list[dict[str, Any]] = [] for i, ed in enumerate(_gb_items(sites, S)): station = _name(ed, i) Z, z, fr = _get_z_block(ed) if Z is None or z is None or fr is None: continue z_work = np.asarray(z, dtype=np.complex128) if rotate_deg is not None: z_work = _rotate_tensor(z_work, float(rotate_deg)) z_fit, fr_fit, _ = _select_band(z_work, fr, band) if z_fit.shape[0] < int(min_freq): rows.append( dict( station=station, status="insufficient_frequencies", n_freq=int(z_fit.shape[0]), ) ) continue fit = _fit_gb_distortion( z_fit, max_iter=max_iter, tol=tol, robust=robust, ) D = fit["D"] params = _gb_parameters(D) corrected = fit["corrected"] rows.append( dict( station=station, status="ok", n_freq=int(z_fit.shape[0]), period_min_s=float(np.nanmin(1.0 / fr_fit)), period_max_s=float(np.nanmax(1.0 / fr_fit)), rotate_deg=( float(rotate_deg) if rotate_deg is not None else np.nan ), distortion_xx=float(D[0, 0]), distortion_xy=float(D[0, 1]), distortion_yx=float(D[1, 0]), distortion_yy=float(D[1, 1]), gain=params["gain"], twist_deg=params["twist_deg"], shear=params["shear"], shear_angle_deg=params["shear_angle_deg"], anisotropy=params["anisotropy"], rms_fit=float(fit["rms_fit"]), diagonal_ratio_before=_diag_ratio(z_fit), diagonal_ratio_after=_diag_ratio(corrected), robust=bool(robust), method="gb_real_distortion_2d", ) ) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="groom_bailey_table", kind="emtools.gb.table", source=sites, description="Groom-Bailey-style galvanic distortion parameters.", )
def _distortion_map(table: pd.DataFrame) -> dict[str, np.ndarray]: out: dict[str, np.ndarray] = {} if table is None or table.empty: return out for _, row in table.iterrows(): if str(row.get("status", "ok")) != "ok": continue D = np.array( [ [ row.get("distortion_xx", np.nan), row.get("distortion_xy", np.nan), ], [ row.get("distortion_yx", np.nan), row.get("distortion_yy", np.nan), ], ], dtype=float, ) if np.isfinite(D).all() and abs(np.linalg.det(D)) > 1e-12: out[str(row["station"])] = D return out
[docs] def apply_groom_bailey( sites: Any, table: pd.DataFrame | None = None, *, band: tuple[float, float] | None = None, rotate_deg: float | None = None, min_freq: int = 4, max_iter: int = 30, tol: float = 1e-6, robust: bool = True, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> Any: """Remove fitted Groom-Bailey galvanic distortion from impedance tensors.""" S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if table is None: table = groom_bailey_table( S, band=band, rotate_deg=rotate_deg, min_freq=min_freq, max_iter=max_iter, tol=tol, robust=robust, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) dmap = _distortion_map(table) def _one(Si): ed = next(_iter_items(Si)) station = _name(ed, 0) D = dmap.get(str(station)) Z, z, _ = _get_z_block(ed) if D is None or Z is None or z is None: return Si try: DI = np.linalg.inv(D) except np.linalg.LinAlgError: return Si z2 = DI[None, :, :] @ np.asarray(z, dtype=np.complex128) ze = getattr(Z, "z_err", None) try: Z.z = z2 if ze is not None: Z.z_err = np.abs(DI)[None, :, :] @ np.asarray(ze, dtype=float) except Exception: pass return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def groom_bailey_decomposition( sites: Any, *, apply: bool = False, band: tuple[float, float] | None = None, rotate_deg: float | None = None, min_freq: int = 4, max_iter: int = 30, tol: float = 1e-6, robust: bool = True, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> GroomBaileyResult: """Estimate, and optionally apply, Groom-Bailey distortion correction.""" S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) table = groom_bailey_table( S, band=band, rotate_deg=rotate_deg, min_freq=min_freq, max_iter=max_iter, tol=tol, robust=robust, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) out_sites = S if apply: out_sites = apply_groom_bailey( S, table=table, inplace=inplace, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) return GroomBaileyResult( sites=out_sites, table=table.copy() if hasattr(table, "copy") else pd.DataFrame(table), applied=bool(apply), method="gb_real_distortion_2d", )
__all__ = [ "GroomBaileyResult", "groom_bailey_table", "apply_groom_bailey", "groom_bailey_decomposition", ]