# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Scientific adapter contracts for historical and tensor AFMAG products.
No native vendor/archive parser is defined here. The module accepts decoded
arrays and maps two distinct AFMAG generations into the common pyCSAMT model:
* original comparator AFMAG -> scalar line-direction tilt response;
* tensor AFMAG / AirMt -> 3 x 2 interstation magnetic transfer function plus
the optional rotationally invariant amplification parameter.
Shape/frequency/mask/metadata normalization that is not specific to
either AFMAG generation is delegated to
:mod:`pycsamt.airborne.validation` (shared with
:mod:`pycsamt.airborne.mobilemt` and :mod:`pycsamt.airborne.ztem`) via
its ``error_cls`` parameter, so every ``AFMAGValidationError`` raised
here still comes from this module even though the check itself is not
reimplemented per technology. ``build_original_afmag_*`` and
``build_airmt_*`` remain genuinely separate below them, because the
two AFMAG generations are scientifically distinct responses (a real
scalar tilt versus a complex 3x2 interstation tensor), not two
configurations of one response.
"""
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,
SiteMeta,
SurveyMeta,
)
from ..base import AirborneEMDataset, AirborneEMLine, AirborneEMRecord
from ..navigation import NavigationTrack
from ..validation import (
merge_remote_reference_processing,
normalize_estimate_array,
normalize_frequency,
normalize_record_mask,
normalize_sample_axis_array,
reference_station_mapping,
resolve_frequency_or_periods,
)
from .base import (
AFMAGReferenceStation,
AirMtSystemSpec,
OriginalAFMAGSystemSpec,
)
from .constants import (
AFMAG_AP_TAG,
AFMAG_AP_UNITS,
AFMAG_TENSOR_COMPONENTS,
AFMAG_TENSOR_INPUT_CHANNELS,
AFMAG_TENSOR_OUTPUT_CHANNELS,
AFMAG_TENSOR_TAG,
AFMAG_TENSOR_UNITS,
AFMAG_TILT_DEFAULT_UNITS,
AFMAG_TILT_TAG,
)
from .datatypes import register_afmag_datatypes
__all__ = [
"AFMAGValidationError",
"compute_airmt_amplification_parameter",
"validate_airmt_transfer_function",
"validate_original_afmag_tilt",
"build_airmt_emtf",
"build_airmt_record",
"build_airmt_line",
"build_airmt_dataset",
"build_original_afmag_emtf",
"build_original_afmag_record",
"build_original_afmag_line",
"build_original_afmag_dataset",
]
[docs]
class AFMAGValidationError(ValueError):
"""Raised when decoded AFMAG scientific arrays are inconsistent."""
def _normalize_tilt(value: Any) -> np.ndarray:
"""Return one sample-batched real-valued ``(nf,)`` tilt array.
Original comparator AFMAG reports a real angle or deflection, not
a complex EM transfer function; a genuinely complex input (with
non-zero imaginary part) is rejected rather than silently
truncated to its real part.
Raises
------
AFMAGValidationError
If *value* is not scalar/1-D, is not numeric, or is complex
with a non-zero imaginary component.
"""
arr = np.asarray(value)
if arr.ndim == 0:
arr = arr.reshape(1)
if arr.ndim != 1:
raise AFMAGValidationError(
"original AFMAG tilt must be scalar or a 1-D array"
)
if arr.dtype.kind not in "biufc":
raise AFMAGValidationError("original AFMAG tilt must be numeric")
if np.iscomplexobj(arr) and np.any(np.imag(arr) != 0.0):
raise AFMAGValidationError("original AFMAG tilt must be real-valued")
return np.asarray(np.real(arr), dtype=float)
def _normalize_scalar_variance(
value: Any,
*,
n_frequency: int,
) -> np.ndarray:
"""Return one sample-batched real-valued ``(nf, 1, 1)`` variance array.
Distinct from :func:`~pycsamt.airborne.validation.normalize_estimate_array`
in two ways beyond shape: a bare ``(nf,)`` vector is also accepted
(there is no separate output/input channel axis to distinguish it
from), and the result is coerced real, matching
:func:`_normalize_tilt`'s real-valued response.
Raises
------
AFMAGValidationError
If *value* does not match one of the accepted shapes, or is
complex with a non-zero imaginary component.
"""
arr = np.asarray(value)
if arr.ndim == 0:
arr = arr.reshape(1)
if arr.ndim == 1:
arr = arr[:, None, None]
elif arr.ndim == 2 and arr.shape == (1, 1):
arr = arr[None, ...]
if arr.shape != (n_frequency, 1, 1):
raise AFMAGValidationError(
"tilt variance must have shape "
f"{(n_frequency, 1, 1)} or ({n_frequency},)"
)
if np.iscomplexobj(arr) and np.any(np.imag(arr) != 0.0):
raise AFMAGValidationError("tilt variance must be real-valued")
return np.asarray(np.real(arr), dtype=float)
[docs]
def compute_airmt_amplification_parameter(
tensor: Any,
*,
zero_policy: str = "nan",
) -> np.ndarray:
"""Compute the AirMt rotation-invariant complex amplification parameter.
For the two column vectors ``T1`` and ``T2`` of a 3 x 2 magnetic transfer
function, the implemented patent formulation is::
Kvec = T1 x T2
AP = Kvec . Re(Kvec) / |Re(Kvec)|
Parameters
----------
tensor : array-like
One ``(3, 2)`` or ``(3, 3)`` matrix, or a family with those
trailing dimensions. The patent parameter uses the first two
column vectors in either case.
zero_policy : {"nan", "raise"}
Behavior when ``|Re(Kvec)|`` is zero. Such a sample has no defined
projection direction for this parameter.
Returns
-------
numpy.ndarray
Complex AP with shape equal to the leading tensor dimensions.
"""
arr = np.asarray(tensor)
if (
arr.ndim < 2
or arr.shape[-2] != 3
or arr.shape[-1] not in {2, 3}
):
raise AFMAGValidationError(
"AirMt amplification parameter requires (..., 3, 2) or "
"(..., 3, 3) data"
)
if arr.dtype.kind not in "biufc":
raise AFMAGValidationError("AirMt tensor must be numeric")
policy = str(zero_policy).strip().lower()
if policy not in {"nan", "raise"}:
raise ValueError("zero_policy must be 'nan' or 'raise'")
first = arr[..., :, 0]
second = arr[..., :, 1]
cross = np.cross(first, second, axis=-1)
real_cross = np.real(cross)
norm = np.linalg.norm(real_cross, axis=-1)
numerator = np.sum(cross * real_cross, axis=-1)
bad = np.isfinite(norm) & (norm <= np.finfo(float).eps)
if policy == "raise" and np.any(bad):
raise AFMAGValidationError(
"AirMt amplification parameter is undefined where "
"|Re(T1 x T2)| is zero"
)
result = np.full(norm.shape, np.nan + 1j * np.nan, dtype=complex)
good = np.isfinite(norm) & (norm > np.finfo(float).eps)
result[good] = numerator[good] / norm[good]
return result
[docs]
def validate_airmt_transfer_function(
tf: TransferFunction,
) -> TransferFunction:
"""Validate and return an AirMt 3 x 2 interstation magnetic TF.
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`.
AFMAGValidationError
If *tf* does not use the standard EMTF interstation
``TI``/``interstation_transfer_functions`` datatype with
``Hx``/``Hy`` inputs, ``Hx``/``Hy``/``Hz`` outputs, and matrix
shape ``(3, 2)``.
"""
if not isinstance(tf, TransferFunction):
raise TypeError("tf must be a TransferFunction")
if tf.name != AFMAG_TENSOR_TAG:
raise AFMAGValidationError(
"AirMt must use the standard EMTF interstation magnetic TF"
)
if tuple(tf.input_channels) != AFMAG_TENSOR_INPUT_CHANNELS:
raise AFMAGValidationError("AirMt transfer inputs must be Hx/Hy")
if tuple(tf.output_channels) != AFMAG_TENSOR_OUTPUT_CHANNELS:
raise AFMAGValidationError(
"AirMt airborne outputs must be Hx/Hy/Hz"
)
if tf.data.shape[1:] != (3, 2):
raise AFMAGValidationError(
"AirMt transfer function must have matrix shape (3, 2)"
)
return tf
[docs]
def validate_original_afmag_tilt(
tf: TransferFunction,
) -> TransferFunction:
"""Validate and return an original AFMAG scalar tilt response.
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`.
AFMAGValidationError
If *tf* does not use the ``afmag_tilt`` datatype, is not
scalar (no input/output channels, matrix shape ``(1, 1)``),
or carries a non-zero imaginary component.
"""
if not isinstance(tf, TransferFunction):
raise TypeError("tf must be a TransferFunction")
if tf.name != AFMAG_TILT_TAG:
raise AFMAGValidationError(
"original AFMAG response must use the afmag_tilt datatype"
)
if tf.input_channels or tf.output_channels:
raise AFMAGValidationError("original AFMAG tilt must be scalar")
if tf.data.shape[1:] != (1, 1):
raise AFMAGValidationError("original AFMAG tilt must be scalar")
if np.iscomplexobj(tf.data) and np.any(np.imag(tf.data) != 0.0):
raise AFMAGValidationError("original AFMAG tilt must be real")
return tf
def _airmt_notes(
spec: AirMtSystemSpec,
reference_station: AFMAGReferenceStation | None,
) -> dict[str, Any]:
"""Return a ``{"AFMAG": {...}}`` block for ``EMTF.metadata["notes"]``.
Presentation metadata only, mirroring
:func:`~pycsamt.airborne.ztem.adapter._xml_notes_mapping`: it does
not feed :attr:`~pycsamt.emtf.EMTF.processing` (see
:func:`_processing_for_reference` for that) and is not read back
by any adapter.
"""
low, high = spec.practical_frequency_range_hz
values: dict[str, Any] = {
"Family": "tensor_afmag_airmt",
"PracticalFrequencyMinHz": low,
"PracticalFrequencyMaxHz": high,
"TypicalFrequencyCountMin": spec.typical_frequency_count[0],
"TypicalFrequencyCountMax": spec.typical_frequency_count[1],
"TimeSeriesSamplingRateHz": spec.time_series_sampling_rate_hz,
"TransferInputChannels": ",".join(spec.input_channels),
"AirborneOutputChannels": ",".join(spec.output_channels),
"ReferenceMeasuredChannels": ",".join(spec.reference_channels),
"TransferFunction": (
"[Hx,Hy,Hz](r) = TI * [Hx,Hy](r0)"
),
}
if reference_station is not None:
if reference_station.preferred_id is not None:
values["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:
values["ReferenceLatitude"] = location.latitude
if location.longitude is not None:
values["ReferenceLongitude"] = location.longitude
if location.elevation is not None:
values["ReferenceElevation"] = location.elevation
if location.datum is not None:
values["ReferenceDatum"] = location.datum
return {"AFMAG": values}
def _original_notes(
spec: OriginalAFMAGSystemSpec,
*,
response_kind: str,
) -> dict[str, Any]:
"""Return a ``{"AFMAG": {...}}`` block for ``EMTF.metadata["notes"]``.
Presentation metadata only; see :func:`_airmt_notes`. The original
generation has no reference-station geometry to record, unlike
AirMt, so this block is purely descriptive of the historical
instrument.
"""
low, high = spec.historical_frequency_band_hz
values: dict[str, Any] = {
"Family": "original_comparator_afmag",
"HistoricalFrequencyMinHz": low,
"HistoricalFrequencyMaxHz": high,
"TypicalFrequenciesHz": ",".join(
f"{value:g}" for value in spec.typical_frequencies_hz
),
"CoilCount": spec.coil_count,
"CoilTiltDeg": spec.coil_tilt_deg,
"CoilSeparationDeg": spec.coil_separation_deg,
"DigitalRecording": spec.digital_recording,
"ResponseKind": response_kind,
}
return {"AFMAG": values}
def _processing_for_reference(
reference_station: AFMAGReferenceStation | None,
processing: ProcessingMeta | None,
) -> ProcessingMeta | None:
"""Fold *reference_station* into *processing*'s remote reference.
Thin AirMt-specific wrapper around
:func:`~pycsamt.airborne.validation.merge_remote_reference_processing`;
see that function's docstring for the merge/conflict semantics
(shared verbatim with
:func:`~pycsamt.airborne.ztem.adapter._processing_for_reference`).
"""
extra = {}
if reference_station is not None:
extra = {
"measured_channels": list(reference_station.measured_channels),
"transfer_input_channels": list(
reference_station.transfer_input_channels
),
}
return merge_remote_reference_processing(
reference_station,
processing,
reference_type="fixed_ground_magnetic",
technology="AirMt",
extra=extra,
error_cls=AFMAGValidationError,
)
[docs]
def build_airmt_emtf(
tensor: Any,
*,
frequency: Any | None = None,
periods: Any | None = None,
units: str | None = AFMAG_TENSOR_UNITS,
variance: Any | None = None,
inverse_signal_covariance: Any | None = None,
residual_covariance: Any | None = None,
include_amplification_parameter: bool = True,
amplification_zero_policy: str = "nan",
product_id: str | None = None,
description: str | None = None,
reference_station: AFMAGReferenceStation | None = None,
system_spec: AirMtSystemSpec | 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 tensor AFMAG/AirMt :class:`EMTF` response.
Parameters
----------
tensor : array-like
Complex interstation magnetic transfer function with shape
``(nf, 3, 2)``, or one ``(3, 2)`` matrix for a single
frequency.
frequency, periods : array-like, optional
Exactly one positive frequency or period vector must be
supplied.
variance : array-like, optional
Component variance with shape ``(nf, 3, 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, 3, 3)``.
include_amplification_parameter : bool, default True
Whether to also attach the derived
``airmt_amplification_parameter`` transfer function; see
:func:`compute_airmt_amplification_parameter`.
amplification_zero_policy : {"nan", "raise"}, default "nan"
Forwarded to :func:`compute_airmt_amplification_parameter`.
reference_station : AFMAGReferenceStation, optional
Fixed ground reference metadata; folded into
``EMTF.processing`` and ``EMTF.attrs["afmag"]`` (see
:func:`_processing_for_reference`).
system_spec : AirMtSystemSpec, optional
System-description metadata; defaults to published nominal
values.
Returns
-------
EMTF
Document carrying the ``interstation_transfer_functions``
(``TI``) transfer function and, unless disabled, the derived
amplification parameter.
Raises
------
AFMAGValidationError
If *tensor*, *frequency*/*periods*, or any statistical
estimate does not match its expected shape.
TypeError
If *system_spec*, *reference_station*, *site*, or
*orientation* has the wrong type.
"""
register_afmag_datatypes()
freq, period_arr = resolve_frequency_or_periods(
frequency=frequency,
periods=periods,
error_cls=AFMAGValidationError,
)
data = normalize_estimate_array(
tensor,
n_frequency=freq.size,
tail=(3, 2),
name="tensor",
error_cls=AFMAGValidationError,
)
tf = TransferFunction(
name=AFMAG_TENSOR_TAG,
data=data,
input_channels=AFMAG_TENSOR_INPUT_CHANNELS,
output_channels=AFMAG_TENSOR_OUTPUT_CHANNELS,
units=units,
periods=period_arr,
attrs={
"technology": "AirMt",
"afmag_family": "tensor",
"reference_geometry": (
"airborne_Hx_Hy_Hz_to_fixed_ground_Hx_Hy"
),
"components": list(AFMAG_TENSOR_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=(3, 2),
name="variance",
error_cls=AFMAGValidationError,
),
)
)
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=AFMAGValidationError,
),
)
)
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=(3, 3),
name="residual_covariance",
error_cls=AFMAGValidationError,
),
)
)
spec = system_spec or AirMtSystemSpec()
if not isinstance(spec, AirMtSystemSpec):
raise TypeError("system_spec must be an AirMtSystemSpec or None")
if reference_station is not None and not isinstance(
reference_station,
AFMAGReferenceStation,
):
raise TypeError(
"reference_station must be AFMAGReferenceStation 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")
document_attrs = dict(attrs or {})
document_attrs["afmag"] = {
"family": "tensor_airmt",
"system": spec.to_dict(max_depth=2),
"reference_station": reference_station_mapping(
reference_station,
channel_fields=(
"measured_channels",
"transfer_input_channels",
),
),
}
doc = EMTF(
product_id=product_id,
description=(
description
or "Tensor AFMAG/AirMt interstation magnetic response"
),
subtype="afmag_airmt",
tags=("afmag", "airmt", AFMAG_TENSOR_TAG),
periods=period_arr,
site=site,
orientation=orientation,
processing=_processing_for_reference(reference_station, processing),
metadata={"notes": _airmt_notes(spec, reference_station)},
attrs=document_attrs,
)
doc.add_transfer_function(tf)
validate_airmt_transfer_function(tf)
if include_amplification_parameter:
ap = compute_airmt_amplification_parameter(
data,
zero_policy=amplification_zero_policy,
)
ap_tf = TransferFunction(
name=AFMAG_AP_TAG,
data=ap,
input_channels=(),
output_channels=(),
units=AFMAG_AP_UNITS,
periods=period_arr,
attrs={
"technology": "AirMt",
"derived_from": AFMAG_TENSOR_TAG,
"rotation_invariant": True,
"formula": (
"K=T1xT2; AP=K.Re(K)/|Re(K)|"
),
},
)
doc.add_transfer_function(ap_tf)
return doc
[docs]
def build_original_afmag_emtf(
tilt: Any,
*,
frequency: Any | None = None,
periods: Any | None = None,
response_kind: str = "tilt_angle",
units: str | None = None,
variance: Any | None = None,
product_id: str | None = None,
description: str | None = None,
system_spec: OriginalAFMAGSystemSpec | None = None,
site: SiteMeta | None = None,
orientation: OrientationMeta | None = None,
attrs: Mapping[str, Any] | None = None,
) -> EMTF:
"""Build one historical comparator-AFMAG scalar tilt response.
``response_kind='tilt_angle'`` represents a calibrated tilt angle and
defaults to degrees. ``'comparator_deflection'`` preserves an archival
instrument deflection without pretending it is an angle; units should then
be supplied when known.
Parameters
----------
tilt : array-like
Real-valued tilt/deflection response, scalar or shape ``(nf,)``.
frequency, periods : array-like, optional
Exactly one positive frequency or period vector must be
supplied.
response_kind : str, default "tilt_angle"
Either ``"tilt_angle"`` (a calibrated angle, defaulting to
degrees) or ``"comparator_deflection"`` (a raw archival
instrument deflection with no implied angular unit).
variance : array-like, optional
Real-valued response variance, scalar or shape ``(nf,)`` /
``(nf, 1, 1)``.
system_spec : OriginalAFMAGSystemSpec, optional
System-description metadata; defaults to published nominal
values.
Returns
-------
EMTF
Document carrying the ``afmag_tilt`` transfer function.
Raises
------
AFMAGValidationError
If *tilt*, *frequency*/*periods*, or *variance* does not
match its expected shape.
ValueError
If *response_kind* is not a recognized value.
TypeError
If *system_spec*, *site*, or *orientation* has the wrong type.
"""
register_afmag_datatypes()
freq, period_arr = resolve_frequency_or_periods(
frequency=frequency,
periods=periods,
error_cls=AFMAGValidationError,
)
data = _normalize_tilt(tilt)
if data.size != freq.size:
raise AFMAGValidationError(
"frequency count does not match original AFMAG tilt: "
f"{freq.size} != {data.size}"
)
kind = str(response_kind).strip().lower().replace("-", "_")
aliases = {
"tilt": "tilt_angle",
"angle": "tilt_angle",
"deflection": "comparator_deflection",
}
kind = aliases.get(kind, kind)
if kind not in {"tilt_angle", "comparator_deflection"}:
raise ValueError(
"response_kind must be 'tilt_angle' or "
"'comparator_deflection'"
)
if units is None and kind == "tilt_angle":
units = AFMAG_TILT_DEFAULT_UNITS
tf = TransferFunction(
name=AFMAG_TILT_TAG,
data=data,
input_channels=(),
output_channels=(),
units=units,
periods=period_arr,
attrs={
"technology": "AFMAG",
"afmag_family": "original_comparator",
"response_kind": kind,
"line_direction_response": True,
"calibrated_angle": kind == "tilt_angle",
},
)
if variance is not None:
tf.add_estimate(
StatisticalEstimate(
name="VAR",
kind="variance",
data=_normalize_scalar_variance(
variance,
n_frequency=freq.size,
),
units=(None if units is None else f"{units}^2"),
)
)
spec = system_spec or OriginalAFMAGSystemSpec()
if not isinstance(spec, OriginalAFMAGSystemSpec):
raise TypeError(
"system_spec must be OriginalAFMAGSystemSpec 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")
document_attrs = dict(attrs or {})
document_attrs["afmag"] = {
"family": "original_comparator",
"system": spec.to_dict(max_depth=2),
}
doc = EMTF(
product_id=product_id,
description=(
description or "Original comparator AFMAG tilt response"
),
subtype="afmag_original",
tags=("afmag", AFMAG_TILT_TAG),
periods=period_arr,
site=site,
orientation=orientation,
metadata={
"notes": _original_notes(spec, response_kind=kind),
},
attrs=document_attrs,
)
doc.add_transfer_function(tf)
validate_original_afmag_tilt(tf)
return doc
[docs]
def build_airmt_record(
sample_id: str,
tensor: 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 tensor AFMAG/AirMt data.
Parameters
----------
sample_id : str
Navigation sample identifier for the new record.
tensor : array-like
Forwarded to :func:`build_airmt_emtf`.
frequency, periods : array-like, optional
Exactly one must be supplied; forwarded to
:func:`build_airmt_emtf`.
fields, quality, record_attrs : dict, optional
Forwarded to :class:`~pycsamt.airborne.base.AirborneEMRecord`.
**emtf_kwargs
Forwarded to :func:`build_airmt_emtf`.
Returns
-------
AirborneEMRecord
The record, with its EMTF ``product_id`` defaulted to
``str(sample_id)`` unless overridden in ``emtf_kwargs``.
"""
doc = build_airmt_emtf(
tensor,
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 {}),
)
[docs]
def build_original_afmag_record(
sample_id: str,
tilt: 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 record from a historical AFMAG tilt response.
Parameters
----------
sample_id : str
Navigation sample identifier for the new record.
tilt : array-like
Forwarded to :func:`build_original_afmag_emtf`.
frequency, periods : array-like, optional
Exactly one must be supplied; forwarded to
:func:`build_original_afmag_emtf`.
fields, quality, record_attrs : dict, optional
Forwarded to :class:`~pycsamt.airborne.base.AirborneEMRecord`.
**emtf_kwargs
Forwarded to :func:`build_original_afmag_emtf`.
Returns
-------
AirborneEMRecord
The record, with its EMTF ``product_id`` defaulted to
``str(sample_id)`` unless overridden in ``emtf_kwargs``.
"""
doc = build_original_afmag_emtf(
tilt,
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 _line_frequency_rows(
frequency: Any,
*,
n_samples: int,
n_frequency: int,
) -> tuple[np.ndarray | None, np.ndarray | None]:
"""Return ``(common_frequency, frequency_rows)`` for one flight line.
Deliberately not
:func:`~pycsamt.airborne.validation.resolve_line_frequency_grid`:
that shared helper only shape-checks a per-sample ``(n_samples,
nf)`` grid, deferring each row's own finiteness/positivity to a
later per-sample call so a row belonging to a ``record_mask``-
excluded sample never needs to be valid. AFMAG validates the
entire per-sample grid eagerly instead, catching a bad frequency
row at line-construction time even for samples that end up masked
out. That is a real, intentional behavioral difference from
MobileMT/ZTEM, not an oversight -- do not "fix" it into matching
the shared helper without confirming the stricter behavior is no
longer wanted.
"""
freq = np.asarray(frequency, dtype=float)
if freq.ndim == 1:
common = normalize_frequency(freq, error_cls=AFMAGValidationError)
if common.size != n_frequency:
raise AFMAGValidationError(
"shared frequency length does not match response"
)
return common, None
if freq.ndim == 2 and freq.shape == (n_samples, n_frequency):
if not np.all(np.isfinite(freq)) or np.any(freq <= 0.0):
raise AFMAGValidationError(
"per-sample frequency grids must be finite and positive"
)
return None, freq
raise AFMAGValidationError(
"frequency must have shape (nf,) or (n_samples, nf)"
)
def _optional_sample_array(
value: Any | None,
*,
name: str,
expected: tuple[int, ...],
n_samples: int,
) -> np.ndarray | None:
"""Return one line's optional per-sample estimate array, or ``None``.
Thin ``None``-tolerant wrapper around
:func:`~pycsamt.airborne.validation.normalize_sample_axis_array`.
"""
if value is None:
return None
return normalize_sample_axis_array(
value,
name=name,
n_samples=n_samples,
expected=expected,
error_cls=AFMAGValidationError,
)
[docs]
def build_airmt_line(
line_id: str,
navigation: NavigationTrack,
tensor: 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 = AFMAG_TENSOR_UNITS,
include_amplification_parameter: bool = True,
reference_station: AFMAGReferenceStation | None = None,
system_spec: AirMtSystemSpec | None = None,
orientation: OrientationMeta | None = None,
attrs: Mapping[str, Any] | None = None,
) -> AirborneEMLine:
"""Build one tensor-AFMAG/AirMt flight line from decoded arrays.
Parameters
----------
line_id : str
Flight-line identifier.
navigation : NavigationTrack
Sample-aligned navigation defining the line's sample axis.
tensor : array-like
Line-batched interstation tensor. For ``n_samples == 1``, a
single ``(3, 2)`` matrix or one ``(nf, 3, 2)`` stack is also
accepted (``n_frequency`` is not known in advance, unlike
:func:`~pycsamt.airborne.validation.normalize_sample_axis_array`'s
contract); for ``n_samples > 1``, the canonical
``(samples, nf, 3, 2)`` shape is required directly.
frequency : array-like
Either one shared ``(nf,)`` vector or a per-sample
``(n_samples, nf)`` grid; see :func:`_line_frequency_rows`.
record_mask : array-like of bool, optional
Marks which navigation samples get an attached record;
``None`` means every sample does.
variance, inverse_signal_covariance, residual_covariance :
array-like, optional
Per-sample statistical estimates, each shaped
``(n_samples, nf, *tail)``; forwarded per sample to
:func:`build_airmt_record`.
include_amplification_parameter : bool, default True
Forwarded to :func:`build_airmt_emtf` for every sample.
units, reference_station, system_spec, orientation : optional
Forwarded to :func:`build_airmt_emtf` for every sample.
attrs : dict, optional
Line-level extension metadata; ``"technology"`` is set to
``"AirMt"`` here.
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`.
AFMAGValidationError
If *tensor*, *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 = np.asarray(tensor)
if n_samples == 1 and data.shape[-2:] == (3, 2):
if data.ndim == 2:
data = data[None, None, ...]
elif data.ndim == 3:
data = data[None, ...]
if data.ndim != 4 or data.shape[0] != n_samples:
raise AFMAGValidationError(
"AirMt line tensor must have shape (samples, nf, 3, 2)"
)
if data.shape[2:] != (3, 2):
raise AFMAGValidationError(
"AirMt line tensor must have matrix shape (3, 2)"
)
if data.dtype.kind not in "biufc":
raise AFMAGValidationError("AirMt line tensor must be numeric")
n_frequency = data.shape[1]
common_freq, freq_rows = _line_frequency_rows(
frequency,
n_samples=n_samples,
n_frequency=n_frequency,
)
mask = normalize_record_mask(
record_mask,
n_samples=n_samples,
error_cls=AFMAGValidationError,
)
var = _optional_sample_array(
variance,
name="variance",
expected=(n_samples, n_frequency, 3, 2),
n_samples=n_samples,
)
inv = _optional_sample_array(
inverse_signal_covariance,
name="inverse_signal_covariance",
expected=(n_samples, n_frequency, 2, 2),
n_samples=n_samples,
)
res = _optional_sample_array(
residual_covariance,
name="residual_covariance",
expected=(n_samples, n_frequency, 3, 3),
n_samples=n_samples,
)
line = AirborneEMLine(
line_id=str(line_id),
navigation=navigation,
attrs={"technology": "AirMt", **dict(attrs or {})},
)
for index, sample_id in enumerate(navigation.sample_ids):
if not mask[index]:
continue
freq_row = common_freq if common_freq is not None else freq_rows[index]
record = build_airmt_record(
sample_id,
data[index],
frequency=freq_row,
variance=None if var is None else var[index],
inverse_signal_covariance=None if inv is None else inv[index],
residual_covariance=None if res is None else res[index],
units=units,
include_amplification_parameter=include_amplification_parameter,
reference_station=reference_station,
system_spec=system_spec,
orientation=orientation,
product_id=f"{line_id}.{sample_id}",
record_attrs={"navigation_index": index},
)
line.add_record(record)
return line
[docs]
def build_original_afmag_line(
line_id: str,
navigation: NavigationTrack,
tilt: Any,
*,
frequency: Any,
record_mask: Any | None = None,
response_kind: str = "tilt_angle",
units: str | None = None,
variance: Any | None = None,
system_spec: OriginalAFMAGSystemSpec | None = None,
orientation: OrientationMeta | None = None,
attrs: Mapping[str, Any] | None = None,
) -> AirborneEMLine:
"""Build one historical comparator-AFMAG flight line.
Parameters
----------
line_id : str
Flight-line identifier.
navigation : NavigationTrack
Sample-aligned navigation defining the line's sample axis.
tilt : array-like
Line-batched real tilt/deflection response, shape
``(n_samples, nf)`` (or unbatched when ``n_samples == 1``).
frequency : array-like
Either one shared ``(nf,)`` vector or a per-sample
``(n_samples, nf)`` grid; see :func:`_line_frequency_rows`.
record_mask : array-like of bool, optional
Marks which navigation samples get an attached record;
``None`` means every sample does.
response_kind, units : optional
Forwarded to :func:`build_original_afmag_emtf` for every
sample.
variance : array-like, optional
Per-sample response variance, shape ``(n_samples, nf)``.
system_spec, orientation : optional
Forwarded to :func:`build_original_afmag_emtf` for every
sample.
attrs : dict, optional
Line-level extension metadata; ``"technology"`` is set to
``"AFMAG"`` here.
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`.
AFMAGValidationError
If *tilt*, *frequency*, *record_mask*, or *variance* does not
match its expected shape.
"""
if not isinstance(navigation, NavigationTrack):
raise TypeError("navigation must be a NavigationTrack")
n_samples = navigation.n_samples
data = np.asarray(tilt)
if n_samples == 1:
if data.ndim == 0:
data = data.reshape(1, 1)
elif data.ndim == 1:
data = data[None, ...]
if data.ndim != 2 or data.shape[0] != n_samples:
raise AFMAGValidationError(
"original AFMAG line tilt must have shape (samples, nf)"
)
if data.dtype.kind not in "biufc":
raise AFMAGValidationError("original AFMAG line tilt must be numeric")
n_frequency = data.shape[1]
common_freq, freq_rows = _line_frequency_rows(
frequency,
n_samples=n_samples,
n_frequency=n_frequency,
)
mask = normalize_record_mask(
record_mask,
n_samples=n_samples,
error_cls=AFMAGValidationError,
)
var = None
if variance is not None:
var = np.asarray(variance)
if n_samples == 1 and var.ndim == 1:
var = var[None, ...]
if var.shape != (n_samples, n_frequency):
raise AFMAGValidationError(
"tilt variance must have shape (samples, nf)"
)
line = AirborneEMLine(
line_id=str(line_id),
navigation=navigation,
attrs={"technology": "AFMAG", **dict(attrs or {})},
)
for index, sample_id in enumerate(navigation.sample_ids):
if not mask[index]:
continue
freq_row = common_freq if common_freq is not None else freq_rows[index]
record = build_original_afmag_record(
sample_id,
data[index],
frequency=freq_row,
response_kind=response_kind,
units=units,
variance=None if var is None else var[index],
system_spec=system_spec,
orientation=orientation,
product_id=f"{line_id}.{sample_id}",
record_attrs={"navigation_index": index},
)
line.add_record(record)
return line
[docs]
def build_airmt_dataset(
name: str,
lines: Any,
*,
survey: SurveyMeta | None = None,
system_spec: AirMtSystemSpec | None = None,
instrument_serial: str | None = None,
attrs: Mapping[str, Any] | None = None,
) -> AirborneEMDataset:
"""Build a common airborne dataset for tensor AFMAG/AirMt lines.
Parameters
----------
name : str
Dataset/survey name.
lines : iterable of AirborneEMLine
Lines to attach, typically previously built by
:func:`build_airmt_line`.
survey : SurveyMeta, optional
Survey-level metadata; defaults to
``SurveyMeta(name=name, method="AEM")``.
system_spec : AirMtSystemSpec, optional
Used to build the dataset's ``instrument`` metadata; defaults
to published nominal values.
instrument_serial : str, optional
Forwarded to :meth:`AirMtSystemSpec.to_instrument_meta`.
attrs : dict, optional
Dataset-level extension metadata; ``"technology"`` is set to
``"AirMt"`` here.
Returns
-------
AirborneEMDataset
The dataset, with every line attached.
Raises
------
TypeError
If *system_spec* has the wrong type, or an entry of *lines*
is not an :class:`~pycsamt.airborne.base.AirborneEMLine` (this
is enforced by :class:`AirborneEMDataset` construction, unlike
:func:`~pycsamt.airborne.ztem.build_ztem_dataset`, which
checks explicitly beforehand and additionally rejects a line
tagged with a conflicting technology; this function does not
perform that second, stricter check).
"""
spec = system_spec or AirMtSystemSpec()
if not isinstance(spec, AirMtSystemSpec):
raise TypeError("system_spec must be an AirMtSystemSpec or None")
line_map = {
line.line_id: line
for line in lines
}
meta = survey or SurveyMeta(name=str(name), method="AEM")
return AirborneEMDataset(
name=str(name),
lines=line_map,
survey=meta,
instrument=spec.to_instrument_meta(serial=instrument_serial),
attrs={"technology": "AirMt", **dict(attrs or {})},
)
[docs]
def build_original_afmag_dataset(
name: str,
lines: Any,
*,
survey: SurveyMeta | None = None,
system_spec: OriginalAFMAGSystemSpec | None = None,
instrument_serial: str | None = None,
attrs: Mapping[str, Any] | None = None,
) -> AirborneEMDataset:
"""Build a common airborne dataset for original AFMAG lines.
Parameters
----------
name : str
Dataset/survey name.
lines : iterable of AirborneEMLine
Lines to attach, typically previously built by
:func:`build_original_afmag_line`.
survey : SurveyMeta, optional
Survey-level metadata; defaults to
``SurveyMeta(name=name, method="AEM")``.
system_spec : OriginalAFMAGSystemSpec, optional
Used to build the dataset's ``instrument`` metadata; defaults
to published nominal values.
instrument_serial : str, optional
Forwarded to
:meth:`OriginalAFMAGSystemSpec.to_instrument_meta`.
attrs : dict, optional
Dataset-level extension metadata; ``"technology"`` is set to
``"AFMAG"`` here.
Returns
-------
AirborneEMDataset
The dataset, with every line attached.
Raises
------
TypeError
If *system_spec* has the wrong type, or an entry of *lines* is
not an :class:`~pycsamt.airborne.base.AirborneEMLine`; see
:func:`build_airmt_dataset` for how this differs from the
ZTEM/MobileMT dataset builders.
"""
spec = system_spec or OriginalAFMAGSystemSpec()
if not isinstance(spec, OriginalAFMAGSystemSpec):
raise TypeError(
"system_spec must be OriginalAFMAGSystemSpec or None"
)
line_map = {
line.line_id: line
for line in lines
}
meta = survey or SurveyMeta(name=str(name), method="AEM")
return AirborneEMDataset(
name=str(name),
lines=line_map,
survey=meta,
instrument=spec.to_instrument_meta(serial=instrument_serial),
attrs={"technology": "AFMAG", **dict(attrs or {})},
)