# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0-or-later
"""
Scalar apparent-field estimates: Resistivity and Phase.
Both classes inherit from :class:`TensorBase` so their values can
be rearranged into 2×2 tensor-like grids per frequency (and per
station), according to the component label found in the table
(e.g., ``ExHy`` goes to slot (0, 1)).
They accept *modern* CSAVGW-like tables and common *legacy*
aliases (AMTAVG/MTEdit). Minimal metadata (units) is preserved
and written back in a compact CSV block.
Usage
-----
>>> r = Resistivity.from_avg((df, meta))
>>> Z, f, st = r.to_tensor(var="rho", align="union")
>>> ds = r.to_xarray()
>>> p = Phase.from_avg((df, meta))
>>> p.convert_unit("deg")
>>> Zφ, f, st = p.to_tensor(var="phase")
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
import numpy as np
import pandas as pd
from ..exceptions import AvgDataError
from .tensor import TensorBase
from .utils import (
_norm_comp,
_to_num,
find_and_rename_column,
to_numeric_if_possible,
)
from .utils import to_xarray as _to_xr
__all__ = ["Resistivity", "Phase"]
[docs]
class Resistivity(TensorBase):
r"""
Apparent resistivity (:math:`\rho_a`) per component.
This is a *scalar* variable (one number per row), but the
class inherits the tensor-alignment logic so values land in
the appropriate :math:`2\times 2` slot given by ``comp``.
Recognized source columns (legacy + modern)
-------------------------------------------
* ``ARes.mag`` (modern)
* ``Resistivity`` (legacy tables)
* ``rho`` / ``Rho`` / ``ares.mag`` (variants)
Canonical internal column: ``'rho'``.
Attributes
----------
VAR_NAME : str
Canonical column in :pyattr:`frame` (``"rho"``).
ALIASES : tuple[str, ...]
Ordered alias list used during :meth:`read`.
UNIT_ATTR : str
Dataset attribute used for units (``"Unit.Rho"``).
"""
VAR_NAME: str = "rho"
UNIT_ATTR: str = "Unit.Rho"
def __init__(
self,
data: pd.DataFrame | None = None,
meta: Mapping[str, Any] | None = None,
*,
name: str | None = None,
verbose: bool = False,
) -> None:
super().__init__(
data=data, meta=meta, name=name or "Resistivity", verbose=verbose
)
[docs]
def read( # noqa: D401
self,
source: pd.DataFrame | Sequence[float] | np.ndarray | pd.Series,
meta: Mapping[str, Any] | None = None,
**kws: Any,
) -> None:
"""
Parse *source* and produce a compact frame with
``station, freq, comp, rho``. Coordinates are injected
conservatively when absent (``station=NaN``,
``comp='ExHy'``).
"""
self._meta = dict(meta or {})
# ensure a stable unit hint
self._meta.setdefault(self.UNIT_ATTR, "ohm·m")
# vector-like → create a minimal tidy frame
if isinstance(source, (list, tuple, np.ndarray, pd.Series)):
vec = pd.to_numeric(pd.Series(source), errors="coerce")
df = pd.DataFrame({self.VAR_NAME: vec})
df["station"] = kws.get("station", np.nan)
df["freq"] = kws.get("freq", np.nan)
df["comp"] = _norm_comp(kws.get("comp", "ExHy"))
self._frame = df[["station", "freq", "comp", self.VAR_NAME]]
return
if not isinstance(source, pd.DataFrame):
raise TypeError(
"Resistivity.read expects DataFrame or vector-like."
)
df = find_and_rename_column(source.copy(), self.VAR_NAME)
if self.VAR_NAME not in df.columns:
df[self.VAR_NAME] = np.nan
if self.verbose:
self._logger.debug(
f"'{self.VAR_NAME}' not in source. Creating empty."
)
# coords (inject defaults)
if "comp" not in df.columns:
df["comp"] = "ExHy"
if "station" not in df.columns:
df["station"] = np.nan
if "freq" not in df.columns:
raise AvgDataError("Resistivity requires a 'freq' column.")
# normalize dtypes
df["station"] = to_numeric_if_possible(df["station"])
df["freq"] = pd.to_numeric(df["freq"], errors="coerce")
df["comp"] = df["comp"].map(_norm_comp)
df[self.VAR_NAME] = df[self.VAR_NAME].map(_to_num)
self._frame = df[["station", "freq", "comp", self.VAR_NAME]]
return self
[docs]
def write(
self,
*,
float_fmt: str = "%.6g",
na_rep: str = "",
) -> Sequence[str]:
"""
Serialise to a compact CSV block with a small meta
preamble (units + timestamp).
"""
meta = {self.UNIT_ATTR: self._meta.get(self.UNIT_ATTR, "ohm·m")}
tmp = self.__class__() # ephemeral for helper reuse
tmp._frame = self._frame.copy()
tmp._meta = meta
return tmp._write_csv_block(
cols=["station", "freq", "comp", self.VAR_NAME],
title=r"$Resistivity Block",
float_fmt=float_fmt,
na_rep=na_rep,
include_meta=True,
stamp=True,
)
[docs]
def to_xarray(
self,
*,
coords: Sequence[str] = ("station", "freq", "comp"),
attrs: Mapping[str, Any] | None = None,
):
"""
Convert to an :class:`xarray.Dataset` with one data
variable (``rho``). Attributes include a stable unit
hint under ``Unit.Rho``.
"""
merged = dict(self._meta)
merged.setdefault(self.UNIT_ATTR, "ohm·m")
if attrs:
merged.update(attrs)
return _to_xr(
self._frame.copy(),
coords=coords,
data_vars=[self.VAR_NAME],
attrs=merged,
)
[docs]
class Phase(TensorBase):
r"""Impedance phase (:math:`\varphi`) per component.
This class manages the impedance phase data, inheriting from
:class:`~.tensor.TensorBase` to allow its scalar values to be
aligned into a :math:`2 \times 2` tensor-like grid based on
the component label.
Notes
-----
Values are typically reported in **milliradians** in modern
CSAVGW tables (``Unit.Phase='mrad'``). Use the
:meth:`convert_unit` method to switch to degrees when
desired.
The internal canonical column name for phase data is ``phase``.
Recognized source columns:
- ``Z.phz`` (modern)
- ``Phase`` (legacy)
- ``phase``, ``ZPHZ`` (common variants)
Attributes
----------
VAR_NAME : str
The canonical column name used in the internal frame
(``"phase"``).
UNIT_ATTR : str
The dataset attribute key used for storing units
(``"Unit.Phase"``).
Examples
--------
>>> from pycsamt.zonge import Phase
>>> import pandas as pd
>>> data = {"freq": [1024], "phase": [1000]}
>>> p = Phase()
>>> p.read(pd.DataFrame(data))
>>> p.frame['phase'].iloc[0]
1000.0
>>> p.convert_unit("deg")
>>> p.frame['phase'].iloc[0]
57.2957...
See Also
--------
Resistivity : Manages apparent resistivity data.
TensorBase : The base class providing
"""
VAR_NAME: str = "phase"
UNIT_ATTR: str = "Unit.Phase"
def __init__(
self,
data: pd.DataFrame | None = None,
meta: Mapping[str, Any] | None = None,
*,
name: str | None = None,
verbose: bool = False,
) -> None:
super().__init__(
data=data, meta=meta, name=name or "Phase", verbose=verbose
)
[docs]
def read( # noqa: D401
self,
source: pd.DataFrame | Sequence[float] | np.ndarray | pd.Series,
meta: Mapping[str, Any] | None = None,
**kws: Any,
) -> None:
"""
Parse *source* and produce a compact frame with
``station, freq, comp, phase``. Coordinates are injected
conservatively when absent (``station=NaN``,
``comp='ExHy'``). ``Unit.Phase`` defaults to ``'mrad'``.
"""
self._meta = dict(meta or {})
self._meta.setdefault(self.UNIT_ATTR, "mrad")
# vector-like path
if isinstance(source, (list, tuple, np.ndarray, pd.Series)):
vec = pd.to_numeric(pd.Series(source), errors="coerce")
df = pd.DataFrame({self.VAR_NAME: vec})
df["station"] = kws.get("station", np.nan)
df["freq"] = kws.get("freq", np.nan)
df["comp"] = _norm_comp(kws.get("comp", "ExHy"))
self._frame = df[["station", "freq", "comp", self.VAR_NAME]]
return
if not isinstance(source, pd.DataFrame):
raise TypeError("Phase.read expects DataFrame or vector-like.")
df = find_and_rename_column(source.copy(), self.VAR_NAME)
if self.VAR_NAME not in df.columns:
df[self.VAR_NAME] = np.nan
if self.verbose:
self._logger.debug(
f"'{self.VAR_NAME}' not in source. Creating empty."
)
if "comp" not in df.columns:
df["comp"] = "ExHy"
if "station" not in df.columns:
df["station"] = np.nan
if "freq" not in df.columns:
raise AvgDataError("Phase requires a 'freq' column.")
df["station"] = to_numeric_if_possible(df["station"])
df["freq"] = pd.to_numeric(df["freq"], errors="coerce")
df["comp"] = df["comp"].map(_norm_comp)
df[self.VAR_NAME] = df[self.VAR_NAME].map(_to_num)
self._frame = df[["station", "freq", "comp", self.VAR_NAME]]
return self
[docs]
def write(
self,
*,
float_fmt: str = "%.6g",
na_rep: str = "",
) -> Sequence[str]:
"""
Serialise to a compact CSV block with a meta preamble
carrying ``Unit.Phase`` and a UTC timestamp.
"""
meta = {self.UNIT_ATTR: self._meta.get(self.UNIT_ATTR, "mrad")}
tmp = self.__class__() # ephemeral
tmp._frame = self._frame.copy()
tmp._meta = meta
return tmp._write_csv_block(
cols=["station", "freq", "comp", self.VAR_NAME],
title=r"$Phase Block",
float_fmt=float_fmt,
na_rep=na_rep,
include_meta=True,
stamp=True,
)
[docs]
def to_xarray(
self,
*,
coords: Sequence[str] = ("station", "freq", "comp"),
attrs: Mapping[str, Any] | None = None,
):
"""
Convert to an :class:`xarray.Dataset` with one data
variable (``phase``). Attributes include a stable unit
hint under ``Unit.Phase``.
"""
merged = dict(self._meta)
merged.setdefault(self.UNIT_ATTR, "mrad")
if attrs:
merged.update(attrs)
return _to_xr(
self._frame.copy(),
coords=coords,
data_vars=[self.VAR_NAME],
attrs=merged,
)
[docs]
def convert_unit(self, target: str = "mrad") -> None:
r"""
Convert the phase values **in place** between
:math:`\mathrm{mrad}` and :math:`\mathrm{deg}` and update
``Unit.Phase`` in :pyattr:`meta`.
.. math::
1~\mathrm{mrad} =
\frac{180}{\pi \cdot 1000}~\mathrm{deg}
"""
cur = str(self._meta.get(self.UNIT_ATTR, "mrad")).lower()
tgt = str(target).lower()
if cur == tgt:
return
if tgt not in {"mrad", "deg"}:
raise ValueError("target must be 'mrad' or 'deg'")
col = self.VAR_NAME
if col not in self._frame.columns:
return
x = pd.to_numeric(self._frame[col], errors="coerce")
if cur == "mrad" and tgt == "deg":
factor = 180.0 / (np.pi * 1000.0)
elif cur == "deg" and tgt == "mrad":
factor = (np.pi / 180.0) * 1000.0
else:
raise ValueError(f"unsupported conversion {cur} → {tgt}")
self._frame[col] = x * factor
self._meta[self.UNIT_ATTR] = tgt