Source code for pycsamt.airborne.ztem.adapter

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

"""Array-to-scientific-object adapter contract for ZTEM products.

ZTEM is represented as the standard vertical magnetic transfer function
(tipper), while acquisition geometry records that the airborne Hz output is
referenced to fixed ground Hx/Hy measurements. No proprietary native file
schema is assumed here.

Shape/frequency/mask normalization that is not specific to the ZTEM
tipper layout is delegated to :mod:`pycsamt.airborne.validation`
(shared with :mod:`pycsamt.airborne.mobilemt` and, once it moves onto
the same helpers, :mod:`pycsamt.airborne.afmag`) via its ``error_cls``
parameter, so every ``ZTEMValidationError`` raised here still comes
from this module even though the check itself is not reimplemented
per technology.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

import numpy as np

from ...emtf.document import EMTF
from ...emtf.estimates import StatisticalEstimate
from ...emtf.transfer import TransferFunction
from ...metadata import (
    OrientationMeta,
    ProcessingMeta,
    RemoteReferenceMeta,
    SiteMeta,
    SurveyMeta,
)
from ..base import AirborneEMDataset, AirborneEMLine, AirborneEMRecord
from ..navigation import NavigationTrack
from ..validation import (
    normalize_estimate_array,
    normalize_frequency,
    normalize_record_mask,
    normalize_sample_axis_array,
    resolve_frequency_or_periods,
    resolve_line_frequency_grid,
)
from .base import ZTEMReferenceStation, ZTEMSystemSpec
from .constants import (
    ZTEM_INPUT_CHANNELS,
    ZTEM_OUTPUT_CHANNELS,
    ZTEM_TIPPER_COMPONENTS,
    ZTEM_TIPPER_TAG,
    ZTEM_TIPPER_UNITS,
)

__all__ = [
    "ZTEMValidationError",
    "validate_ztem_transfer_function",
    "build_ztem_emtf",
    "build_ztem_record",
    "build_ztem_line",
    "build_ztem_dataset",
]


[docs] class ZTEMValidationError(ValueError): """Raised when decoded ZTEM scientific arrays are inconsistent."""
def _normalize_tipper(value: Any) -> np.ndarray: """Return one sample-batched ``(nf, 1, 2)`` complex tipper array. Accepts, and promotes to that canonical shape, a single ``(Tzx, Tzy)`` pair of shape ``(2,)``, a stack of shape ``(nf, 2)``, or the canonical EMTF matrix shape ``(nf, 1, 2)`` already. Raises ------ ZTEMValidationError If *value* matches none of the accepted shapes, or is not numeric. """ arr = np.asarray(value) if arr.ndim == 1 and arr.shape == (2,): arr = arr[None, None, :] elif arr.ndim == 2 and arr.shape[1:] == (2,): arr = arr[:, None, :] elif arr.ndim != 3 or arr.shape[1:] != (1, 2): raise ZTEMValidationError( "tipper must have shape (2,), (nf, 2), or (nf, 1, 2)" ) if arr.dtype.kind not in "biufc": raise ZTEMValidationError("tipper must be numeric") return arr def _reference_mapping( reference_station: ZTEMReferenceStation | None, ) -> dict[str, Any] | None: """Return *reference_station* as a plain ``EMTF.attrs`` mapping.""" if reference_station is None: return None result: dict[str, Any] = { "station_id": reference_station.preferred_id, "magnetic_channels": list(reference_station.magnetic_channels), } if reference_station.site is not None: result["site"] = reference_station.site.to_dict(max_depth=2) if reference_station.attrs: result["attrs"] = dict(reference_station.attrs) return result def _xml_notes_mapping( spec: ZTEMSystemSpec, reference_station: ZTEMReferenceStation | None, ) -> dict[str, Any]: """Return a ``{"ZTEM": {...}}`` block for ``EMTF.metadata["notes"]``. This is presentation metadata only: it does not feed :attr:`~pycsamt.emtf.EMTF.processing` (see :func:`_processing_for_reference` for that) and is not read back by any adapter, so its keys are free to describe the system however is most useful for a human or archive reading the serialized EMTF XML. """ low, high = spec.practical_frequency_range_hz ztem: dict[str, Any] = { "PracticalFrequencyMinHz": low, "PracticalFrequencyMaxHz": high, "TypicalFrequencyCountMin": spec.typical_frequency_count[0], "TypicalFrequencyCountMax": spec.typical_frequency_count[1], "TimeSeriesSamplingRateHz": spec.time_series_sampling_rate_hz, "NominalOutputRateHz": spec.nominal_output_rate_hz, "InputChannels": ",".join(spec.input_channels), "OutputChannels": ",".join(spec.output_channels), "TransferFunction": "Hz = Tzx*Hx + Tzy*Hy", } if reference_station is not None: if reference_station.preferred_id is not None: ztem["ReferenceStationId"] = reference_station.preferred_id site = reference_station.site location = site.location if site is not None else None if location is not None: if location.latitude is not None: ztem["ReferenceLatitude"] = location.latitude if location.longitude is not None: ztem["ReferenceLongitude"] = location.longitude if location.elevation is not None: ztem["ReferenceElevation"] = location.elevation if location.datum is not None: ztem["ReferenceDatum"] = location.datum return {"ZTEM": ztem} def _processing_for_reference( reference_station: ZTEMReferenceStation | None, processing: ProcessingMeta | None, ) -> ProcessingMeta | None: """Fold *reference_station* into *processing*'s remote reference. ``reference_station`` is the ZTEM-specific, scientifically typed way to supply the fixed ground magnetic reference; a caller- supplied *processing* is the general EMTF way. When both are given, they must describe the same reference site -- this raises :class:`ZTEMValidationError` on conflict rather than silently preferring one over the other. When only *reference_station* is given, this synthesizes a :class:`~pycsamt.metadata.ProcessingMeta` around it so :attr:`~pycsamt.emtf.EMTF.processing` is always the one place downstream code looks, in :func:`~pycsamt.airborne.qc.assess_airborne_qc` and elsewhere. """ if processing is not None and not isinstance(processing, ProcessingMeta): raise TypeError("processing must be a ProcessingMeta or None") if reference_station is None: return processing remote = RemoteReferenceMeta( reference_type="fixed_ground_horizontal_magnetic", site=reference_station.preferred_id, extra={ "channels": list(reference_station.magnetic_channels), "technology": "ZTEM", }, ) if processing is None: return ProcessingMeta(remote_reference=remote) if processing.remote_reference is None: processing = ProcessingMeta( sign_convention=processing.sign_convention, processed_by=processing.processed_by, software=processing.software, remote_reference=remote, processing_tag=processing.processing_tag, run_list=( None if processing.run_list is None else list(processing.run_list) ), extra=dict(processing.extra), ) return processing existing = processing.remote_reference reference_id = reference_station.preferred_id if existing.site is not None and reference_id is not None: if str(existing.site) != str(reference_id): raise ZTEMValidationError( "processing remote-reference site conflicts with " "ZTEMReferenceStation" ) return processing
[docs] def validate_ztem_transfer_function( tf: TransferFunction, ) -> TransferFunction: """Validate and return a ZTEM 1x2 vertical magnetic tipper. Parameters ---------- tf : TransferFunction Transfer function to validate in place. Returns ------- TransferFunction *tf*, unchanged, for convenient chaining after :meth:`~pycsamt.emtf.EMTF.add_transfer_function`. Raises ------ TypeError If *tf* is not a :class:`~pycsamt.emtf.TransferFunction`. ZTEMValidationError If *tf* does not use the standard EMTF ``tipper`` datatype with ``Hx``/``Hy`` inputs, ``Hz`` output, and matrix shape ``(1, 2)``. """ if not isinstance(tf, TransferFunction): raise TypeError("tf must be a TransferFunction") if tf.name != ZTEM_TIPPER_TAG: raise ZTEMValidationError( "ZTEM must use the standard EMTF 'tipper' datatype" ) if tuple(tf.input_channels) != ZTEM_INPUT_CHANNELS: raise ZTEMValidationError("ZTEM tipper inputs must be ('Hx', 'Hy')") if tuple(tf.output_channels) != ZTEM_OUTPUT_CHANNELS: raise ZTEMValidationError("ZTEM tipper output must be ('Hz',)") if tf.data.shape[1:] != (1, 2): raise ZTEMValidationError( "ZTEM tipper must have matrix shape (1, 2)" ) return tf
[docs] def build_ztem_emtf( tipper: Any, *, frequency: Any | None = None, periods: Any | None = None, units: str | None = ZTEM_TIPPER_UNITS, variance: Any | None = None, inverse_signal_covariance: Any | None = None, residual_covariance: Any | None = None, product_id: str | None = None, description: str | None = None, reference_station: ZTEMReferenceStation | None = None, system_spec: ZTEMSystemSpec | None = None, site: SiteMeta | None = None, orientation: OrientationMeta | None = None, processing: ProcessingMeta | None = None, attrs: Mapping[str, Any] | None = None, ) -> EMTF: """Build one sample-level :class:`EMTF` ZTEM response. Parameters ---------- tipper : array-like Complex ``(Tzx, Tzy)`` values with shape ``(nf, 2)`` or canonical EMTF shape ``(nf, 1, 2)``. A single ``(2,)`` vector is accepted. frequency, periods : array-like, optional Exactly one positive frequency or period vector must be supplied. variance : array-like, optional Component variance with shape ``(nf, 1, 2)``. inverse_signal_covariance : array-like, optional Input covariance factor ``S`` with shape ``(nf, 2, 2)``. residual_covariance : array-like, optional Output residual covariance ``N`` with shape ``(nf, 1, 1)``. Notes ----- ZTEM reuses the standard EMTF tipper datatype. The technology-specific distinction is acquisition geometry: airborne ``Hz`` is related to fixed ground-reference ``Hx`` and ``Hy``. No line-axis orientation is inferred. """ freq, period_arr = resolve_frequency_or_periods( frequency=frequency, periods=periods, error_cls=ZTEMValidationError, ) data = _normalize_tipper(tipper) if data.shape[0] != freq.size: raise ZTEMValidationError( "frequency count does not match tipper: " f"{freq.size} != {data.shape[0]}" ) tf = TransferFunction( name=ZTEM_TIPPER_TAG, data=data, input_channels=ZTEM_INPUT_CHANNELS, output_channels=ZTEM_OUTPUT_CHANNELS, units=units, periods=period_arr, attrs={ "technology": "ZTEM", "reference_geometry": ( "airborne_Hz_to_fixed_ground_Hx_Hy" ), "components": list(ZTEM_TIPPER_COMPONENTS), "axis_convention": "as_supplied", }, ) if variance is not None: tf.add_estimate( StatisticalEstimate( name="VAR", kind="variance", data=normalize_estimate_array( variance, n_frequency=freq.size, tail=(1, 2), name="variance", error_cls=ZTEMValidationError, ), ) ) if inverse_signal_covariance is not None: tf.add_estimate( StatisticalEstimate( name="INVSIGCOV", kind="inverse_signal_covariance", data=normalize_estimate_array( inverse_signal_covariance, n_frequency=freq.size, tail=(2, 2), name="inverse_signal_covariance", error_cls=ZTEMValidationError, ), ) ) if residual_covariance is not None: tf.add_estimate( StatisticalEstimate( name="RESIDCOV", kind="residual_covariance", data=normalize_estimate_array( residual_covariance, n_frequency=freq.size, tail=(1, 1), name="residual_covariance", error_cls=ZTEMValidationError, ), ) ) spec = system_spec or ZTEMSystemSpec() if not isinstance(spec, ZTEMSystemSpec): raise TypeError("system_spec must be a ZTEMSystemSpec or None") if reference_station is not None and not isinstance( reference_station, ZTEMReferenceStation, ): raise TypeError( "reference_station must be ZTEMReferenceStation or None" ) if site is not None and not isinstance(site, SiteMeta): raise TypeError("site must be a SiteMeta or None") if orientation is not None and not isinstance( orientation, OrientationMeta, ): raise TypeError("orientation must be an OrientationMeta or None") ztem_meta: dict[str, Any] = { "system": spec.to_dict(max_depth=2), "reference_station": _reference_mapping(reference_station), } document_attrs = dict(attrs or {}) document_attrs["ztem"] = ztem_meta doc = EMTF( product_id=product_id, description=( description or "ZTEM airborne vertical magnetic transfer-function response" ), subtype="ztem", tags=("ztem", ZTEM_TIPPER_TAG), periods=period_arr, site=site, orientation=orientation, processing=_processing_for_reference(reference_station, processing), metadata={ "notes": _xml_notes_mapping(spec, reference_station), }, attrs=document_attrs, ) doc.add_transfer_function(tf) validate_ztem_transfer_function(tf) return doc
[docs] def build_ztem_record( sample_id: str, tipper: Any, *, frequency: Any | None = None, periods: Any | None = None, fields: Mapping[str, Any] | None = None, quality: Mapping[str, Any] | None = None, record_attrs: Mapping[str, Any] | None = None, **emtf_kwargs: Any, ) -> AirborneEMRecord: """Build one airborne record from decoded ZTEM tipper values. Parameters ---------- sample_id : str Navigation sample identifier for the new record. tipper : array-like Forwarded to :func:`build_ztem_emtf`. frequency, periods : array-like, optional Exactly one must be supplied; forwarded to :func:`build_ztem_emtf`. fields, quality, record_attrs : dict, optional Forwarded to :class:`~pycsamt.airborne.base.AirborneEMRecord`. **emtf_kwargs Forwarded to :func:`build_ztem_emtf`. Returns ------- AirborneEMRecord The record, with its EMTF ``product_id`` defaulted to ``str(sample_id)`` unless overridden in ``emtf_kwargs``. """ doc = build_ztem_emtf( tipper, frequency=frequency, periods=periods, product_id=emtf_kwargs.pop("product_id", str(sample_id)), **emtf_kwargs, ) return AirborneEMRecord( sample_id=str(sample_id), emtf=doc, fields=dict(fields or {}), quality=dict(quality or {}), attrs=dict(record_attrs or {}), )
def _sample_axis_tipper( value: Any, *, n_samples: int, ) -> np.ndarray: """Return one line's tipper data as ``(n_samples, nf, 1, 2)``. Combines two independent promotions: the leading sample axis may be omitted when ``n_samples == 1`` (see :func:`~pycsamt.airborne.validation.normalize_sample_axis_array`), and the tipper tail axis may be supplied as ``(..., 2)`` instead of the canonical ``(..., 1, 2)`` (see :func:`_normalize_tipper`). Both are handled together here because unlike the generic per-estimate arrays (variance, covariances), a stacked ``(nf, 2)`` per-sample tipper is ambiguous between "one sample, ``nf`` rows" and "``nf`` samples, one row" without also knowing ``n_samples``. Raises ------ ZTEMValidationError If *value* does not match one of the accepted shapes for the given *n_samples*, or is not numeric. """ arr = np.asarray(value) if arr.ndim == 1 and arr.shape == (2,) and n_samples == 1: arr = arr[None, None, None, :] elif arr.ndim == 2 and arr.shape[-1] == 2 and n_samples == 1: arr = arr[None, :, None, :] elif ( arr.ndim == 3 and arr.shape[-2:] == (1, 2) and n_samples == 1 ): arr = arr[None, ...] elif arr.ndim == 3 and arr.shape[0] == n_samples: if arr.shape[-1] != 2: raise ZTEMValidationError( "line tipper component axis must contain Tzx/Tzy" ) arr = arr[:, :, None, :] if arr.ndim != 4 or arr.shape[0] != n_samples: raise ZTEMValidationError( "tipper must have shape (samples, nf, 2) or " "(samples, nf, 1, 2)" ) if arr.shape[2:] != (1, 2): raise ZTEMValidationError( "line tipper must have matrix shape (samples, nf, 1, 2)" ) if arr.dtype.kind not in "biufc": raise ZTEMValidationError("tipper must be numeric") return arr
[docs] def build_ztem_line( line_id: str, navigation: NavigationTrack, tipper: Any, *, frequency: Any, record_mask: Any | None = None, variance: Any | None = None, inverse_signal_covariance: Any | None = None, residual_covariance: Any | None = None, units: str | None = ZTEM_TIPPER_UNITS, reference_station: ZTEMReferenceStation | None = None, system_spec: ZTEMSystemSpec | None = None, orientation: OrientationMeta | None = None, attrs: Mapping[str, Any] | None = None, ) -> AirborneEMLine: """Build one ZTEM flight line from decoded sample-aligned arrays. Parameters ---------- line_id : str Flight-line identifier. navigation : NavigationTrack Sample-aligned navigation defining the line's sample axis. tipper : array-like Forwarded to :func:`_sample_axis_tipper`; accepts the per-sample analogues of every shape :func:`_normalize_tipper` accepts for one sample. frequency : array-like Either one shared ``(nf,)`` vector or a per-sample ``(n_samples, nf)`` grid; see :func:`~pycsamt.airborne.validation.resolve_line_frequency_grid`. record_mask : array-like of bool, optional Marks which navigation samples get an attached record; ``None`` means every sample does. Samples excluded here never need a valid ``tipper``/``frequency`` row -- navigation points are never deleted to represent a rejected EM sample. variance, inverse_signal_covariance, residual_covariance : array-like, optional Per-sample statistical estimates, each shaped ``(n_samples, nf, *tail)`` (or unbatched when ``n_samples == 1``); forwarded per sample to :func:`build_ztem_record`. units : str, optional Forwarded to :func:`build_ztem_emtf` for every sample. reference_station, system_spec, orientation : optional Forwarded to :func:`build_ztem_emtf` for every sample. attrs : dict, optional Line-level extension metadata; ``"technology"`` and ``"reference_station"`` are set here unless already present. Returns ------- AirborneEMLine The line, with one record per sample where ``record_mask`` (or its default) is ``True``. Raises ------ TypeError If *navigation* is not a :class:`NavigationTrack`. ZTEMValidationError If *tipper*, *frequency*, *record_mask*, or any statistical estimate does not match its expected shape. """ if not isinstance(navigation, NavigationTrack): raise TypeError("navigation must be a NavigationTrack") n_samples = navigation.n_samples data = _sample_axis_tipper(tipper, n_samples=n_samples) n_frequency = data.shape[1] common_frequency, frequency_rows = resolve_line_frequency_grid( frequency, n_samples=n_samples, n_frequency=n_frequency, error_cls=ZTEMValidationError, ) mask = normalize_record_mask( record_mask, n_samples=n_samples, error_cls=ZTEMValidationError, ) def _optional_sample( value: Any | None, *, name: str, tail: tuple[int, int], ) -> np.ndarray | None: if value is None: return None expected = (n_samples, n_frequency, *tail) return normalize_sample_axis_array( value, name=name, n_samples=n_samples, expected=expected, error_cls=ZTEMValidationError, ) var_all = _optional_sample( variance, name="variance", tail=(1, 2), ) inv_all = _optional_sample( inverse_signal_covariance, name="inverse_signal_covariance", tail=(2, 2), ) res_all = _optional_sample( residual_covariance, name="residual_covariance", tail=(1, 1), ) line_attrs = dict(attrs or {}) line_attrs.setdefault("technology", "ZTEM") line_attrs.setdefault( "reference_station", _reference_mapping(reference_station), ) line = AirborneEMLine( line_id=str(line_id), navigation=navigation, attrs=line_attrs, ) for index, sample_id in enumerate(navigation.sample_ids): if not mask[index]: continue sample_frequency = ( common_frequency if common_frequency is not None else normalize_frequency( frequency_rows[index], name=f"frequency[{sample_id}]", error_cls=ZTEMValidationError, ) ) record = build_ztem_record( sample_id, data[index], frequency=sample_frequency, variance=None if var_all is None else var_all[index], inverse_signal_covariance=( None if inv_all is None else inv_all[index] ), residual_covariance=( None if res_all is None else res_all[index] ), units=units, reference_station=reference_station, system_spec=system_spec, orientation=orientation, product_id=f"{line.line_id}.{sample_id}", record_attrs={"navigation_index": index}, ) line.add_record(record) return line
[docs] def build_ztem_dataset( name: str, lines: Any, *, survey: SurveyMeta | None = None, system_spec: ZTEMSystemSpec | None = None, instrument_serial: str | None = None, software_version: str = "", attrs: Mapping[str, Any] | None = None, ) -> AirborneEMDataset: """Build a common airborne dataset from constructed ZTEM lines. Parameters ---------- name : str Dataset/survey name. lines : iterable of AirborneEMLine, or mapping of str to AirborneEMLine Lines to attach, typically previously built by :func:`build_ztem_line`. survey : SurveyMeta, optional Survey-level metadata. system_spec : ZTEMSystemSpec, optional Used to build the dataset's ``instrument`` metadata; defaults to published nominal values. instrument_serial : str, optional Forwarded to :meth:`ZTEMSystemSpec.to_instrument_meta`. software_version : str, optional Forwarded to :meth:`ZTEMSystemSpec.to_instrument_meta`. attrs : dict, optional Dataset-level extension metadata; ``"technology"`` and ``"ztem_system"`` are set here unless already present. Returns ------- AirborneEMDataset The dataset, with every line attached via :meth:`~pycsamt.airborne.base.AirborneEMDataset.add_line`. Raises ------ TypeError If *survey*, *system_spec*, or an entry of *lines* has the wrong type. ZTEMValidationError If a line is explicitly tagged with a different technology. """ if survey is not None and not isinstance(survey, SurveyMeta): raise TypeError("survey must be a SurveyMeta or None") spec = system_spec or ZTEMSystemSpec() if not isinstance(spec, ZTEMSystemSpec): raise TypeError("system_spec must be a ZTEMSystemSpec or None") dataset_attrs = dict(attrs or {}) dataset_attrs.setdefault("technology", "ZTEM") dataset_attrs.setdefault("ztem_system", spec.to_dict(max_depth=2)) dataset = AirborneEMDataset( name=str(name), survey=survey, instrument=spec.to_instrument_meta( serial=instrument_serial, software_version=software_version, ), method="AEM", attrs=dataset_attrs, ) iterable = lines.values() if isinstance(lines, Mapping) else lines for line in iterable: if not isinstance(line, AirborneEMLine): raise TypeError("lines must contain AirborneEMLine instances") technology = str(line.attrs.get("technology", "")).strip().lower() if technology and technology != "ztem": raise ZTEMValidationError( f"line {line.line_id!r} is tagged as {technology!r}" ) dataset.add_line(line) return dataset