# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Document-level electromagnetic transfer-function scientific container."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from ..core.base import MTBase, TFBundle
from ..metadata import (
CopyrightInfo,
LocationMeta,
OrientationMeta,
ProcessingMeta,
ProvenanceMeta,
SiteLayout,
SiteMeta,
TransferFunctionQuality,
)
from .base import (
IMPEDANCE_INPUT_CHANNELS,
IMPEDANCE_OUTPUT_CHANNELS,
LEGACY_STANDARD_ERROR_KIND,
LEGACY_STANDARD_ERROR_NAME,
TIPPER_INPUT_CHANNELS,
TIPPER_OUTPUT_CHANNELS,
)
from .estimates import StatisticalEstimate
from .transfer import TransferFunction
from .validation import normalize_periods
__all__ = ["EMTF"]
def _as_frequency(value: Any | None) -> np.ndarray | None:
if value is None:
return None
arr = np.asarray(value, dtype=float)
if arr.ndim == 0:
arr = arr.reshape(1)
if arr.ndim != 1:
raise ValueError("frequency must be a 1-D array")
if arr.size and (not np.all(np.isfinite(arr)) or np.any(arr <= 0.0)):
raise ValueError("frequency must contain finite positive values")
return arr
def _tipper_to_matrix(value: Any) -> np.ndarray:
arr = np.asarray(value)
if arr.ndim == 1 and arr.shape == (2,):
return arr[None, None, :]
if arr.ndim == 2 and arr.shape[1] == 2:
return arr[:, None, :]
if arr.ndim == 3 and arr.shape[1:] == (1, 2):
return arr
raise ValueError(
"legacy tipper must have shape (2,), (n, 2), or (n, 1, 2)"
)
def _legacy_scalar_or_tensor(
name: str,
value: Any,
*,
periods: np.ndarray | None,
) -> TransferFunction:
arr = np.asarray(value)
if arr.ndim == 3 and arr.shape[1:] == (2, 2):
return TransferFunction(
name=name,
data=arr,
input_channels=IMPEDANCE_INPUT_CHANNELS,
output_channels=IMPEDANCE_OUTPUT_CHANNELS,
periods=periods,
)
if arr.ndim == 2 and arr.shape == (2, 2):
return TransferFunction(
name=name,
data=arr,
input_channels=IMPEDANCE_INPUT_CHANNELS,
output_channels=IMPEDANCE_OUTPUT_CHANNELS,
periods=periods,
)
if arr.ndim <= 1:
return TransferFunction(name=name, data=arr, periods=periods)
raise ValueError(
f"legacy {name} data must be scalar/frequency-vector or (n, 2, 2)"
)
[docs]
@dataclass(repr=False)
class EMTF(MTBase):
"""Format-neutral electromagnetic transfer-function document.
``EMTF`` is the scientific object that EDI and EMTF XML adapters will
eventually populate. Phase 2 adds reusable metadata objects while keeping
the model independent of XML parsing and :mod:`pycsamt.seg`.
"""
product_id: str | None = None
description: str | None = None
subtype: str | None = None
tags: tuple[str, ...] = field(default_factory=tuple)
periods: Any | None = None
transfer_functions: dict[str, TransferFunction] = field(
default_factory=dict
)
# Format-neutral document metadata. These objects are independent of
# EDI and XML syntax and can therefore be reused by future formats.
provenance: ProvenanceMeta | None = None
copyright: CopyrightInfo | None = None
site: SiteMeta | None = None
site_layout: SiteLayout | None = None
orientation: OrientationMeta | None = None
processing: ProcessingMeta | None = None
quality: TransferFunctionQuality | None = None
field_notes: dict[str, Any] = field(default_factory=dict)
# Legacy TFBundle bridge. These fields remain available so existing
# pyCSAMT callers do not need to understand the richer metadata model.
station: str | None = None
station_id: str | int | None = None
lat: float | None = None
lon: float | None = None
elev: float | None = None
azimuth: float | None = None
# Unmodelled document metadata and application-specific attributes.
metadata: dict[str, Any] = field(default_factory=dict)
attrs: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
self.validate()
[docs]
def validate(self) -> None:
"""Normalize document state and validate attached TF period grids."""
self.periods = normalize_periods(self.periods)
self.tags = tuple(
str(tag).strip().lower()
for tag in self.tags
if str(tag).strip()
)
self.metadata = dict(self.metadata or {})
self.attrs = dict(self.attrs or {})
self.field_notes = dict(self.field_notes or {})
self._validate_metadata_types()
self._synchronize_site_bridge()
incoming = dict(self.transfer_functions or {})
self.transfer_functions = {}
for key, tf in incoming.items():
self.add_transfer_function(tf, key=key, replace=True)
def _validate_metadata_types(self) -> None:
expected = (
("provenance", self.provenance, ProvenanceMeta),
("copyright", self.copyright, CopyrightInfo),
("site", self.site, SiteMeta),
("site_layout", self.site_layout, SiteLayout),
("orientation", self.orientation, OrientationMeta),
("processing", self.processing, ProcessingMeta),
("quality", self.quality, TransferFunctionQuality),
)
for name, value, cls in expected:
if value is not None and not isinstance(value, cls):
raise TypeError(f"{name} must be {cls.__name__} or None")
def _synchronize_site_bridge(self) -> None:
"""Expose explicit ``SiteMeta`` values through legacy aliases.
Phase 2 intentionally does not infer a ``SiteMeta`` object from the
legacy TFBundle fields. Their historical ``station``/``station_id``
semantics are not identical to EMTF ``Site/Id`` and ``Site/Name``.
The later EDI interoperability layer will perform that mapping with
format-specific knowledge.
"""
if self.site is None:
return
station_key = (
self.site.site_id
if self.site.site_id is not None
else self.site.name
)
self._merge_bridge_value("station", station_key)
self._merge_bridge_value("station_id", self.site.site_id)
location = self.site.location
if location is not None:
self._merge_bridge_value("lat", location.latitude)
self._merge_bridge_value("lon", location.longitude)
self._merge_bridge_value("elev", location.elevation)
def _merge_bridge_value(self, name: str, metadata_value: Any) -> None:
if metadata_value is None:
return
legacy_value = getattr(self, name)
if legacy_value is None:
setattr(self, name, metadata_value)
return
if name in {"lat", "lon", "elev"}:
if not np.isclose(float(legacy_value), float(metadata_value)):
raise ValueError(
f"legacy {name} conflicts with SiteMeta: "
f"{legacy_value!r} != {metadata_value!r}"
)
return
if str(legacy_value) != str(metadata_value):
raise ValueError(
f"legacy {name} conflicts with SiteMeta: "
f"{legacy_value!r} != {metadata_value!r}"
)
[docs]
@property
def frequency(self) -> np.ndarray | None:
"""Return the document frequency vector in Hz when available."""
if self.periods is not None:
return 1.0 / np.asarray(self.periods, dtype=float)
for tf in self.transfer_functions.values():
if tf.periods is not None:
return tf.frequency
return None
[docs]
@property
def n_periods(self) -> int:
"""Return the common number of period samples when known."""
if self.periods is not None:
return int(np.asarray(self.periods).size)
for tf in self.transfer_functions.values():
return tf.n_periods
return 0
[docs]
def add_transfer_function(
self,
tf: TransferFunction,
*,
key: str | None = None,
replace: bool = False,
) -> "EMTF":
"""Attach one scientific transfer function to the document."""
if not isinstance(tf, TransferFunction):
raise TypeError("tf must be a TransferFunction")
tf_key = str(key or tf.name).strip().lower()
if not tf_key:
raise ValueError("transfer-function key must be non-empty")
if tf_key in self.transfer_functions and not replace:
raise ValueError(f"transfer function already exists: {tf_key}")
if self.periods is not None and tf.periods is not None:
doc_periods = np.asarray(self.periods, dtype=float)
tf_periods = np.asarray(tf.periods, dtype=float)
if doc_periods.shape != tf_periods.shape or not np.allclose(
doc_periods,
tf_periods,
rtol=1.0e-10,
atol=0.0,
):
raise ValueError(
f"period grid for {tf_key!r} differs from EMTF document"
)
elif self.periods is None and tf.periods is not None:
self.periods = np.array(tf.periods, copy=True)
self.transfer_functions[tf_key] = tf
if tf_key not in self.tags:
self.tags = (*self.tags, tf_key)
return self
[docs]
def get_transfer_function(self, key: str) -> TransferFunction | None:
"""Return a TF by semantic key or registered short code."""
raw = str(key).strip()
if not raw:
return None
direct = self.transfer_functions.get(raw.lower())
if direct is not None:
return direct
for tf in self.transfer_functions.values():
definition = tf.definition
if definition is not None:
names = {definition.name, *definition.aliases}
if raw.upper() in names:
return tf
return None
[docs]
@property
def impedance(self) -> TransferFunction | None:
"""Return the matrix-oriented impedance TF."""
return self.get_transfer_function("impedance")
[docs]
@property
def tipper_tf(self) -> TransferFunction | None:
"""Return the matrix-oriented tipper TF."""
return self.get_transfer_function("tipper")
[docs]
@property
def z(self) -> np.ndarray | None:
"""Return the impedance array for legacy-style access."""
tf = self.impedance
return None if tf is None else tf.data
[docs]
@property
def z_err(self) -> np.ndarray | None:
"""Return legacy standard errors when explicitly available."""
tf = self.impedance
if tf is None:
return None
estimate = tf.get_estimate(LEGACY_STANDARD_ERROR_KIND)
return None if estimate is None else estimate.data
[docs]
@property
def tipper(self) -> np.ndarray | None:
"""Return tipper data in the pyCSAMT ``(n, 1, 2)`` shape."""
tf = self.tipper_tf
return None if tf is None else tf.data
[docs]
@property
def tipper_err(self) -> np.ndarray | None:
"""Return legacy tipper standard errors when available."""
tf = self.tipper_tf
if tf is None:
return None
estimate = tf.get_estimate(LEGACY_STANDARD_ERROR_KIND)
return None if estimate is None else estimate.data
[docs]
@property
def rho(self) -> np.ndarray | None:
"""Return stored apparent resistivity, or derive it from ``Z``."""
tf = self.get_transfer_function("apparent_resistivity")
if tf is not None:
data = tf.data
if data.shape[1:] == (1, 1):
return data[:, 0, 0]
return data
z_obj = self.Z
return None if z_obj is None else z_obj.resistivity
[docs]
@property
def phase(self) -> np.ndarray | None:
"""Return stored impedance phase, or derive it from ``Z``."""
tf = self.get_transfer_function("impedance_phase")
if tf is not None:
data = tf.data
if data.shape[1:] == (1, 1):
return data[:, 0, 0]
return data
z_obj = self.Z
return None if z_obj is None else z_obj.phase
[docs]
@property
def Z(self):
"""Build the existing :class:`pycsamt.z.Z` compatibility object."""
if self.z is None:
return None
if self.z.shape[1:] != (2, 2):
return None
from ..z.z import Z
return Z(
z_array=self.z,
z_err_array=self.z_err,
freq=self.frequency,
name=self.station,
)
[docs]
@property
def Tip(self):
"""Build the existing :class:`pycsamt.z.tipper.Tipper` object."""
if self.tipper is None:
return None
from ..z.tipper import Tipper
return Tipper(
tipper_array=self.tipper,
tipper_err_array=self.tipper_err,
freq=self.frequency,
name=self.station,
)
[docs]
@classmethod
def from_xml(cls, source, *, strict: bool = True) -> "EMTF":
"""Read an EMTF XML document through the XML adapter.
The import is intentionally local so constructing the scientific core
never requires XML serialization support unless this method is called.
"""
from .xml import EMTFXMLReader
return EMTFXMLReader(strict=strict).read(source)
[docs]
@classmethod
def from_edi(cls, source, **kwargs) -> "EMTF":
"""Convert a historical SEG EDI object or path into ``EMTF``.
EDI SPECTRA are preferred by default when present because they retain
the full information needed to recover transfer-function covariance.
Pass ``prefer_spectra=False`` to force the traditional impedance/tipper
blocks instead. The adapter import remains local so the scientific core
is independent of :mod:`pycsamt.seg` until requested.
"""
from .converters.edi import edi_to_emtf
return edi_to_emtf(source, **kwargs)
[docs]
@classmethod
def from_edi_spectra(cls, source, **kwargs) -> "EMTF":
"""Recover EMTFs and full covariance directly from EDI SPECTRA.
This is the explicit Phase-8 entry point for single-station and
remote-reference cross-power spectra. Any requested rotation is
delegated to the format-neutral Phase-7 rotation engine.
"""
from .converters.spectra import spectra_to_emtf
return spectra_to_emtf(source, **kwargs)
[docs]
def to_edi(self, *, on_loss: str = "warn"):
"""Return an in-memory :class:`pycsamt.seg.EDIFile` representation.
Parameters
----------
on_loss : {"warn", "raise", "ignore"}
Policy for EMTF content that standard EDI cannot preserve.
"""
from .converters.edi import emtf_to_edi
return emtf_to_edi(self, on_loss=on_loss)
[docs]
def to_xml(
self,
*,
strict: bool = True,
precision: int = 17,
pretty: bool = True,
xml_declaration: bool = True,
) -> str:
"""Serialize this scientific document to an EMTF XML string."""
from .xml import EMTFXMLWriter
return EMTFXMLWriter(
strict=strict,
precision=precision,
pretty=pretty,
).dumps(self, xml_declaration=xml_declaration)
[docs]
def write_xml(
self,
target,
*,
strict: bool = True,
precision: int = 17,
pretty: bool = True,
xml_declaration: bool = True,
encoding: str = "utf-8",
):
"""Write this document in EMTF XML format."""
from .xml import write_emtf_xml
return write_emtf_xml(
self,
target,
strict=strict,
precision=precision,
pretty=pretty,
xml_declaration=xml_declaration,
encoding=encoding,
)
[docs]
def write(self, target, *, format: str = "emtf_xml", **kwargs):
"""Write this document using a supported serialization format.
Phase 6 supports EMTF XML and historical SEG EDI. EDI conversion is
explicit and may emit :class:`DataLossWarning` for content that the
historical format cannot represent.
"""
normalized = str(format).strip().lower().replace("-", "_")
if normalized in {"emtf_xml", "xml", "emtf"}:
return self.write_xml(target, **kwargs)
if normalized in {"edi", "seg_edi"}:
from .converters.edi import write_edi
return write_edi(self, target, **kwargs)
raise ValueError("unsupported EMTF output format: " f"{format!r}")
[docs]
def rotate(
self,
angle: float | None = 0.0,
*,
target: str = "orthogonal",
inplace: bool = False,
source_angles=None,
use_legacy_edi_rotation: bool = False,
variance_policy: str = "drop",
unsupported_estimates: str = "drop",
derived_policy: str = "drop",
) -> "EMTF":
"""Rotate transfer functions with the format-neutral EMTF engine.
The original :class:`~pycsamt.metadata.SiteLayout` remains physical
acquisition metadata and is never rotated. Full covariance factors
are transformed consistently when present.
"""
from .orientation import rotate_emtf
return rotate_emtf(
self,
angle,
target=target,
inplace=inplace,
source_angles=source_angles,
use_legacy_edi_rotation=use_legacy_edi_rotation,
variance_policy=variance_policy,
unsupported_estimates=unsupported_estimates,
derived_policy=derived_policy,
)
[docs]
def is_empty(self) -> bool:
"""Return ``True`` when no transfer-function content is attached."""
return not bool(self.transfer_functions)
[docs]
def to_bundle(self) -> TFBundle:
"""Return a backward-compatible :class:`TFBundle` view."""
tip = self.tipper
tip_err = self.tipper_err
def _stored_derived(tag: str):
tf = self.get_transfer_function(tag)
if tf is None:
return None
data = tf.data
if data.shape[1:] == (1, 1):
return data[:, 0, 0]
return data
rho = _stored_derived("apparent_resistivity")
phase = _stored_derived("impedance_phase")
if tip is not None and tip.ndim == 3 and tip.shape[1:] == (1, 2):
tip = tip[:, 0, :]
if (
tip_err is not None
and tip_err.ndim == 3
and tip_err.shape[1:] == (1, 2)
):
tip_err = tip_err[:, 0, :]
return TFBundle(
freq=self.frequency,
z=self.z,
z_err=self.z_err,
tipper=tip,
tipper_err=tip_err,
rho=rho,
phase=phase,
station=self.station,
station_id=self.station_id,
lat=self.lat,
lon=self.lon,
elev=self.elev,
azimuth=self.azimuth,
attrs=dict(self.attrs),
transfer_functions=dict(self.transfer_functions),
estimates={
key: dict(tf.estimates)
for key, tf in self.transfer_functions.items()
if tf.estimates
},
)
[docs]
@classmethod
def from_bundle(cls, bundle: TFBundle) -> "EMTF":
"""Build an EMTF document from a legacy/generalized bundle."""
if not isinstance(bundle, TFBundle):
raise TypeError("bundle must be a TFBundle")
freq = _as_frequency(bundle.freq)
periods = None if freq is None else 1.0 / freq
out = cls(
periods=periods,
station=bundle.station,
station_id=bundle.station_id,
lat=bundle.lat,
lon=bundle.lon,
elev=bundle.elev,
azimuth=bundle.azimuth,
attrs=dict(bundle.attrs or {}),
)
for key, value in dict(bundle.transfer_functions or {}).items():
if isinstance(value, TransferFunction):
out.add_transfer_function(value.copy(), key=key, replace=True)
if bundle.z is not None and out.impedance is None:
ztf = TransferFunction(
name="impedance",
data=np.asarray(bundle.z),
input_channels=IMPEDANCE_INPUT_CHANNELS,
output_channels=IMPEDANCE_OUTPUT_CHANNELS,
periods=periods,
)
if bundle.z_err is not None:
ztf.add_estimate(
StatisticalEstimate(
name=LEGACY_STANDARD_ERROR_NAME,
kind=LEGACY_STANDARD_ERROR_KIND,
data=np.asarray(bundle.z_err),
attrs={
"semantics": "pycsamt_legacy_z_err",
"source": "TFBundle",
},
)
)
out.add_transfer_function(ztf, replace=True)
if bundle.tipper is not None and out.tipper_tf is None:
tip_data = _tipper_to_matrix(bundle.tipper)
ttf = TransferFunction(
name="tipper",
data=tip_data,
input_channels=TIPPER_INPUT_CHANNELS,
output_channels=TIPPER_OUTPUT_CHANNELS,
periods=periods,
)
if bundle.tipper_err is not None:
tip_err = _tipper_to_matrix(bundle.tipper_err)
ttf.add_estimate(
StatisticalEstimate(
name=LEGACY_STANDARD_ERROR_NAME,
kind=LEGACY_STANDARD_ERROR_KIND,
data=tip_err,
attrs={
"semantics": "pycsamt_legacy_z_err",
"source": "TFBundle",
},
)
)
out.add_transfer_function(ttf, replace=True)
if (
bundle.rho is not None
and out.get_transfer_function("apparent_resistivity") is None
):
out.add_transfer_function(
_legacy_scalar_or_tensor(
"apparent_resistivity",
bundle.rho,
periods=periods,
),
replace=True,
)
if (
bundle.phase is not None
and out.get_transfer_function("impedance_phase") is None
):
out.add_transfer_function(
_legacy_scalar_or_tensor(
"impedance_phase",
bundle.phase,
periods=periods,
),
replace=True,
)
return out