# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""MobileMT-specific processing, diagnostics, and plotting.
MobileMT (Prikhodko et al. 2022) is a natural-source airborne EM
technology built on a fundamentally different scientific object than
ZTEM/AFMAG: three orthogonal airborne magnetic coils
(:math:`H_x, H_y, H_z`) referenced to a fixed-ground horizontal
electric dipole pair (:math:`E_x, E_y`), giving a complex
**admittance** tensor
.. math::
\begin{pmatrix} H_x \\ H_y \\ H_z \end{pmatrix} =
\begin{pmatrix}
Y_{xx} & Y_{xy} \\
Y_{yx} & Y_{yy} \\
Y_{hzx} & Y_{hzy}
\end{pmatrix}
\begin{pmatrix} E_x \\ E_y \end{pmatrix}
-- the reciprocal relation of the classical MT impedance tensor
:math:`Z` (which gives :math:`E` from :math:`H`), not a tipper. This
is why :mod:`pycsamt.airborne.mobilemt` maps it onto a dedicated
``mobilemt_admittance`` :class:`~pycsamt.emtf.TransferFunction` of
shape ``(nf, 3, 2)`` rather than onto
:attr:`~pycsamt.site.base.Site.z` or
:attr:`~pycsamt.site.base.Site.tipper`, and why converting it to an
EDI/:class:`~pycsamt.site.base.Site` is refused outright
(``EMTF.to_edi`` raises ``EMTFEDIConversionError`` for this
transfer-function type -- see
``pycsamt.airborne.mobilemt.tests.test_mobilemt_interop``). Every
``emtools`` module before this one accepts and returns
:class:`~pycsamt.site.base.Sites`; this module cannot honestly do
that, so it works instead on the container hierarchy that already
carries MobileMT's real scientific content:
:class:`~pycsamt.airborne.AirborneEMDataset` ->
:class:`~pycsamt.airborne.AirborneEMLine` ->
:class:`~pycsamt.airborne.AirborneEMRecord`. The public functions
below accept a dataset (or one line, normalized through
:func:`ensure_mobilemt_dataset`, this module's ``ensure_sites``
counterpart) and return either a tidy table or a
:class:`~pycsamt.airborne.AirborneEMDataset` -- the closest honest
analogue of the rest of ``emtools``'s "sites in, sites/table out"
contract.
:func:`ensure_mobilemt_dataset` also accepts anything
:func:`~pycsamt.airborne.site.ensure_asites` does -- an
:class:`~pycsamt.airborne.site.AirborneSites`/
:class:`~pycsamt.airborne.site.AirborneSite`, or a bare path/directory
of EMTF-XML files -- regrouping it into an
:class:`~pycsamt.airborne.AirborneEMDataset` by each site's
:attr:`~pycsamt.airborne.site.AirborneSite.line_id` (a fresh,
single-line grouping when that is unset) so a
:class:`~pycsamt.airborne.site.AirborneSites` produced elsewhere in
``emtools`` -- or a directory of raw MobileMT EMTF-XML -- can flow
straight into this module without an intermediate
:class:`~pycsamt.airborne.AirborneEMDataset` construction step. This
is deliberately one-directional, unlike ZTEM/AFMAG's
``ensure_any_sites``: the module still only ever *returns* a dataset
(never :class:`~pycsamt.airborne.site.AirborneSites`), since its
functions are organized around flight lines
(:func:`plot_mobilemt_admittance_profile` and friends plot one line's
along-line chainage), not a flat station list.
Two kinds of quantity are computed here, and the distinction is kept
visible in every column name:
* **Scale-invariant tensor diagnostics** -- :func:`admittance_skew_table`
(a Swift 1967-style skew ratio, :math:`|Y_{xx}+Y_{yy}|
/|Y_{xy}-Y_{yx}|`, applied to the horizontal 2x2 admittance
submatrix by direct algebraic analogy to the same ratio already
used for the impedance tensor). Being a ratio of magnitudes, it
needs no absolute physical constant and is safe to compute directly
from any admittance tensor.
* **Theoretical apparent conductivity/phase** --
:func:`admittance_determinant_table`'s ``theoretical_*`` columns.
In the co-located-sensor limit, the MobileMT admittance tensor
equals the classical MT admittance :math:`Z^{-1}` (stated
explicitly by Zhdanov et al. 2024 and Sattel et al. 2019). Applying
that identity to pyCSAMT's own, already-shipped and tested
Berdichevsky-determinant convention for :math:`Z`
(:class:`pycsamt.z.resphase.ResPhase`'s ``res_det``/``phase_det``,
:math:`\rho_a = 0.2\,|\det Z|/f`, :math:`\varphi = \arg\sqrt{\det Z}`)
gives, by direct algebraic substitution (:math:`\det Y = 1/\det Z`):
.. math::
\sigma_a = 5\,f\,|\det Y|, \qquad
\varphi_a = -\arg\sqrt{\det Y}
This is a derived theoretical quantity, not a reproduction of
MobileMT's proprietary processed apparent-conductivity product --
:func:`~pycsamt.airborne.mobilemt.build_mobilemt_record`'s own
docstring explicitly declines to derive that vendor quantity
("a verified delivery schema is required before pyCSAMT should
codify its exact exported representation"), and this module
respects that same restraint by never presenting the derived
``theoretical_*`` columns as the vendor product. When a record
already carries the vendor-delivered
``fields["apparent_conductivity"]``, every table here reports it
alongside as ``apparent_conductivity_native_Sm`` for direct
comparison, and :func:`plot_mobilemt_conductivity_psection` can
plot either one explicitly via its ``source`` argument.
References
----------
.. [Prikhodko2022] Prikhodko, A., Bagrianski, A., Kuzmin, P., and
Sirohey, A. (2022). Natural field airborne electromagnetics --
history of development and current exploration capabilities.
Minerals, 12(5), 583.
.. [Sattel2019] Sattel, D., Witherly, K., and Kaminski, V. (2019). A
brief analysis of MobileMT data. SEG International Exposition and
Annual Meeting, D043S102R007.
.. [Zhdanov2024] Zhdanov, M. S., Gribenko, A., Prikhodko, A., Sabra,
H. E., Jorgensen, M., and Cox, L. H. (2024). Three-dimensional
MobileMT and TMI data inversions for mineral exploration. 1st ASEG
DISCOVER Symposium.
.. [Swift1967] Swift, C. M. (1967). A magnetotelluric investigation
of an electrical conductivity anomaly in the southwestern United
States. PhD thesis, MIT.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from ..airborne import (
AirborneEMDataset,
AirborneEMLine,
AirborneEMRecord,
NavigationTrack,
)
from ..airborne.mobilemt import (
MOBILEMT_ADMITTANCE_TAG,
MOBILEMT_APPARENT_CONDUCTIVITY_FIELD,
MobileMTSystemSpec,
)
from ..api.labels import LOG10_PERIOD_LABEL
from ..api.plot import add_colorbar
from ..api.style import PYCSAMT_STYLE
from ._core import _WGS84_ELLIPSOID_ID
__all__ = [
"ensure_mobilemt_dataset",
# tables
"admittance_table",
"admittance_determinant_table",
"admittance_skew_table",
# pipeline QC (dataset in, dataset out)
"mask_outside_mobilemt_band",
# plots
"plot_mobilemt_admittance_profile",
"plot_mobilemt_conductivity_psection",
"plot_mobilemt_skew_profile",
]
_COMPONENT_INDEX = {
"xx": (0, 0),
"xy": (0, 1),
"yx": (1, 0),
"yy": (1, 1),
"hzx": (2, 0),
"hzy": (2, 1),
}
_ADMITTANCE_COLS = [
"line_id",
"sample_id",
"x_m",
"freq_hz",
"period_s",
"Yxx_real",
"Yxx_imag",
"Yxy_real",
"Yxy_imag",
"Yyx_real",
"Yyx_imag",
"Yyy_real",
"Yyy_imag",
"Yhzx_real",
"Yhzx_imag",
"Yhzy_real",
"Yhzy_imag",
"apparent_conductivity_native_Sm",
]
_DETERMINANT_COLS = [
"line_id",
"sample_id",
"x_m",
"freq_hz",
"period_s",
"det_abs",
"theoretical_sigma_a_Sm",
"theoretical_rho_a_ohm_m",
"theoretical_phase_deg",
"apparent_conductivity_native_Sm",
]
_SKEW_COLS = [
"line_id",
"sample_id",
"x_m",
"freq_hz",
"period_s",
"skew",
]
# ─────────────────────────────────────────────────────────────────────────
# Private helpers
# ─────────────────────────────────────────────────────────────────────────
def _line_chainage(
line: AirborneEMLine, spacing_m: float = 50.0
) -> np.ndarray:
"""Return per-sample chainage [m] along one flight line.
Mirrors :func:`~pycsamt.emtools._core._station_positions`, adapted
to a :class:`~pycsamt.airborne.NavigationTrack`: prefers real
easting/northing, falls back to a local-metre projection of
latitude/longitude, and falls back further to a uniform
``spacing_m`` step when neither is usable.
"""
nav = line.navigation
n = nav.n_samples
east = north = None
if nav.has_projected_coordinates:
east = np.asarray(nav.easting, dtype=float)
north = np.asarray(nav.northing, dtype=float)
elif nav.has_geographic_coordinates:
from ..gis.utils import ll_to_utm
lat = np.asarray(nav.latitude, dtype=float)
lon = np.asarray(nav.longitude, dtype=float)
east = np.full(n, np.nan)
north = np.full(n, np.nan)
zones: set[str] = set()
for i in range(n):
if not (np.isfinite(lat[i]) and np.isfinite(lon[i])):
continue
try:
zone, e, no = ll_to_utm(_WGS84_ELLIPSOID_ID, lat[i], lon[i])
except Exception:
continue
east[i], north[i] = e, no
zones.add(zone)
if len(zones) > 1:
east = north = None
if east is None or north is None:
return np.arange(n, dtype=float) * spacing_m
valid = np.isfinite(east) & np.isfinite(north)
idx = np.where(valid)[0]
if idx.size < 2:
return np.arange(n, dtype=float) * spacing_m
origin = np.array([east[idx[0]], north[idx[0]]])
direction = np.array([east[idx[-1]], north[idx[-1]]]) - origin
norm = float(np.linalg.norm(direction))
if norm < 1.0:
return np.arange(n, dtype=float) * spacing_m
direction /= norm
pos = np.arange(n, dtype=float) * spacing_m
for i in idx:
pos[i] = float(
np.dot([east[i] - origin[0], north[i] - origin[1]], direction)
)
return pos
def _dataset_records(
ds: AirborneEMDataset,
) -> list[tuple[str, str, float, AirborneEMRecord]]:
"""Return ``(line_id, sample_id, x_m, record)`` for every record."""
out: list[tuple[str, str, float, AirborneEMRecord]] = []
for line in ds.iter_lines():
pos = _line_chainage(line)
for idx, sample_id in enumerate(line.navigation.sample_ids):
record = line.records.get(sample_id)
if record is None:
continue
out.append((line.line_id, sample_id, float(pos[idx]), record))
return out
def _get_admittance(
record: AirborneEMRecord,
) -> tuple[Any, np.ndarray | None]:
"""Return ``(TransferFunction, freq)`` for one MobileMT record."""
if record.emtf is None:
return None, None
tf = record.emtf.get_transfer_function(MOBILEMT_ADMITTANCE_TAG)
if tf is None:
return None, None
freq = record.emtf.frequency
if freq is None:
return None, None
return tf, np.asarray(freq, dtype=float)
def _native_sigma(record: AirborneEMRecord) -> np.ndarray | None:
value = record.fields.get(MOBILEMT_APPARENT_CONDUCTIVITY_FIELD)
return None if value is None else np.asarray(value, dtype=float)
def _component_series(Y: np.ndarray, component: str) -> np.ndarray:
"""Return one complex admittance component series, or its determinant."""
component = str(component).strip().lower()
if component == "det":
y2 = Y[:, :2, :]
return y2[:, 0, 0] * y2[:, 1, 1] - y2[:, 0, 1] * y2[:, 1, 0]
idx = _COMPONENT_INDEX.get(component)
if idx is None:
raise ValueError(
"component must be one of "
f"{sorted(_COMPONENT_INDEX) + ['det']}; got {component!r}"
)
return Y[:, idx[0], idx[1]]
def _resolve_target_frequency(
freqs: np.ndarray,
frequency_hz: float | None,
period_s: float | None,
) -> tuple[int, float]:
if frequency_hz is not None and period_s is not None:
raise ValueError(
"give at most one of frequency_hz or period_s, not both"
)
if frequency_hz is not None:
target = float(frequency_hz)
elif period_s is not None:
target = 1.0 / max(float(period_s), 1e-24)
else:
target = float(np.median(freqs))
k = int(np.argmin(np.abs(freqs - target)))
return k, float(freqs[k])
def _recover_native_apparent_conductivity(doc: Any) -> np.ndarray | None:
r"""Recover a native ``apparent_conductivity`` vector lost on read.
:func:`~pycsamt.airborne.site.AirborneSite.from_xml` builds a bare
``AirborneEMRecord(sample_id=..., emtf=doc)``: it never carries
``AirborneEMRecord.fields`` (an in-memory-only attribute with no
EMTF-XML representation of its own). The synthetic-data generator
already works around this on write by stashing the vector as
comma-separated text in ``doc.metadata["notes"]["MobileMT"]
["ApparentConductivitySm"]`` -- the one part of an EMTF-XML
document that does round-trip losslessly -- but nothing previously
read it back out again, so every MobileMT survey loaded through
:func:`ensure_mobilemt_dataset` (path/:class:`AirborneSites`
input) silently reported ``apparent_conductivity_native_Sm`` as
all-``nan``, even for files that do carry the vendor value. This
reads that same note back into a real array whenever present.
"""
if doc is None:
return None
raw = doc.metadata.get("notes", {}).get("MobileMT", {}).get(
"ApparentConductivitySm"
)
if not raw:
return None
try:
return np.array(
[float(v) for v in str(raw).split(",") if v.strip()],
dtype=float,
)
except ValueError:
return None
def _dataset_from_asites(asites: Any) -> AirborneEMDataset:
"""Regroup an :class:`~pycsamt.airborne.site.AirborneSites` into a
dataset, one flight line per distinct ``line_id`` (unset ids all
fall into one ``"L001"`` line).
Each new :class:`~pycsamt.airborne.NavigationTrack` carries
latitude/longitude/elevation straight from
:attr:`~pycsamt.airborne.site.AirborneSite.coords` whenever at
least one site in the group has a finite value, so
:func:`_line_chainage` can still resolve real along-line
positions afterward. Every underlying
:class:`~pycsamt.airborne.AirborneEMRecord` is reused as-is (no
copy) *unless* its native apparent conductivity can be recovered
via :func:`_recover_native_apparent_conductivity` and is not
already present, in which case a new record carrying the
recovered field replaces it -- the original object the caller
holds is never mutated in place.
"""
groups: dict[str, list] = {}
for s in asites:
groups.setdefault(s.line_id or "L001", []).append(s)
lines: dict[str, AirborneEMLine] = {}
for line_id, items in groups.items():
sample_ids = tuple(s.sample_id for s in items)
lat = np.array([s.coords[0] for s in items], dtype=float)
lon = np.array([s.coords[1] for s in items], dtype=float)
elev = np.array([s.coords[2] for s in items], dtype=float)
nav_kwargs: dict[str, Any] = {"sample_ids": sample_ids}
if np.any(np.isfinite(lat)) and np.any(np.isfinite(lon)):
nav_kwargs["latitude"] = lat
nav_kwargs["longitude"] = lon
if np.any(np.isfinite(elev)):
nav_kwargs["terrain_elevation"] = elev
line = AirborneEMLine(
line_id=line_id, navigation=NavigationTrack(**nav_kwargs),
)
for s in items:
record = s.record
if MOBILEMT_APPARENT_CONDUCTIVITY_FIELD not in record.fields:
recovered = _recover_native_apparent_conductivity(s.emtf)
if recovered is not None:
record = AirborneEMRecord(
sample_id=record.sample_id,
emtf=record.emtf,
fields={
**record.fields,
MOBILEMT_APPARENT_CONDUCTIVITY_FIELD: recovered,
},
quality=record.quality,
attrs=record.attrs,
)
line.add_record(record)
lines[line.line_id] = line
return AirborneEMDataset(name="mobilemt", lines=lines)
def _pick_line(ds: AirborneEMDataset, line_id: str | None) -> AirborneEMLine:
if line_id is not None:
line = ds.get_line(line_id)
if line is None:
raise ValueError(f"line not found: {line_id!r}")
return line
for line in ds.iter_lines():
return line
raise ValueError("dataset has no flight lines")
# ─────────────────────────────────────────────────────────────────────────
# Input normalization (this module's ``ensure_sites`` counterpart)
# ─────────────────────────────────────────────────────────────────────────
[docs]
def ensure_mobilemt_dataset(obj: Any) -> AirborneEMDataset:
"""Normalize a dataset or single line to an :class:`AirborneEMDataset`.
The single entry-point validator for every public function in
this module, mirroring the role
:func:`~pycsamt.emtools._core.ensure_sites` plays for the rest of
``emtools``.
Parameters
----------
obj : AirborneEMDataset or AirborneEMLine or AirborneSites or \
AirborneSite or str or pathlib.Path
Accepted as-is when already a dataset; a single line is
wrapped in a new one-line dataset. An
:class:`~pycsamt.airborne.site.AirborneSites`/
:class:`~pycsamt.airborne.site.AirborneSite`, or a path to a
single EMTF-XML file or a directory of them, is first coerced
via :func:`~pycsamt.airborne.site.ensure_asites` and then
regrouped into flight lines by
:attr:`~pycsamt.airborne.site.AirborneSite.line_id` (see
:func:`_dataset_from_asites`).
Returns
-------
AirborneEMDataset
Raises
------
TypeError
If *obj* is none of the accepted types.
"""
if isinstance(obj, AirborneEMDataset):
return obj
if isinstance(obj, AirborneEMLine):
return AirborneEMDataset(name=obj.line_id, lines={obj.line_id: obj})
from ..airborne.site import AirborneSite, AirborneSites, ensure_asites
if isinstance(obj, AirborneSite):
return _dataset_from_asites(AirborneSites([obj]))
if isinstance(obj, (AirborneSites, str, Path)):
return _dataset_from_asites(ensure_asites(obj))
raise TypeError(
"ensure_mobilemt_dataset: expected an AirborneEMDataset, "
"AirborneEMLine, AirborneSites, AirborneSite, or a path to "
f"EMTF-XML; got {type(obj).__name__!r}"
)
# ─────────────────────────────────────────────────────────────────────────
# Tables
# ─────────────────────────────────────────────────────────────────────────
[docs]
def admittance_table(dataset: Any) -> pd.DataFrame:
r"""Return a tidy per-(line, sample, frequency) admittance table.
Parameters
----------
dataset : AirborneEMDataset or AirborneEMLine
Anything accepted by :func:`ensure_mobilemt_dataset`.
Returns
-------
pandas.DataFrame
Columns: ``line_id``, ``sample_id``, ``x_m`` (chainage along
the flight line, see :func:`~pycsamt.emtools._core
._station_positions`'s along-profile convention),
``freq_hz``, ``period_s``, the real/imaginary parts of every
entry of the horizontal 2x2 admittance
(``Yxx``, ``Yxy``, ``Yyx``, ``Yyy``) and of the vertical-field
row (``Yhzx``, ``Yhzy``), and
``apparent_conductivity_native_Sm`` -- the vendor-delivered
processed field (:data:`~pycsamt.airborne.mobilemt
.MOBILEMT_APPARENT_CONDUCTIVITY_FIELD`) when present, ``NaN``
otherwise.
"""
ds = ensure_mobilemt_dataset(dataset)
rows: list[dict[str, Any]] = []
for line_id, sample_id, x_m, record in _dataset_records(ds):
tf, freq = _get_admittance(record)
if tf is None or freq is None:
continue
Y = np.asarray(tf.data, dtype=complex)
sigma_native = _native_sigma(record)
for k in range(freq.size):
y = Y[k]
rows.append(
{
"line_id": line_id,
"sample_id": sample_id,
"x_m": x_m,
"freq_hz": float(freq[k]),
"period_s": 1.0 / max(float(freq[k]), 1e-12),
"Yxx_real": float(y[0, 0].real),
"Yxx_imag": float(y[0, 0].imag),
"Yxy_real": float(y[0, 1].real),
"Yxy_imag": float(y[0, 1].imag),
"Yyx_real": float(y[1, 0].real),
"Yyx_imag": float(y[1, 0].imag),
"Yyy_real": float(y[1, 1].real),
"Yyy_imag": float(y[1, 1].imag),
"Yhzx_real": float(y[2, 0].real),
"Yhzx_imag": float(y[2, 0].imag),
"Yhzy_real": float(y[2, 1].real),
"Yhzy_imag": float(y[2, 1].imag),
"apparent_conductivity_native_Sm": (
float(sigma_native[k])
if sigma_native is not None
and k < sigma_native.size
and np.isfinite(sigma_native[k])
else np.nan
),
}
)
if not rows:
return pd.DataFrame(columns=_ADMITTANCE_COLS)
return pd.DataFrame(rows, columns=_ADMITTANCE_COLS)
[docs]
def admittance_determinant_table(dataset: Any) -> pd.DataFrame:
r"""Return the theoretical Berdichevsky-determinant admittance table.
See the module docstring for the full derivation. In brief, using
the horizontal 2x2 admittance submatrix
:math:`Y = \begin{pmatrix}Y_{xx}&Y_{xy}\\Y_{yx}&Y_{yy}\end{pmatrix}`
and the co-located-sensor identity :math:`Y=Z^{-1}` (Zhdanov et
al. 2024; Sattel et al. 2019), applying pyCSAMT's own
:math:`Z`-determinant convention
(:class:`pycsamt.z.resphase.ResPhase`) by substitution gives:
.. math::
Y_{\mathrm{eff}} = \sqrt{\det Y}, \qquad
\sigma_a = 5\,f\,|Y_{\mathrm{eff}}|^2, \qquad
\varphi_a = -\arg(Y_{\mathrm{eff}})
Parameters
----------
dataset : AirborneEMDataset or AirborneEMLine
Anything accepted by :func:`ensure_mobilemt_dataset`.
Returns
-------
pandas.DataFrame
Columns: ``line_id``, ``sample_id``, ``x_m``, ``freq_hz``,
``period_s``, ``det_abs`` (:math:`|\det Y|`),
``theoretical_sigma_a_Sm``, ``theoretical_rho_a_ohm_m``
(:math:`1/\sigma_a`), ``theoretical_phase_deg``, and
``apparent_conductivity_native_Sm`` (the vendor-delivered
field, for direct comparison, ``NaN`` when absent). Samples
with a non-finite determinant are omitted.
Notes
-----
The ``theoretical_*`` columns are a derived quantity assuming
ideal co-located sensors; they are **not** a reproduction of
MobileMT's proprietary processed apparent-conductivity output.
Prefer ``apparent_conductivity_native_Sm`` whenever it is present.
"""
ds = ensure_mobilemt_dataset(dataset)
rows: list[dict[str, Any]] = []
for line_id, sample_id, x_m, record in _dataset_records(ds):
tf, freq = _get_admittance(record)
if tf is None or freq is None:
continue
Y = np.asarray(tf.data, dtype=complex)
y2 = Y[:, :2, :]
det = y2[:, 0, 0] * y2[:, 1, 1] - y2[:, 0, 1] * y2[:, 1, 0]
with np.errstate(invalid="ignore"):
y_eff = det**0.5
sigma_a = 5.0 * freq * np.abs(y_eff) ** 2
with np.errstate(divide="ignore"):
rho_a = 1.0 / sigma_a
phase_a = -np.degrees(np.angle(y_eff))
sigma_native = _native_sigma(record)
for k in range(freq.size):
if not np.isfinite(det[k].real) or not np.isfinite(det[k].imag):
continue
rows.append(
{
"line_id": line_id,
"sample_id": sample_id,
"x_m": x_m,
"freq_hz": float(freq[k]),
"period_s": 1.0 / max(float(freq[k]), 1e-12),
"det_abs": float(np.abs(det[k])),
"theoretical_sigma_a_Sm": float(sigma_a[k]),
"theoretical_rho_a_ohm_m": float(rho_a[k]),
"theoretical_phase_deg": float(phase_a[k]),
"apparent_conductivity_native_Sm": (
float(sigma_native[k])
if sigma_native is not None
and k < sigma_native.size
and np.isfinite(sigma_native[k])
else np.nan
),
}
)
if not rows:
return pd.DataFrame(columns=_DETERMINANT_COLS)
return pd.DataFrame(rows, columns=_DETERMINANT_COLS)
[docs]
def admittance_skew_table(dataset: Any) -> pd.DataFrame:
r"""Return a Swift (1967)-style skew table for the admittance tensor.
.. math::
\mathrm{skew} = \frac{|Y_{xx} + Y_{yy}|}{|Y_{xy} - Y_{yx}|}
applied to the horizontal 2x2 admittance submatrix by direct
algebraic analogy to the identical ratio already used for the
impedance tensor elsewhere in pyCSAMT. Being a ratio of
magnitudes, it needs no absolute physical constant and is safe to
compute directly, unlike :func:`admittance_determinant_table`'s
``theoretical_*`` columns. Large values flag departures from an
ideal 1D/2D-consistent admittance tensor (instrument coupling,
cultural noise, genuinely 3-D structure).
Parameters
----------
dataset : AirborneEMDataset or AirborneEMLine
Anything accepted by :func:`ensure_mobilemt_dataset`.
Returns
-------
pandas.DataFrame
Columns: ``line_id``, ``sample_id``, ``x_m``, ``freq_hz``,
``period_s``, ``skew``. Non-finite values are omitted.
"""
ds = ensure_mobilemt_dataset(dataset)
rows: list[dict[str, Any]] = []
for line_id, sample_id, x_m, record in _dataset_records(ds):
tf, freq = _get_admittance(record)
if tf is None or freq is None:
continue
Y = np.asarray(tf.data, dtype=complex)
yxx, yxy = Y[:, 0, 0], Y[:, 0, 1]
yyx, yyy = Y[:, 1, 0], Y[:, 1, 1]
with np.errstate(divide="ignore", invalid="ignore"):
skew = np.abs(yxx + yyy) / np.abs(yxy - yyx)
for k in range(freq.size):
if not np.isfinite(skew[k]):
continue
rows.append(
{
"line_id": line_id,
"sample_id": sample_id,
"x_m": x_m,
"freq_hz": float(freq[k]),
"period_s": 1.0 / max(float(freq[k]), 1e-12),
"skew": float(skew[k]),
}
)
if not rows:
return pd.DataFrame(columns=_SKEW_COLS)
return pd.DataFrame(rows, columns=_SKEW_COLS)
# ─────────────────────────────────────────────────────────────────────────
# Pipeline QC: mask frequencies outside the published MobileMT band
# ─────────────────────────────────────────────────────────────────────────
[docs]
def mask_outside_mobilemt_band(
dataset: Any,
*,
band_hz: tuple[float, float] | None = None,
system_spec: MobileMTSystemSpec | None = None,
inplace: bool = False,
) -> AirborneEMDataset:
r"""Mask admittance/conductivity outside the usable MobileMT band.
Reuses the published usable bandwidth already carried by
:class:`~pycsamt.airborne.mobilemt.MobileMTSystemSpec` (default
``nominal_frequency_range_hz`` of 19-26,000 Hz) rather than
inventing a new band definition. This is the one function in this
module meant to sit inside a processing pipeline (dataset in,
dataset out) rather than only produce a diagnostic table -- the
closest analogue here to
:func:`~pycsamt.emtools.afmag.flag_motion_susceptible_band` and
:func:`~pycsamt.emtools.ztem.mask_outside_ztem_band`.
Unlike those two functions, only masking is offered (no
``action="drop"``): each :class:`~pycsamt.airborne.AirborneEMRecord`
packages its admittance transfer function and any auxiliary
per-frequency fields (variance, covariances, native apparent
conductivity) around one shared period axis, and safely dropping
frequencies would require rebuilding all of them consistently.
Masking with ``nan`` needs no such reconstruction and never
confuses "known bad" with a physical zero.
Parameters
----------
dataset : AirborneEMDataset or AirborneEMLine
Anything accepted by :func:`ensure_mobilemt_dataset`.
band_hz : (float, float), optional
Explicit ``(low, high)`` band in Hz. Mutually exclusive with
*system_spec*; when neither is given, a default
:class:`~pycsamt.airborne.mobilemt.MobileMTSystemSpec`'s
``nominal_frequency_range_hz`` is used.
system_spec : MobileMTSystemSpec, optional
Survey-specific system metadata to read the band from.
inplace : bool, default False
When ``False`` (default), a deep copy of *dataset* is masked
and returned, leaving the input untouched.
Returns
-------
AirborneEMDataset
The (optionally new) dataset with out-of-band admittance
values and native apparent-conductivity samples set to
``nan``.
Raises
------
ValueError
If both *band_hz* and *system_spec* are given.
TypeError
If *system_spec* is given and is not a
:class:`~pycsamt.airborne.mobilemt.MobileMTSystemSpec`.
"""
if band_hz is not None and system_spec is not None:
raise ValueError(
"give at most one of band_hz or system_spec, not both"
)
if system_spec is not None and not isinstance(
system_spec, MobileMTSystemSpec
):
raise TypeError("system_spec must be a MobileMTSystemSpec or None")
if band_hz is not None:
lo, hi = float(band_hz[0]), float(band_hz[1])
else:
spec = system_spec or MobileMTSystemSpec()
lo, hi = spec.nominal_frequency_range_hz
ds = ensure_mobilemt_dataset(dataset)
if not inplace:
ds = copy.deepcopy(ds)
for line in ds.iter_lines():
for record in line.iter_records():
tf, freq = _get_admittance(record)
if tf is None or freq is None:
continue
keep = (freq >= lo) & (freq <= hi)
if keep.all():
continue
tf.data[~keep, :, :] = np.nan + 1j * np.nan
sigma_native = _native_sigma(record)
if sigma_native is not None:
sigma_native = sigma_native.copy()
sigma_native[~keep[: sigma_native.size]] = np.nan
record.fields[MOBILEMT_APPARENT_CONDUCTIVITY_FIELD] = (
sigma_native
)
return ds
# ─────────────────────────────────────────────────────────────────────────
# Plots
# ─────────────────────────────────────────────────────────────────────────
[docs]
def plot_mobilemt_admittance_profile(
dataset: Any,
*,
line_id: str | None = None,
component: str = "det",
part: str = "abs",
frequency_hz: float | None = None,
period_s: float | None = None,
figsize: tuple[float, float] = (9.5, 4.0),
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot one admittance component along one flight line.
Parameters
----------
dataset : AirborneEMDataset or AirborneEMLine
Anything accepted by :func:`ensure_mobilemt_dataset`.
line_id : str, optional
Flight line to plot; defaults to the first line in *dataset*.
component : {"xx", "xy", "yx", "yy", "hzx", "hzy", "det"}, default "det"
Admittance entry to plot, or ``"det"`` for the horizontal
2x2 determinant (see :func:`admittance_determinant_table`).
part : {"real", "imag", "abs"}, default "abs"
frequency_hz, period_s : float, optional
Reference frequency/period; nearest available value is used
per sample. At most one may be given; the median frequency
is used when neither is given.
figsize : (float, float), default (9.5, 4.0)
Used only when *ax* is not supplied.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
ds = ensure_mobilemt_dataset(dataset)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
try:
line = _pick_line(ds, line_id)
except ValueError:
ax.text(0.5, 0.5, "no flight lines", ha="center", va="center")
return ax
pos = _line_chainage(line)
xs: list[float] = []
ys: list[float] = []
freqs_used: list[float] = []
for idx, sample_id in enumerate(line.navigation.sample_ids):
record = line.records.get(sample_id)
if record is None:
continue
tf, freq = _get_admittance(record)
if tf is None or freq is None:
continue
k, f0 = _resolve_target_frequency(freq, frequency_hz, period_s)
val = _component_series(
np.asarray(tf.data, dtype=complex)[k : k + 1], component
)[0]
if not np.isfinite(val.real) or not np.isfinite(val.imag):
continue
v = {"real": val.real, "imag": val.imag, "abs": abs(val)}[part]
xs.append(pos[idx])
ys.append(float(v))
freqs_used.append(f0)
if not xs:
ax.text(0.5, 0.5, "no admittance data", ha="center", va="center")
return ax
order = np.argsort(xs)
xs_arr = np.asarray(xs)[order]
ys_arr = np.asarray(ys)[order]
ref = float(np.nanmedian(freqs_used))
_ml = PYCSAMT_STYLE.multiline
ax.axhline(0.0, color="0.85", lw=0.8)
ax.plot(xs_arr, ys_arr, lw=_ml.lw, alpha=_ml.alpha, color="tab:blue")
ax.set_xlabel("Position along flight line (m)")
ax.set_ylabel(f"Y[{component}] [{part}]")
ax.set_title(
f"MobileMT admittance profile — {line.line_id} @ {ref:.4g} Hz",
fontsize=10,
)
ax.grid(True, ls=":", alpha=0.3)
return ax
[docs]
def plot_mobilemt_conductivity_psection(
dataset: Any,
*,
line_id: str | None = None,
source: str = "theoretical",
cmap: str = "viridis",
clim: tuple[float, float] | None = None,
clim_pct: tuple[float, float] = (2.0, 98.0),
figsize: tuple[float, float] = (9.0, 5.0),
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot an apparent-conductivity pseudosection for one flight line.
Parameters
----------
dataset : AirborneEMDataset or AirborneEMLine
Anything accepted by :func:`ensure_mobilemt_dataset`.
line_id : str, optional
Flight line to plot; defaults to the first line in *dataset*.
source : {"theoretical", "native"}, default "theoretical"
``"theoretical"`` plots
:func:`admittance_determinant_table`'s derived
``theoretical_sigma_a_Sm`` (see the module docstring for the
caveat); ``"native"`` plots the vendor-delivered
``apparent_conductivity_native_Sm`` field, when present.
cmap : str, default "viridis"
clim : (float, float), optional
Explicit color limits; overrides *clim_pct*.
clim_pct : (float, float), default (2.0, 98.0)
Percentile color limits when *clim* is not given.
figsize : (float, float), default (9.0, 5.0)
Used only when *ax* is not supplied.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
Raises
------
ValueError
If *source* is not ``"theoretical"`` or ``"native"``.
"""
source = str(source).strip().lower()
if source not in {"theoretical", "native"}:
raise ValueError(
f"source must be 'theoretical' or 'native'; got {source!r}"
)
ds = ensure_mobilemt_dataset(dataset)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
try:
line = _pick_line(ds, line_id)
except ValueError:
ax.text(0.5, 0.5, "no flight lines", ha="center", va="center")
return ax
one_line = AirborneEMDataset(name=line.line_id, lines={line.line_id: line})
if source == "theoretical":
df = admittance_determinant_table(one_line)
col = "theoretical_sigma_a_Sm"
else:
df = admittance_table(one_line)
col = "apparent_conductivity_native_Sm"
if df.empty or df[col].isna().all():
ax.text(0.5, 0.5, "no data", ha="center", va="center")
return ax
order_df = df.drop_duplicates("sample_id").sort_values("x_m")
samples = order_df["sample_id"].tolist()
x_sorted = order_df["x_m"].to_numpy()
freqs = np.sort(df["freq_hz"].unique())[::-1]
grid = np.full((freqs.size, len(samples)), np.nan)
s_idx = {s: j for j, s in enumerate(samples)}
f_idx = {float(f): i for i, f in enumerate(freqs)}
for _, row in df.iterrows():
j = s_idx.get(row["sample_id"])
i = f_idx.get(float(row["freq_hz"]))
if i is not None and j is not None:
grid[i, j] = row[col]
finite = grid[np.isfinite(grid)]
if clim is None:
vmin, vmax = (
np.percentile(finite, clim_pct) if finite.size else (0.0, 1.0)
)
else:
vmin, vmax = clim
extent = (
-0.5,
len(samples) - 0.5,
np.log10(1.0 / freqs[0]),
np.log10(1.0 / freqs[-1]),
)
im = ax.imshow(
grid,
aspect="auto",
origin="upper",
interpolation="nearest",
cmap=cmap,
vmin=vmin,
vmax=vmax,
extent=extent,
)
n_xtick = min(8, len(samples))
step = max(1, len(samples) // n_xtick)
xt_idx = np.arange(0, len(samples), step)
ax.set_xticks(xt_idx)
ax.set_xticklabels(
[f"{x_sorted[k]:.0f}" for k in xt_idx],
rotation=45,
ha="right",
fontsize=8,
)
ax.set_xlabel("Position along flight line (m)")
ax.set_ylabel(LOG10_PERIOD_LABEL)
label = "theoretical" if source == "theoretical" else "native"
ax.set_title(
f"MobileMT apparent conductivity [{label}] — {line.line_id}",
fontsize=10,
)
add_colorbar(im, ax, label=r"$\sigma_a$ (S/m)")
return ax
[docs]
def plot_mobilemt_skew_profile(
dataset: Any,
*,
line_id: str | None = None,
frequency_hz: float | None = None,
period_s: float | None = None,
figsize: tuple[float, float] = (9.5, 4.0),
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot the admittance skew profile along one flight line.
See :func:`admittance_skew_table` for the underlying formula.
Parameters
----------
dataset : AirborneEMDataset or AirborneEMLine
Anything accepted by :func:`ensure_mobilemt_dataset`.
line_id : str, optional
Flight line to plot; defaults to the first line in *dataset*.
frequency_hz, period_s : float, optional
Reference frequency/period; nearest available value is used
per sample. At most one may be given; the median frequency
is used when neither is given.
figsize : (float, float), default (9.5, 4.0)
Used only when *ax* is not supplied.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
ds = ensure_mobilemt_dataset(dataset)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
try:
line = _pick_line(ds, line_id)
except ValueError:
ax.text(0.5, 0.5, "no flight lines", ha="center", va="center")
return ax
one_line = AirborneEMDataset(name=line.line_id, lines={line.line_id: line})
df = admittance_skew_table(one_line)
if df.empty:
ax.text(0.5, 0.5, "no admittance data", ha="center", va="center")
return ax
freqs = df["freq_hz"].to_numpy()
_, target = _resolve_target_frequency(
np.sort(np.unique(freqs)), frequency_hz, period_s
)
xs: list[float] = []
ys: list[float] = []
for _sample_id, sub in df.groupby("sample_id", sort=False):
idx = (sub["freq_hz"] - target).abs().idxmin()
xs.append(float(sub.loc[idx, "x_m"]))
ys.append(float(sub.loc[idx, "skew"]))
order = np.argsort(xs)
xs_arr = np.asarray(xs)[order]
ys_arr = np.asarray(ys)[order]
_ml = PYCSAMT_STYLE.multiline
ax.plot(xs_arr, ys_arr, lw=_ml.lw, alpha=_ml.alpha, color="tab:purple")
ax.set_xlabel("Position along flight line (m)")
ax.set_ylabel("Admittance skew")
ax.set_title(
f"MobileMT admittance skew — {line.line_id} @ {target:.4g} Hz",
fontsize=10,
)
ax.grid(True, ls=":", alpha=0.3)
return ax