Source code for pycsamt.inversion.data

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Data containers for physics-based EM inversion workflows."""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any

import numpy as np

from ..api.property import MetadataMixin, PyCSAMTObject
from .doc import _inversion_param_docs

__all__ = ["EMData"]


[docs] @dataclass class EMData(PyCSAMTObject, MetadataMixin): method: str = "mt" frequencies: Any = None times: Any = None rho_a: Any = None phase: Any = None values: Any = None errors: Any = None station_names: list[str] = field(default_factory=list) station_x: Any = None source: Any = field(default=None, repr=False) metadata: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: self.method = str(self.method).lower() self.frequencies = _as_float_array(self.frequencies) self.times = _as_float_array(self.times) self.rho_a = _as_float_array(self.rho_a) self.phase = _as_float_array(self.phase) self.values = _as_float_array(self.values) self.errors = _as_float_array(self.errors) self.station_x = _as_float_array(self.station_x) self.station_names = [str(name) for name in self.station_names] self.validate()
[docs] @classmethod def from_dict(cls, data: dict[str, Any], **overrides: Any) -> EMData: """Build :class:`EMData` from a mapping. Parameters ---------- data : mapping Mapping with observation fields. Keys may include ``method``, ``freqs``/``frequencies``, ``periods``, ``rho_a``, ``phase``, ``times``, ``values``, ``errors``, ``stations``/``station_names``, ``station_x``, and ``metadata``. **overrides Field values that override keys read from ``data``. This is useful when a caller wants to force ``method`` or attach metadata while preserving the original observation mapping. Returns ------- EMData Normalized observation container. Notes ----- The aliases ``freqs`` and ``stations`` are normalized to ``frequencies`` and ``station_names``. ``periods`` are converted to frequency in hertz. Examples -------- >>> from pycsamt.inversion.data import EMData >>> data = EMData.from_dict({ ... "method": "mt", ... "freqs": [1.0, 10.0], ... "rho_a": [100.0, 120.0], ... "phase": [45.0, 47.0], ... "stations": ["S01"], ... }) >>> data.frequencies.tolist() [1.0, 10.0] >>> data.station_names ['S01'] """ values = dict(data) if "freqs" in values and "frequencies" not in values: values["frequencies"] = values.pop("freqs") if "periods" in values and "frequencies" not in values: periods = np.asarray(values.pop("periods"), dtype=float) values["frequencies"] = 1.0 / periods if "stations" in values and "station_names" not in values: values["station_names"] = values.pop("stations") values.update(overrides) return cls(**values)
[docs] @classmethod def coerce(cls, data: Any, *, method: str = "mt") -> EMData: """Return *data* as an :class:`EMData` instance. Parameters ---------- data : EMData, mapping, response object, sounding object, survey object, or sequence Input converted to :class:`EMData`. Supported objects include natural-source response objects with frequency and rho/phase attributes; station collections of such objects; TDEM sounding objects exposing time gates and decay values; and survey objects exposing ``to_soundings()``. method : str, default "mt" Fallback method used when *data* does not declare its own method. TDEM sounding/survey inputs are automatically promoted to ``"tdem"``. Returns ------- EMData Existing ``EMData`` instances are returned unchanged. Recognized objects are parsed into normalized arrays. Unrecognized non-null inputs are retained as ``source`` on an otherwise empty container so backend adapters can still inspect them. Raises ------ ValueError If station collections have incompatible frequency counts, TDEM soundings have incompatible time gates, or normalized arrays fail validation. Examples -------- Coerce a response-like object:: >>> import numpy as np >>> from pycsamt.inversion.data import EMData >>> class Response: ... freqs = np.array([1.0, 10.0]) ... rho_a = np.array([100.0, 120.0]) ... phase = np.array([45.0, 47.0]) ... station_name = "S01" >>> data = EMData.coerce(Response(), method="mt") >>> data.station_names ['S01'] Coerce a small TDEM survey object exposing ``to_soundings()``:: >>> import numpy as np >>> from pycsamt.inversion.data import EMData >>> class Sounding: ... station_name = "T01" ... time_gates = np.array([1e-5, 1e-4]) ... data = np.array([1e-8, 2e-9]) >>> class Survey: ... def to_soundings(self): ... return [Sounding()] >>> tdem = EMData.coerce(Survey()) >>> tdem.method 'tdem' """ if isinstance(data, cls): return data if isinstance(data, dict): return cls.from_dict(data, method=data.get("method", method)) parsed = _coerce_object(data, method=method) if parsed is not None: return parsed return cls(method=method, source=data)
[docs] @property def n_samples(self) -> int: """Return the number of primary samples.""" for arr in (self.frequencies, self.times, self.rho_a, self.values): if arr is not None: return int(np.asarray(arr).shape[-1]) return 0
[docs] @property def n_stations(self) -> int: """Return the number of stations/soundings represented.""" for arr in (self.rho_a, self.phase, self.values): if arr is None: continue arr = np.asarray(arr) if arr.ndim <= 1: return 1 return int(arr.shape[0]) if self.station_x is not None: return int(np.asarray(self.station_x).size) if self.station_names: return len(self.station_names) return 1
[docs] @property def has_mt_response(self) -> bool: """Whether apparent resistivity/phase observations are available.""" return self.frequencies is not None and ( self.rho_a is not None or self.phase is not None )
[docs] @property def has_tdem_response(self) -> bool: """Whether time-domain EM decay observations are available.""" return self.times is not None and self.values is not None
[docs] def validate(self) -> None: """Validate basic shape consistency.""" if self.frequencies is not None and np.any(self.frequencies <= 0): raise ValueError("frequencies must be strictly positive.") if self.times is not None and np.any(self.times <= 0): raise ValueError("times must be strictly positive.") n = None for name in ("rho_a", "phase", "values", "errors"): arr = getattr(self, name) if arr is None: continue if arr.ndim > 2: raise ValueError(f"{name} must be 1-D or 2-D.") if n is None: n = int(arr.shape[-1]) elif int(arr.shape[-1]) != n: raise ValueError( "rho_a, phase, and errors must share sample length." ) if self.frequencies is not None and n is not None: if int(self.frequencies.size) != n: raise ValueError("frequencies must match data sample length.") if self.times is not None and self.values is not None: if int(self.times.size) != int(self.values.shape[-1]): raise ValueError("times must match values sample length.") n_st = self.n_stations if self.station_x is not None and self.station_x.size not in { 0, n_st, }: raise ValueError("station_x must match number of stations.") if self.station_names and len(self.station_names) != n_st: raise ValueError("station_names must match number of stations.")
EMData.__doc__ = f""" Normalized EM observation container. ``EMData`` is the backend-neutral observation object used by :mod:`pycsamt.inversion`. It keeps natural-source and time-domain EM data in one small shape contract so built-in, optional, and external backends can share the same input API. Shape convention ---------------- Frequency-domain ``rho_a`` and ``phase`` arrays may be one-dimensional for a single station or two-dimensional with shape ``(n_stations, n_frequencies)``. TDEM ``values`` arrays may be one-dimensional for a single sounding or two-dimensional with shape ``(n_soundings, n_times)``. Station metadata, when provided, must match the first dimension of these arrays. Parameters ---------- {_inversion_param_docs.data.method} {_inversion_param_docs.data.frequencies} {_inversion_param_docs.data.times} {_inversion_param_docs.data.rho_a} {_inversion_param_docs.data.phase} {_inversion_param_docs.data.values} {_inversion_param_docs.data.errors} {_inversion_param_docs.data.station_names} {_inversion_param_docs.data.station_x} {_inversion_param_docs.data.source} {_inversion_param_docs.data.metadata} Notes ----- Use :meth:`EMData.coerce` at API boundaries. It accepts dictionaries, response-like objects, station collections, TDEM sounding collections, and TDEM survey objects exposing ``to_soundings()``. The original input object is kept in ``source`` for provenance and backend-specific adapters. {_inversion_param_docs.data.examples} {_inversion_param_docs.data.references} """ def _as_float_array(value: Any) -> np.ndarray | None: if value is None: return None arr = np.asarray(value, dtype=float) return arr.reshape(1) if arr.ndim == 0 else arr def _coerce_object(data: Any, *, method: str) -> EMData | None: if data is None or isinstance(data, (str, bytes)): return None if _is_tdem_sounding(data): return _from_tdem_soundings([data], method=method, source=data) if hasattr(data, "to_soundings"): try: soundings = data.to_soundings() except TypeError: soundings = None if soundings: return _from_tdem_soundings(soundings, method="tdem", source=data) if _looks_like_response(data): return _from_response_object(data, method=method, source=data) if _is_iterable_collection(data): items = list(data) if not items: return None if all(_is_tdem_sounding(item) for item in items): return _from_tdem_soundings(items, method="tdem", source=data) if all(_looks_like_response(item) for item in items): return _from_response_collection( items, method=method, source=data ) return None def _from_response_object(obj: Any, *, method: str, source: Any) -> EMData: freqs = _frequencies(obj) rho, phase = _rho_phase(obj) errors = _attr( obj, ("errors", "error", "rho_error", "std", "standard_deviation") ) station_names = _station_names_from_response( obj, _n_stations_from_arrays(rho, phase) ) station_x = _station_x_from_response( obj, len(station_names) or _n_stations_from_arrays(rho, phase) ) return EMData( method=_method_from_source(obj, method), frequencies=freqs, rho_a=_station_sample_matrix(rho, freqs), phase=_station_sample_matrix(phase, freqs), errors=_station_sample_matrix(errors, freqs), station_names=station_names, station_x=station_x, source=source, metadata=_metadata_from_source(obj, reader="response_object"), ) def _from_response_collection( items: list[Any], *, method: str, source: Any ) -> EMData: freqs = _frequencies(items[0]) rho_rows = [] phase_rows = [] error_rows = [] names = [] xs = [] for idx, item in enumerate(items): item_freqs = _frequencies(item) if item_freqs is not None and freqs is not None: if ( np.asarray(item_freqs, dtype=float).size != np.asarray(freqs, dtype=float).size ): raise ValueError( "all station response objects must share frequency count." ) rho, phase = _rho_phase(item) if rho is not None: rho_rows.append(np.asarray(rho, dtype=float).reshape(-1)) if phase is not None: phase_rows.append(np.asarray(phase, dtype=float).reshape(-1)) err = _attr( item, ("errors", "error", "rho_error", "std", "standard_deviation"), ) if err is not None: error_rows.append(np.asarray(err, dtype=float).reshape(-1)) names.append(_station_name(item, idx)) xs.append(_station_x_value(item, idx)) return EMData( method=_method_from_source(source, method), frequencies=freqs, rho_a=np.vstack(rho_rows) if rho_rows else None, phase=np.vstack(phase_rows) if phase_rows else None, errors=np.vstack(error_rows) if error_rows else None, station_names=names, station_x=np.asarray(xs, dtype=float), source=source, metadata={ "reader": "response_collection", "n_input_objects": len(items), }, ) def _from_tdem_soundings( items: Iterable[Any], *, method: str, source: Any ) -> EMData: soundings = list(items) if not soundings: return EMData(method="tdem", source=source) times = np.asarray( _attr(soundings[0], ("time_gates", "times", "time")), dtype=float ) rows = [] errors = [] names = [] xs = [] for idx, sounding in enumerate(soundings): st_times = np.asarray( _attr(sounding, ("time_gates", "times", "time")), dtype=float ) if st_times.size != times.size or not np.allclose(st_times, times): raise ValueError("all TEM soundings must share time gates.") values = _tdem_values(sounding) rows.append(np.asarray(values, dtype=float).reshape(-1)) err = _attr( sounding, ("error", "errors", "std", "standard_deviation") ) if err is not None: errors.append(np.asarray(err, dtype=float).reshape(-1)) names.append(_station_name(sounding, idx)) xs.append(_station_x_value(sounding, idx)) return EMData( method="tdem" if method == "mt" else method, times=times, values=np.vstack(rows) if len(rows) > 1 else rows[0], errors=np.vstack(errors) if errors else None, station_names=names, station_x=np.asarray(xs, dtype=float), source=source, metadata={"reader": "tdem_soundings", "n_soundings": len(soundings)}, ) def _is_iterable_collection(data: Any) -> bool: return isinstance(data, Iterable) and not isinstance( data, (str, bytes, dict, np.ndarray) ) def _is_tdem_sounding(obj: Any) -> bool: return _attr(obj, ("time_gates", "times", "time")) is not None and ( _attr(obj, ("data", "values", "dBz_dt", "dbdt")) is not None or callable(getattr(obj, "dBdt", None)) ) def _looks_like_response(obj: Any) -> bool: return _frequencies(obj) is not None and any( _attr(obj, names) is not None for names in ( ( "rho_a", "rhoa", "app_res", "apparent_resistivity", "resistivity", ), ("phase", "phi"), ("rho_a_te", "rho_te"), ("phase_te",), ("rho_a_tm", "rho_tm"), ("phase_tm",), ) ) def _frequencies(obj: Any) -> np.ndarray | None: freqs = _attr(obj, ("frequencies", "freqs", "freq", "frequency")) if freqs is not None: return np.asarray(freqs, dtype=float) periods = _attr(obj, ("periods", "period")) if periods is not None: return 1.0 / np.asarray(periods, dtype=float) return None def _rho_phase(obj: Any) -> tuple[Any, Any]: component = str(_metadata_from_source(obj).get("component", "te")).lower() rho = _attr(obj, ("rho_a", "rhoa", "app_res", "apparent_resistivity")) phase = _attr(obj, ("phase", "phi")) if rho is None: rho = _attr( obj, (f"rho_a_{component}", f"rho_{component}", "rho_a_te", "rho_te"), ) if phase is None: phase = _attr(obj, (f"phase_{component}", "phase_te")) return rho, phase def _station_sample_matrix(values: Any, freqs: Any) -> np.ndarray | None: if values is None: return None arr = np.asarray(values, dtype=float) if arr.ndim <= 1: return arr n_freq = 0 if freqs is None else int(np.asarray(freqs).size) if n_freq and arr.shape[0] == n_freq and arr.shape[-1] != n_freq: return arr.T return arr def _n_stations_from_arrays(*arrays: Any) -> int: for arr in arrays: if arr is None: continue arr = np.asarray(arr) if arr.ndim > 1: return int(min(arr.shape[0], arr.shape[1])) return 1 def _station_names_from_response(obj: Any, n_stations: int) -> list[str]: raw = _attr(obj, ("station_names", "stations", "site_names", "names")) if raw is not None: return [str(name) for name in np.asarray(raw).reshape(-1).tolist()] if n_stations <= 1: return [_station_name(obj, 0)] return [f"S{i:03d}" for i in range(n_stations)] def _station_x_from_response(obj: Any, n_stations: int) -> np.ndarray | None: raw = _attr( obj, ("station_x", "stations_x", "x_stations", "x", "easting") ) if raw is None: return None if n_stations <= 1 else np.arange(n_stations, dtype=float) arr = np.asarray(raw, dtype=float) if arr.ndim == 0: return arr.reshape(1) return arr def _station_name(obj: Any, idx: int) -> str: raw = _attr( obj, ("station_name", "station", "name", "id", "site", "site_id") ) if raw is None or isinstance(raw, (list, tuple, np.ndarray, dict)): return f"S{idx:03d}" return str(raw) def _station_x_value(obj: Any, idx: int) -> float: raw = _attr(obj, ("station_x", "x", "easting", "longitude", "lon")) if raw is None: return float(idx) arr = np.asarray(raw, dtype=float) return float(arr.reshape(-1)[0]) def _tdem_values(obj: Any) -> np.ndarray: dbdt = getattr(obj, "dBdt", None) if callable(dbdt): try: return np.asarray(dbdt(), dtype=float) except Exception: pass return np.asarray( _attr(obj, ("dBz_dt", "dbdt", "data", "values")), dtype=float ) def _method_from_source(obj: Any, fallback: str) -> str: method = _attr(obj, ("method", "survey_type", "data_type")) if method is None: return fallback method = str(method).lower() if method in {"dbdt", "dhdt", "voltage", "normalized_voltage"}: return "tdem" return method def _metadata_from_source(obj: Any, **extra: Any) -> dict[str, Any]: meta = _attr(obj, ("metadata", "meta")) if callable(meta): try: meta = meta() except Exception: meta = None out = dict(meta or {}) if isinstance(meta, dict) else {} out.update(extra) return out def _attr(obj: Any, names: tuple[str, ...]) -> Any: for name in names: if isinstance(obj, dict) and name in obj: return obj[name] if hasattr(obj, name): value = getattr(obj, name) return ( value() if callable(value) and name in {"metadata", "meta"} else value ) return None