Source code for pycsamt.core.mixins

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

"""Reusable mixins for creating and manipulating transfer-function bundles."""

from __future__ import annotations

from collections.abc import Iterator
from typing import Any

from .base import CoreObject, TFBundle, ensure_station, to_edi

__all__ = [
    "bundle_from_edi",
    "BundleMixin",
    "BundleContainerMixin",
]


def _get(obj: Any, *names: str) -> Any:
    for n in names:
        if hasattr(obj, n):
            return getattr(obj, n)
    return None


[docs] def bundle_from_edi(obj: Any) -> TFBundle: r""" Build a :class:`TFBundle` from an EDI-like object. The function probes common attribute names found in EDI objects and populates a neutral bundle. It tolerates missing fields and does not enforce array types. Parameters ---------- obj : Any EDI-like object. Attributes such as ``freq``, ``z``, ``z_err``, ``tipper``, ``rho``, ``phase``, ``station``, ``lat``, ``lon``, ``elev``, ``azimuth`` are searched with several conventional aliases. Returns ------- TFBundle A bundle with fields filled where data could be found. Notes ----- If the tipper has shape ``(n, 1, 2)`` it is normalized to ``(n, 2)``. Unknown shapes are left unchanged. Examples -------- >>> from pycsamt.core.mixins import bundle_from_edi >>> class E: ... def __init__(self): ... self.freq = [1.0, 2.0] ... self.phase = [45.0, 50.0] ... self.rho = [100.0, 120.0] ... >>> b = bundle_from_edi(E()) >>> b.freq, b.phase[0] ([1.0, 2.0], 45.0) """ freq = _get(obj, "freq", "frequency") z = _get(obj, "z", "Z") z_err = _get(obj, "z_err", "Z_err", "z_error") tip = _get(obj, "tipper", "Tipper", "tip") tip_err = _get(obj, "tipper_err", "tip_err") rho = _get(obj, "resistivity", "rho") pha = _get(obj, "phase", "phi", "phase_deg") name = _get(obj, "station", "site", "name") lat = _get(obj, "lat", "latitude") lon = _get(obj, "lon", "longitude") elev = _get(obj, "elev", "elevation") az = _get(obj, "azimuth", "az") # normalize tipper shape to (n, 2) try: if tip is not None and getattr(tip, "ndim", 1) == 3: if tip.shape[1:] == (1, 2): tip = tip[:, 0, :] except Exception: pass return TFBundle( freq=freq, z=z, z_err=z_err, tipper=tip, tipper_err=tip_err, rho=rho, phase=pha, station=name, lat=lat, lon=lon, elev=elev, azimuth=az, )
def _looks_collection(obj: Any) -> bool: r""" Heuristically decide if ``obj`` behaves like a collection. Criteria are conservative: basic sequences return True, while strings and bytes return False. Any iterable that does not expose a ``z`` attribute is considered a collection. Parameters ---------- obj : Any Candidate object. Returns ------- bool True when it looks like a collection of items. Notes ----- This is a soft check used by :meth:`BundleMixin.from_edi` to support collections without importing container types. """ if isinstance(obj, (list, tuple, set)): return True if isinstance(obj, (str, bytes)): return False if hasattr(obj, "__iter__") and not hasattr(obj, "z"): return True return False
[docs] class BundleMixin(CoreObject): r""" Mixin for single items convertible to and from bundles. Subclasses implement ``to_bundle`` and ``from_bundle`` to map between their internal representation and the neutral :class:`TFBundle`. Notes ----- ``to_edi`` delegates to :func:`pycsamt.core.base.to_edi`, which uses the adapter registry to pick the right converter. Examples -------- >>> from pycsamt.core.mixins import BundleMixin >>> from pycsamt.core.base import TFBundle >>> >>> class Item(BundleMixin): ... def __init__(self, b): self._b = b ... def to_bundle(self): return self._b ... @classmethod ... def from_bundle(cls, b): return cls(b) ... >>> b = TFBundle(freq=[1.0], rho=[100.], phase=[45.]) >>> it = Item.from_bundle(b) >>> isinstance(it.to_bundle(), TFBundle) True """
[docs] def to_bundle(self) -> TFBundle: # noqa: D401 r""" Return a :class:`TFBundle` representation. Must be implemented by subclasses. """ raise NotImplementedError
[docs] @classmethod def from_bundle(cls, bundle: TFBundle): # noqa: D401 r""" Construct an instance from a :class:`TFBundle`. Returns a new instance of the implementing class. Must be implemented by subclasses. """ raise NotImplementedError
[docs] @staticmethod def ensure_station_name( name: str | None, station_id: str | int | None, ) -> str: r""" Validate or synthesize a station name via policy. Parameters ---------- name : str or None Preferred station/site name if available. station_id : str, int or None Identifier used to synthesize a name when needed. Returns ------- str Validated or synthetic name. See Also -------- pycsamt.core.base.ensure_station Underlying helper with policy lookup. """ return ensure_station(name, station_id)
[docs] def to_edi(self, *, key: str | None = None, **k: Any) -> Any: r""" Convert ``self`` to EDI or EDICollection via dispatch. Parameters ---------- key : str, optional Adapter key override (e.g. ``'avg'``, ``'j'``). **k : Any Extra keyword arguments forwarded to the adapter. Returns ------- Any EDI object or EDICollection, depending on adapter. Notes ----- Dispatch is handled by :func:`pycsamt.core.base.to_edi`. A suitable adapter must have been registered, otherwise a runtime error is raised. """ return to_edi(self, key=key, **k)
[docs] @classmethod def from_edi(cls, edi_obj: Any): r""" Construct instance(s) from an EDI object or a collection. If ``edi_obj`` looks like a collection, every element is converted to a :class:`TFBundle` and fed to ``from_bundle``. Otherwise the single object is converted and fed to ``from_bundle``. Parameters ---------- edi_obj : Any EDI object or an iterable of EDI objects. Returns ------- Any Instance or list of instances of the implementing class. Examples -------- >>> from pycsamt.core.mixins import BundleMixin, bundle_from_edi >>> class I(BundleMixin): ... def __init__(self, b): self._b = b ... def to_bundle(self): return self._b ... @classmethod ... def from_bundle(cls, b): return cls(b) ... >>> e = type('E', (), {'freq':[1.0], 'rho':[100.], 'phase':[45.]})() >>> obj = I.from_edi(e) # doctest: +SKIP """ if _looks_collection(edi_obj): out = [] for it in edi_obj: # type: ignore b = bundle_from_edi(it) out.append(cls.from_bundle(b)) return out b = bundle_from_edi(edi_obj) return cls.from_bundle(b)
[docs] class BundleContainerMixin(BundleMixin): r""" Mixin for containers of items that implement ``to_bundle``. This mixin provides iteration over bundles and a convenience method to convert all items to an EDI collection using the adapter dispatch. Notes ----- Detection of children is permissive: if the container has an ``items()`` method, values are inspected; otherwise the container is iterated directly. Examples -------- >>> from pycsamt.core.mixins import BundleContainerMixin >>> from pycsamt.core.base import TFBundle >>> >>> class Bag(dict, BundleContainerMixin): ... pass ... >>> _ = Bag({0: type('X',(),{'to_bundle':lambda s: TFBundle()})()}) >>> list(_.iter_bundles())[0].__class__.__name__ 'TFBundle' """
[docs] def iter_bundles(self) -> Iterator[TFBundle]: """Yield contained items as :class:`TFBundle` objects.""" if hasattr(self, "items"): for _, v in self.items(): # type: ignore if hasattr(v, "to_bundle"): yield v.to_bundle() elif hasattr(self, "__iter__"): for v in self: # type: ignore if hasattr(v, "to_bundle"): yield v.to_bundle()
[docs] def to_edi_collection( self, *, key: str | None = None, **k: Any, ) -> list[Any]: r""" Convert all child bundles through adapter dispatch. Parameters ---------- key : str, optional Adapter key override for all children. **k : Any Extra keyword arguments forwarded to the adapter. Returns ------- list of Any A list of EDI objects. Callers may wrap this into an actual :class:`~pycsamt.seg.collection.EDICollection`. Examples -------- >>> from pycsamt.core.mixins import BundleContainerMixin >>> # Requires registered adapters to actually run: >>> # objs = MyContainer(...).to_edi_collection() # doctest:+SKIP """ edis: list[Any] = [] for b in self.iter_bundles(): proxy = _ProxyFromBundle(b) edis.append(to_edi(proxy, key=key, **k)) return edis
class _ProxyFromBundle: r""" Tiny proxy that exposes ``to_bundle`` for a stored bundle. Used internally to feed :func:`pycsamt.core.base.to_edi` with a minimal object that satisfies the dispatch contract. """ def __init__(self, b: TFBundle) -> None: self._b = b def to_bundle(self) -> TFBundle: return self._b