Source code for pycsamt.z.base

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0

from __future__ import annotations

import copy as _copy
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any, Union

import numpy as np

from ..exceptions import ZError
from ..log.logger import get_logger

_StrSeq = Sequence[str]
_Idx = Union[int, slice, np.ndarray, Sequence[int]]


[docs] @dataclass class EMBase: r""" Minimal, mixin-style base class for EM containers. ``EMBase`` provides a small set of uniform behaviors used by higher-level containers such as :class:`~pycsamt.z.z.Z`, :class:`~pycsamt.z.resphase.ResPhase`, and :class:`~pycsamt.z.tipper.Tipper`. It centralizes frequency handling, safe slicing along the frequency axis, shape checks, light verbosity control, and compact summaries. The class is deliberately conservative: it makes as few assumptions as possible about attribute names and shapes, while still enabling useful defaults for common containers. Parameters ---------- name : str, optional Human-friendly display name. Used by :meth:`summary` and representations. meta : dict, optional Arbitrary metadata attached to the instance. Not used by core logic. verbose : int, default 0 Controls the object logger level. ``0`` → WARNING, ``1`` → INFO, ``>=2`` → DEBUG. See :meth:`set_verbose`. Attributes ---------- name : str or None Display name. meta : dict User metadata stored verbatim. verbose : int Current verbosity level. See :meth:`set_verbose`. log : logging.Logger Per-class logger created via :func:`~pycsamt.log.logger. get_logger`. freq : ndarray or None One-dimensional frequency vector in Hz. The property enforces finite, strictly positive values. Stored internally as ``_freq`` when set. n_freq : int Inferred number of frequencies. If ``_freq`` exists, its length is returned. Otherwise, the first dimension of any known array attribute (e.g., ``_z``, ``_tipper``, ``_resistivity``, ``_phase``) is used. Returns ``0`` if unknown. Notes ----- **Design.** The class aims to be a small, composable layer. It avoids imposing a strict attribute schema so that existing containers can adopt it without refactors. Uniform frequency handling is offered through the :pyattr:`freq` property. **Slicing.** :meth:`subset` returns a deep-copied view where every attribute whose first dimension equals :pyattr:`n_freq` is sliced along axis-0. This covers typical arrays like ``_z``, ``_tipper``, ``_phase``, and their error fields. Subclasses can override :meth:`_sliceable_predicate` for fine-grained control. **Validation.** :meth:`validate_shapes` checks that all frequency-aligned arrays share the same leading dimension (:pyattr:`n_freq`). It raises :class:`~pycsamt.exceptions.ZError` on mismatches. **Verbosity.** :meth:`set_verbose` updates the instance logger level only. It does not alter global logging configuration. **Back-compat.** ``BaseEM`` remains available as an alias to ``EMBase`` for compatibility with older imports. Examples -------- Minimal subclass that stores a frequency vector and a tensor: >>> import numpy as np >>> from pycsamt.z.base import BaseEM >>> class Dummy(BaseEM): ... def __init__(self, f, z, *, name=None, verbose=0): ... super().__init__(name=name, verbose=verbose) ... self.freq = f ... self._z = np.asarray(z, complex) ... >>> f = np.array([10.0, 1.0]) >>> z = np.zeros((2, 2, 2), complex) >>> d = Dummy(f, z, name="site-A", verbose=1) >>> d.n_freq 2 >>> print(d.summary()) # doctest: +ELLIPSIS EMBase: site-A n_freq: 2 f[Hz]: min=1, max=10 errors: no arrays: - _z: (2, 2, 2)@complex128 Frequency-axis subsetting with a boolean mask: >>> m = np.array([True, False]) >>> d2 = d.subset(m) >>> d2.n_freq 1 Validating shapes (raises on mismatch): >>> d._phase = np.zeros((1, 2, 2)) >>> from pycsamt.exceptions import ZError >>> _ = d.validate_shapes() # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... ZError: freq-aligned arrays mismatch n_freq. ... See Also -------- pycsamt.z.z.Z : Impedance tensor container building on resistivity/phase. pycsamt.z.resphase.ResPhase : Resistivity/phase container with Z linkage and errors. pycsamt.z.tipper.Tipper : Complex tipper container with rotation and arrow tools. References ---------- .. [1] Chave, A. D., & Jones, A. G. (2012). *The Magnetotelluric Method: Theory and Practice*. Cambridge University Press. .. [2] Simpson, F., & Bahr, K. (2005). *Practical Magnetotellurics*. Cambridge University Press. """ name: str | None = None meta: dict[str, Any] = field(default_factory=dict) verbose: int = 0 def __post_init__(self) -> None: # subclasses may not call super(); be defensive elsewhere self.log = get_logger(type(self).__name__) self.set_verbose(self.verbose)
[docs] def set_verbose(self, level: int = 0) -> EMBase: self.verbose = int(level) lg = getattr(self, "log", None) if lg is None: lg = get_logger(type(self).__name__) self.log = lg if self.verbose <= 0: lg.setLevel("WARNING") elif self.verbose == 1: lg.setLevel("INFO") else: lg.setLevel("DEBUG") return self
@property def freq(self) -> np.ndarray | None: f = getattr(self, "_freq", None) return None if f is None else f
[docs] @freq.setter def freq(self, f: Sequence[float] | None) -> None: if f is None: self._freq = None return ff = np.asarray(f, dtype=float) if ff.ndim != 1: raise ZError("freq must be 1-D") if not np.all(np.isfinite(ff)): raise ZError("freq must be finite") if np.any(ff <= 0.0): raise ZError("freq must be > 0") self._freq = ff
[docs] @property def n_freq(self) -> int: f = getattr(self, "_freq", None) if isinstance(f, np.ndarray) and f.ndim == 1: return int(f.size) for attr in ("_z", "_tipper", "_resistivity", "_phase"): a = getattr(self, attr, None) if isinstance(a, np.ndarray) and a.ndim >= 1: return int(a.shape[0]) return 0
def __len__(self) -> int: return self.n_freq
[docs] @property def has_freq(self) -> bool: f = getattr(self, "_freq", None) return isinstance(f, np.ndarray) and f.ndim == 1 and f.size > 0
[docs] @property def has_errors(self) -> bool: for attr in ( "_z_err", "_tipper_err", "_resistivity_err", "_phase_err", ): a = getattr(self, attr, None) if isinstance(a, np.ndarray): return True return False
[docs] def copy(self) -> EMBase: return _copy.copy(self)
[docs] def deepcopy(self) -> EMBase: return _copy.deepcopy(self)
def _sliceable_predicate( self, name: str, value: object, n: int, ) -> bool: if not isinstance(value, np.ndarray): return False if value.ndim == 0: return False return value.shape[0] == n
[docs] def subset(self, indices: _Idx) -> EMBase: n = self.n_freq if n == 0: return self.deepcopy() idx = indices new = self.deepcopy() f = getattr(new, "_freq", None) if isinstance(f, np.ndarray) and f.ndim == 1 and f.size == n: new._freq = f[idx] for name, value in list(vars(new).items()): if self._sliceable_predicate(name, value, n): try: setattr(new, name, value[idx]) except Exception as exc: # pragma: no cover lg = getattr( self, "log", get_logger(type(self).__name__), ) lg.debug("skip slice %s: %s", name, exc) return new
# small alias that reads nicely in notebooks
[docs] def select(self, indices: _Idx) -> EMBase: return self.subset(indices)
[docs] def validate_shapes(self) -> None: n = self.n_freq if n == 0: return bad: list[str] = [] for name, value in vars(self).items(): if not isinstance(value, np.ndarray): continue if value.ndim == 0: continue if value.shape[0] != n: bad.append(f"{name}:{tuple(value.shape)}") if bad: raise ZError( "freq-aligned arrays mismatch n_freq. " f"n={n}; bad={', '.join(bad)}" )
def _array_sig(self) -> tuple[_StrSeq, _StrSeq]: names: list[str] = [] sigs: list[str] = [] for name, value in vars(self).items(): if isinstance(value, np.ndarray): names.append(name) sigs.append(f"{tuple(value.shape)}@{value.dtype}") return names, sigs
[docs] def summary(self) -> str: cls = type(self).__name__ n = self.n_freq parts = [f"{cls}: {self.name or '-'}", f"n_freq: {n}"] if self.has_freq: f = self._freq fmin = float(np.min(f)) if f.size else np.nan fmax = float(np.max(f)) if f.size else np.nan parts.append(f"f[Hz]: min={fmin:.6g}, max={fmax:.6g}") parts.append(f"errors: {'yes' if self.has_errors else 'no'}") an, asig = self._array_sig() if an: parts.append("arrays:") for name, sig in zip(an, asig): parts.append(f" - {name}: {sig}") return "\n".join(parts)
def __str__(self) -> str: # pragma: no cover return self.summary() def __repr__(self) -> str: # pragma: no cover cls = type(self).__name__ return ( f"{cls}(name={self.name!r}, n_freq={self.n_freq}, " f"errors={self.has_errors})" )
# keep backward compat name BaseEM = EMBase