Source code for pycsamt.zonge.meas

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

Lightweight, robust containers for *measurement* columns that
appear in Zonge AVG tables.  These components follow the new
table-centric design (inherit from AVGComponentBase), so they can
be fed with a tidy DataFrame and later serialize as small CSV
blocks if desired.

Exports
-------
- CompMeas : validator/normaliser for component labels ('ExHy', …)
- Amps     : transmitter current amplitude handler (A)

Both classes keep *context* columns ('station', 'freq', 'comp')
when available, which helps downstream grouping and reshaping.
"""

from __future__ import annotations

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

import numpy as np
import pandas as pd

from ..exceptions import FrequencyError, InputError
from ..utils._dependency import import_optional_dependency
from .base import AVGComponentBase
from .utils import _standardise_columns
from .utils import to_xarray as _to_xr

__all__ = ["CompMeas", "Amps", "Frequency"]


_NUMERIC_NA = {"", "*", "nan", "NaN", "None", None}


[docs] class CompMeas(AVGComponentBase): """ Enumeration/validator for classical CSAMT component labels. The class guarantees a canonical ``comp`` column with allowed values (case-sensitive): ``{'ExHy','ExHx','EyHy','EyHx'}``. A few common case variants are accepted and normalized. Typical usage ------------- >>> cm = CompMeas.from_avg((df, meta)) >>> cm.unique ['ExHy'] # for a scalar survey with one component """ # canonical forms allowed by the pipeline _VALID: set[str] = { "ExHy", "ExHx", "EyHy", "EyHx", "Zxx", "Zxy", "Zyx", "Zyy", } # tolerant normalisation map (upper/lower → canonical) _NORM: dict[str, str] = { "EXHY": "ExHy", "EXHX": "ExHx", "EYHY": "EyHy", "EYHX": "EyHx", "exhy": "ExHy", "exhx": "ExHx", "eyhy": "EyHy", "eyhx": "EyHx", "ZXX": "Zxx", "ZXY": "Zxy", "ZYX": "Zyx", "ZYY": "Zyy", "zxx": "Zxx", "zxy": "Zxy", "zyx": "Zyx", "zyy": "Zyy", } required: set[str] = set() # flexible on input provides: set[str] = {"comp"} # always provides comp
[docs] def read( self, source: pd.DataFrame, meta: Mapping[str, Any] | None = None ) -> None: """ Ensure a normalised ``comp`` column exists. - If missing, default to **'ExHy'** (legacy kind-1 style). - If present, normalise values and validate membership. """ # start with a context copy to keep coords if present df = _keep_context(source, cols=("comp",)) if "comp" not in df.columns: df["comp"] = "ExHy" # normalise tolerant variants → canonical def _norm_one(v: Any) -> str: s = str(v).strip() # Use .upper() for case-insensitivity before mapping return self._NORM.get(s.upper(), s) df["comp"] = df["comp"].map(_norm_one) # Drop only rows whose component is missing ("nan" strings can # be introduced during xarray conversion). Filtering *all* # invalid labels here made the validation below unreachable. df = df[ df["comp"].notna() & (df["comp"].astype(str).str.lower() != "nan") ].copy() # validate – bail early with a friendly message bad = sorted(set(df["comp"]) - self._VALID) if bad: raise InputError( "unrecognised component(s) " f"{bad} – expect one of {sorted(self._VALID)}" ) self._frame = df self._meta = dict(meta or {})
[docs] def write(self) -> Sequence[str]: """ Serialise the component block as a compact CSV fragment. Only the *context* columns and ``comp`` are emitted to keep files readable. Disk I/O is the caller's responsibility. """ cols = [c for c in ("station", "freq", "comp") if c in self._frame] if not cols: return [] return self._write_csv_block( cols=cols, title="Component labels", include_meta=False, stamp=False, )
[docs] @property def unique(self) -> list[str]: """Sorted unique component labels.""" if "comp" not in self._frame: return [] return sorted(pd.unique(self._frame["comp"]).tolist())
def __str__(self) -> str: kinds = ",".join(self.unique[:4]) more = "…" if len(self.unique) > 4 else "" return f"CompMeas[{len(self._frame)}] {{{kinds}{more}}}"
@dataclass class _AmpStats: """Small stats tuple for quick diagnostics.""" vmin: float | None = None vmax: float | None = None mean: float | None = None median: float | None = None count: int = 0
[docs] class Amps(AVGComponentBase): """ Transmitter **current amplitude** container (unit: **A**). The class normalises the ``amps`` column to numeric, tracks a few useful stats, and can (optionally) export itself as a small CSV block. Context columns are preserved. Examples -------- >>> amps = Amps.from_avg((df, meta)) >>> amps.stats.mean 12.7 >>> ds = amps.to_xarray() # optional grid for convenience """ required: set[str] = set() # tolerant – legacy files provides: set[str] = {"amps"} def __init__( self, data: pd.DataFrame | None = None, meta: Mapping[str, Any] | None = None, *, name: str | None = None, ) -> None: super().__init__(data=data, meta=meta, name=name) self._stats = _AmpStats()
[docs] def read( self, source: pd.DataFrame, meta: Mapping[str, Any] | None = None ) -> None: """ Parse *source* and populate the ``amps`` column as float. Non-numeric entries ('*', blanks) become NaN. Context columns (station/freq/comp) are kept when present. """ df = _keep_context(source, cols=("amps", "Tx.Amp")) # tolerate both modern 'amps' and legacy 'Tx.Amp' if "amps" not in df.columns and "Tx.Amp" in df.columns: df["amps"] = df["Tx.Amp"] if "amps" not in df.columns: # provide the column – some natural-source datasets # deliberately omit Tx current; we keep NaNs. df["amps"] = np.nan df["amps"] = df["amps"].map(_to_float) self._frame = df self._meta = dict(meta or {}) self._compute_stats() return self
def _compute_stats(self) -> None: """Compute quick stats on finite ``amps`` values.""" s = pd.to_numeric( self._frame.get("amps", pd.Series(dtype=float)), errors="coerce" ) s = s[np.isfinite(s.values)] if s.empty: self._stats = _AmpStats(count=0) return self._stats = _AmpStats( vmin=float(np.min(s)), vmax=float(np.max(s)), mean=float(np.mean(s)), median=float(np.median(s)), count=int(s.size), )
[docs] @property def stats(self) -> _AmpStats: """Return min/max/mean/median/count snapshot.""" return self._stats
[docs] def as_series(self) -> pd.Series: """Return the ``amps`` column as a Series (copy).""" return self._frame.get("amps", pd.Series(dtype=float)).copy()
[docs] def to_frame(self) -> pd.DataFrame: # override for clarity """ Return a small table with context + ``amps`` only. """ keep = [ c for c in ("station", "freq", "comp", "amps") if c in self._frame ] return self._frame.loc[:, keep].copy()
[docs] def to_xarray( self, *, coords: Sequence[str] = ("station", "freq", "comp"), attrs: dict[str, Any] | None = None, ): """ Optional convenience: grid the column into an xarray dataset (dims: station × freq × comp) with a single data-variable called ``amps``. """ df = self.to_frame() if df.empty: return None return _to_xr( df, coords=coords, data_vars=["amps"], attrs=attrs or {"component": "amps"}, )
[docs] def write(self) -> Sequence[str]: """ Serialise as a compact CSV fragment. We keep context columns if present so the block remains useful alone. """ cols = [ c for c in ("station", "freq", "comp", "amps") if c in self._frame ] if not cols: return [] return self._write_csv_block( cols=cols, title="Tx current (amps)", include_meta=False, stamp=False, )
def __str__(self) -> str: s = self._stats if s.count == 0: return "Amps[empty]" span = f"{s.vmin:g}{s.vmax:g} A" return f"Amps[n={s.count}, span={span}, mean={s.mean:g}]"
[docs] class Frequency(AVGComponentBase): """ Frequency axis manager (Hz) for AVG tables. Goals ----- • Read from *legacy* and *modern* frames (column aliases handled) • Enforce positivity (> 0 Hz) while tolerating missing markers • Offer stable unique grids across stations/components • Provide a compact `to_xarray()` for downstream use Notes ----- - Legacy decimals like '.5' are parsed correctly. - Missing values ('*', '', None) become NaN and are ignored in summaries. Non-positive *numeric* entries raise `FrequencyError`. """ # what we require/provide as a component table required: set[str] = set() # we can construct from a vector provides: set[str] = {"freq"} def __init__( self, data: pd.DataFrame | None = None, meta: Mapping[str, Any] | None = None, *, name: str | None = None, ) -> None: super().__init__(data=data, meta=meta, name=name or "Frequency") # ensure we always carry a frequency unit tag self._meta.setdefault("Unit.Freq", "Hz")
[docs] def read( self, source: pd.DataFrame | Sequence[float] | np.ndarray | pd.Series, meta: Mapping[str, Any] | None = None, **kws: Any, ) -> None: """ Load frequency values from a tidy frame *or* a flat vector. If *source* is a DataFrame, we try to keep `station` / `comp` when present. Otherwise we inject conservative defaults: `station=NaN`, `comp='ExHy'`. """ self._meta = dict(meta or {}) self._meta.setdefault("Unit.Freq", "Hz") # vector-like → build a tiny tidy frame if isinstance(source, (list, tuple, np.ndarray, pd.Series)): vec = pd.to_numeric( pd.Series(source, dtype="float64"), errors="coerce" ) df = pd.DataFrame({"freq": vec}) if "station" in kws: df["station"] = kws["station"] else: df["station"] = np.nan df["comp"] = kws.get("comp", "ExHy") self._frame = df[["station", "freq", "comp"]] self._validate_positive() return # dataframe path if not isinstance(source, pd.DataFrame): raise TypeError("Frequency.read expects DataFrame or vector-like") df = _standardise_columns(source.copy()) if "freq" not in df.columns: raise FrequencyError( "Canonical column 'freq' not found in table." ) # tidy coords (inject when absent) if "comp" not in df.columns: df["comp"] = "ExHy" if "station" not in df.columns: df["station"] = np.nan # robust numeric parsing ('.5' → 0.5, '*'/' ' → NaN) df["freq"] = ( df["freq"] .astype(str) .str.strip() .replace({"": np.nan, "*": np.nan}) ) df["freq"] = pd.to_numeric(df["freq"], errors="coerce") self._frame = df[["station", "freq", "comp"]].copy() self._validate_positive() return self
[docs] def write(self) -> list[str]: """ Emit a compact CSV block with the contextual columns that we manage (`station`, `freq`, `comp`), suitable for round-tripping. """ if self._frame.empty: return [] cols: list[str] = [] for c in ("station", "freq", "comp"): if c in self._frame.columns: cols.append(c) return self._write_csv_block( cols=cols, title="$Frequency Block", include_meta=True, stamp=True, )
def _validate_positive(self) -> None: """Raise on non-positive *numeric* frequency values.""" s = pd.to_numeric( self._frame.get("freq", pd.Series(dtype=float)), errors="coerce" ) bad = s.notna() & (s <= 0.0) if bool(bad.any()): n = int(bad.sum()) raise FrequencyError(f"found {n} non-positive frequency value(s)")
[docs] def unique( self, *, sort: bool = True, dropna: bool = True, rtol: float = 1e-6, atol: float = 1e-12, ) -> np.ndarray: """ Unique global frequency grid with tolerance deduplication. """ s = pd.to_numeric( self._frame.get("freq", pd.Series(dtype=float)), errors="coerce" ) if dropna: s = s.dropna() arr = s.to_numpy(dtype=float) if arr.size == 0: return arr uniq = _unique_tol(arr, rtol=rtol, atol=atol) if sort: uniq.sort() return uniq
[docs] def by_station( self, *, rtol: float = 1e-6, atol: float = 1e-12 ) -> dict[float, np.ndarray]: """ Per-station unique frequency grids (sorted). """ out: dict[float, np.ndarray] = {} if self._frame.empty: return out tmp = self._frame.copy() tmp["station"] = pd.to_numeric(tmp["station"], errors="coerce") for stn, sub in tmp.groupby("station", dropna=True): s = pd.to_numeric(sub["freq"], errors="coerce").dropna() out[float(stn)] = _unique_tol(s.to_numpy(), rtol=rtol, atol=atol) return out
[docs] @property def n_unique(self) -> int: """Number of unique frequencies in the table.""" return int(self.unique().size)
[docs] @staticmethod def logspace( decade_start: int, decade_stop: int, n_points: int ) -> np.ndarray: """ Canonical log-spaced grid (10**start → 10**stop), inclusive. """ if n_points < 2: raise ValueError("n_points must be >= 2") return np.logspace( decade_start, decade_stop, n_points, endpoint=True, base=10.0 )
[docs] def to_xarray( self, *, coords: Sequence[str] = ("station", "freq", "comp"), attrs: dict | None = None, ): """ Convert to an xarray.Dataset. We include `freq` as a data variable as well, which is helpful in some consumers; the coordinate is still the same `freq` axis created by `coords`. """ import_optional_dependency( "xarray", extra="xarray is required for to_xarray()", errors="raise", ) import xarray as xr # 1) Build a base dataset that has the dims we need df = self._frame.copy() df["present"] = True # any simple data var to force a dense grid attrs = dict(self._meta or {}) attrs.setdefault("Unit.Freq", "Hz") ds = _to_xr( df, coords=coords, data_vars=["present"], attrs=attrs, ) # 2) Grab the coordinate values for broadcasting, then # drop the 1-D 'freq' coord so we can use that # name for a data-var. st = ds.coords["station"].values cp = ds.coords["comp"].values fq = ds.coords["freq"].values # 1-D list of freqs ds = ds.drop_vars( "freq" ) # remove the 1-D coord variable named 'freq' # 3) Broadcast freq values over (station, freq, comp) freq3 = np.broadcast_to( fq[np.newaxis, :, np.newaxis], (st.size, fq.size, cp.size), ) ds["freq_grid"] = xr.DataArray( freq3, dims=("station", "freq", "comp"), coords={"station": st, "comp": cp}, ) return ds
def __str__(self) -> str: if self._frame.empty: return "Frequency[0×0]" f = pd.to_numeric(self._frame["freq"], errors="coerce") f = f.dropna() if f.empty: return "Frequency[n=? span=?–? Hz, unique=0]" return ( f"Frequency[{len(self._frame)}×{self._frame.shape[1]}] " f"span={f.min():g}{f.max():g} Hz, unique={self.n_unique}" ) __repr__ = __str__
def _to_float(x: Any) -> float | np.floating | np.nan: """ Best-effort numeric coercion for table columns. - blanks / '*' / 'nan' → np.nan - integral strings → float(int) (we stay in float for pandas) - everything else → float(...) or np.nan on failure """ if x in _NUMERIC_NA: return np.nan try: f = float(str(x).strip()) return f except Exception: return np.nan def _keep_context(df: pd.DataFrame, cols: Sequence[str]) -> pd.DataFrame: """ Return a *copy* of ``df`` with the selected columns if present, preserving a helpful context subset first: station/freq/comp. """ want = [c for c in ("station", "freq", "comp") if c in df.columns] want += [c for c in cols if c not in want] keep = [c for c in want if c in df.columns] return df.loc[:, keep].copy() def _unique_tol( arr: np.ndarray, *, rtol: float = 1e-6, atol: float = 1e-12 ) -> np.ndarray: """ Return sorted uniques using an *isclose* tolerance, which is useful when frequencies come from decimal strings like '.5' and suffer tiny rounding differences between stations. """ if arr.size == 0: return arr a = np.sort(arr.astype(float, copy=False)) keep = [a[0]] for x in a[1:]: if not np.isclose(x, keep[-1], rtol=rtol, atol=atol): keep.append(x) return np.asarray(keep, dtype=float)