# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""AFMAG-specific processing, diagnostics, and plotting.
AFMAG (Audio-Frequency Magnetics) is a passive, magnetic-field-only
method: there is no electric-field channel, so the usual impedance
tensor ``Z`` is not part of its data model. This module's own
"Tilt-angle diagnostics" and "Motion-coupling QC" sections below are
built, like :mod:`pycsamt.emtools.tf`, entirely on :attr:`Site.tipper`
(the complex ``(Tzx, Tzy)`` pair, ``Hz = Tzx*Hx + Tzy*Hy``) — they
never touch :attr:`Site.z`, and apply equally to ground data and to a
ZTEM-style airborne tipper (see :mod:`pycsamt.airborne.ztem`, which
documents that equivalence explicitly).
That is deliberately *not* a claim that every real AFMAG measurement
*is* a ``(Tzx, Tzy)`` tipper pair, though. :mod:`pycsamt.airborne.afmag`
keeps two genuinely different response families, neither of them
tipper-shaped:
* the historical two-coil comparator (Ward 1959) reports a bare
scalar tilt/deflection angle -- one real number per frequency, no
input/output channels, no polarization-ellipse decomposition
possible at all;
* modern tensor AFMAG/AirMt reports a complex ``(nf, 3, 2)``
interstation magnetic transfer function (ground-reference Hx,Hy ->
airborne Hx,Hy,Hz) plus a derived rotation-invariant amplification
parameter -- six components, not two.
Section "AFMAG-family tilt diagnostics" below handles those two real
shapes explicitly and separately (:func:`original_afmag_tilt_table`,
:func:`airmt_tilt_angles`), reading them via
:class:`~pycsamt.airborne.site.AirborneSite`'s
:attr:`~pycsamt.airborne.site.AirborneSite.afmag_tilt_deg`/
:attr:`~pycsamt.airborne.site.AirborneSite.interstation_tensor`
rather than forcing either into the tipper-shaped functions above.
Three genuinely different things live here side by side:
* Sections "Tilt-angle diagnostics" and "Motion-coupling QC" are
ordinary ``emtools`` functions built on the ZTEM-style tipper: they
take ``sites`` (coerced through
:func:`~pycsamt.emtools._core.ensure_any_sites`, like
:mod:`pycsamt.emtools.ztem`) and either return a ``Sites``-or-
``AirborneSites`` container (the one mutating function,
:func:`flag_motion_susceptible_band`) or a tidy
:class:`pandas.DataFrame` (matching the convention already used by
:func:`~pycsamt.emtools.spectra.psd_table`,
:func:`~pycsamt.emtools.spectra.coherence_table`, and friends — not
every ``emtools`` "processing" function returns a container; only
the data-mutating ones do).
* Section "AFMAG-family tilt diagnostics" is built on the two real
AFMAG-family shapes described above instead, and therefore only
ever accepts :class:`~pycsamt.airborne.site.AirborneSites` (there
is no ground-EDI equivalent of either shape).
* Section "Motion-coupling physics" implements the rotation-matrix /
Euler-angle motion-induced-noise method of Liu et al. (2018) directly
from their Eq. 1-14. These functions are deliberately **not**
``Sites``-based: they operate on raw attitude (yaw/pitch/roll) time
series and geomagnetic field geometry, neither of which
:class:`~pycsamt.site.base.Site` carries (it is strictly
frequency-domain, with no time axis and no attitude field — the only
attitude container in pyCSAMT today is
:class:`~pycsamt.airborne.navigation.NavigationTrack`, which belongs
to the unrelated :mod:`pycsamt.airborne` line/dataset model). They
are included here, as pure reusable physics, because the paper's
actual deliverable is exactly this math; forcing it onto ``Sites``
would require either fabricating time-series storage that does not
exist or silently doing nothing. The one bridge between the two
halves is :func:`motion_susceptibility_table` /
:func:`flag_motion_susceptible_band`, which use the Section-1 math
together with a nominal attitude-amplitude envelope to flag which
*stations* are most exposed to motion noise in their low-frequency
band — the paper's own finding is that this noise concentrates at
low frequency and depends on attitude amplitude and geomagnetic
geometry, not on a raw time series.
No apparent-resistivity/conductivity formula is derived from tilt angle
here: unlike full MT, classical AFMAG tilt data has no simple closed
form for that (nothing in Ward 1959 or Liu et al. 2018 provides one),
so none is invented.
References
----------
.. [Ward1959] Ward, S. H. (1959). AFMAG -- Airborne and ground.
Geophysics, 24(4), 761-787.
.. [Liu2018] Liu, F., Huang, L., Pang, Y., Shi, Z., Xiao, P., & Fang, G.
(2018). Airborne AFMAG method motion-induced noise simulation and
suppression. Journal of Applied Geophysics, 158, 129-138.
"""
from __future__ import annotations
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from ..api.labels import LOG10_PERIOD_LABEL
from ..api.plot import add_colorbar, add_polar_colorbar
from ..api.station import PYCSAMT_STATION_RENDERING
from ..api.style import PYCSAMT_STYLE
from ._core import (
_apply_each,
_axes_list,
_get_t_block,
_iter_items,
_name,
ensure_any_sites,
hide_polar_radius_labels,
)
__all__ = [
# motion-coupling physics (Liu et al. 2018)
"euler_rotation_matrix",
"geomagnetic_field_direction",
"coil_normal_direction",
"motion_coupling_cosine",
"motion_coupling_angle",
"simulate_motion_induced_voltage",
"correct_motion_induced_noise",
# tilt-angle diagnostics (ground / tipper-shaped)
"afmag_tilt_angles",
# tilt-angle diagnostics (real AFMAG-family shapes)
"airmt_tilt_angles",
"original_afmag_tilt_table",
# motion-coupling QC
"motion_susceptibility_table",
"flag_motion_susceptible_band",
# plots
"plot_afmag_tilt_profile",
"plot_afmag_tilt_psection",
"plot_afmag_tilt_polar",
"plot_airmt_tilt_profile",
"plot_airmt_tilt_psection",
"plot_original_afmag_tilt_profile",
"original_afmag_conductor_diagnostics",
"plot_original_afmag_dual_frequency_profile",
"plot_motion_coupling",
"plot_motion_susceptibility_map",
"plot_afmag_correction_comparison",
]
# Historical original-AFMAG comparator band; reused as the default
# "motion-vulnerable" reference range rather than inventing a new one.
# Mirrors ``pycsamt.airborne.afmag.constants
# .AFMAG_ORIGINAL_TYPICAL_FREQUENCIES_HZ`` without importing
# ``pycsamt.airborne`` here (a heavier, unrelated package graph).
_DEFAULT_SUSCEPTIBLE_BAND_HZ = (150.0, 510.0)
# ─────────────────────────────────────────────────────────────────────────
# Motion-coupling physics (Liu et al. 2018, Eq. 1-14 and appendix)
# ─────────────────────────────────────────────────────────────────────────
[docs]
def euler_rotation_matrix(
yaw: Any,
pitch: Any,
roll: Any,
*,
degrees: bool = True,
) -> np.ndarray:
r"""Return the ZYX "airline convention" attitude rotation matrix.
Implements Liu et al. (2018) Eq. 3-4 (equivalently their appendix
A1-A6): the standard aerospace Tait-Bryan rotation
:math:`R_{LS} = R_z(\psi)\,R_y(\theta)\,R_x(\phi)` from the
sensor/body frame (S) to the local, fixed east-north-up frame (L),
where :math:`\psi` = yaw (about :math:`Z_S`), :math:`\theta` =
pitch (about the once-rotated :math:`Y_S'`), :math:`\phi` = roll
(about the twice-rotated :math:`X_S''`).
Parameters
----------
yaw, pitch, roll : array-like
Attitude angles, broadcastable to a common shape ``(...,)``.
Degrees by default; radians when ``degrees=False``.
degrees : bool, default True
Whether *yaw*, *pitch*, *roll* are in degrees.
Returns
-------
ndarray of shape (..., 3, 3)
Rotation matrix ``R_LS`` for each broadcast attitude sample;
``x_L = R_LS @ x_S``.
Notes
-----
``R_LS`` is a proper rotation: it is always orthogonal
(``R_LS.T == inv(R_LS)``) with determinant ``+1``, for any input
angles. Identity input (``yaw=pitch=roll=0``) returns the identity
matrix.
Examples
--------
>>> from pycsamt.emtools.afmag import euler_rotation_matrix
>>> R = euler_rotation_matrix(0.0, 0.0, 0.0)
>>> np.allclose(R, np.eye(3))
True
"""
yaw_arr = np.asarray(yaw, dtype=float)
pitch_arr = np.asarray(pitch, dtype=float)
roll_arr = np.asarray(roll, dtype=float)
yaw_arr, pitch_arr, roll_arr = np.broadcast_arrays(
yaw_arr, pitch_arr, roll_arr
)
if degrees:
yaw_arr = np.deg2rad(yaw_arr)
pitch_arr = np.deg2rad(pitch_arr)
roll_arr = np.deg2rad(roll_arr)
cy, sy = np.cos(yaw_arr), np.sin(yaw_arr)
cp, sp = np.cos(pitch_arr), np.sin(pitch_arr)
cr, sr = np.cos(roll_arr), np.sin(roll_arr)
r = np.empty(yaw_arr.shape + (3, 3), dtype=float)
r[..., 0, 0] = cy * cp
r[..., 0, 1] = cy * sp * sr - sy * cr
r[..., 0, 2] = cy * sp * cr + sy * sr
r[..., 1, 0] = sy * cp
r[..., 1, 1] = sy * sp * sr + cy * cr
r[..., 1, 2] = sy * sp * cr - cy * sr
r[..., 2, 0] = -sp
r[..., 2, 1] = cp * sr
r[..., 2, 2] = cp * cr
return r
[docs]
def geomagnetic_field_direction(
inclination: Any,
declination: Any,
*,
degrees: bool = True,
) -> np.ndarray:
r"""Return the local geomagnetic field unit vector :math:`B_E`.
In the (east, north, up) local frame used throughout this module
(Liu et al. 2018 Eq. 8-9), with *inclination* the dip angle below
horizontal (positive downward) and *declination* the horizontal
bearing from geographic north toward east:
.. math::
\hat{B}_E = (\cos I \sin D,\ \cos I \cos D,\ -\sin I)
Parameters
----------
inclination, declination : array-like
Geomagnetic inclination and declination, broadcastable to a
common shape ``(...,)``. Degrees by default.
degrees : bool, default True
Whether *inclination* and *declination* are in degrees.
Returns
-------
ndarray of shape (..., 3)
Unit vector(s) ``(east, north, up)``.
Examples
--------
>>> from pycsamt.emtools.afmag import geomagnetic_field_direction
>>> np.round(geomagnetic_field_direction(90.0, 0.0), 6) # straight down
array([ 0., 0., -1.])
"""
inc = np.asarray(inclination, dtype=float)
dec = np.asarray(declination, dtype=float)
inc, dec = np.broadcast_arrays(inc, dec)
if degrees:
inc = np.deg2rad(inc)
dec = np.deg2rad(dec)
east = np.cos(inc) * np.sin(dec)
north = np.cos(inc) * np.cos(dec)
up = -np.sin(inc)
return np.stack([east, north, up], axis=-1)
[docs]
def coil_normal_direction(
yaw: Any,
pitch: Any,
roll: Any,
*,
degrees: bool = True,
) -> np.ndarray:
r"""Return the coil-normal unit vector :math:`N_L` in the local frame.
Implements Liu et al. (2018) Eq. 10-12 for a single z-axis coil
(:math:`N_S = (0, 0, 1)^T`, "we use z-axis hereinafter for the sake
of simplification"): :math:`N_L = R_{LS}\,N_S`, i.e. the third
column of :func:`euler_rotation_matrix`.
Parameters
----------
yaw, pitch, roll : array-like
Forwarded to :func:`euler_rotation_matrix`.
degrees : bool, default True
Forwarded to :func:`euler_rotation_matrix`.
Returns
-------
ndarray of shape (..., 3)
Unit vector(s) ``(east, north, up)``. Identity attitude
(``yaw=pitch=roll=0``) returns straight up, ``(0, 0, 1)``.
"""
r = euler_rotation_matrix(yaw, pitch, roll, degrees=degrees)
return r[..., :, 2]
[docs]
def motion_coupling_cosine(
yaw: Any,
pitch: Any,
roll: Any,
inclination: Any,
declination: Any,
*,
degrees: bool = True,
) -> np.ndarray:
r"""Return :math:`\cos\theta(t)`, the coil/field coupling factor.
Implements Liu et al. (2018) Eq. 13-14:
:math:`\cos\theta(t) = \hat{B}_E \cdot N_L(t)`, the cosine of the
angle between the (fixed) geomagnetic field direction and the
(time-varying) coil-normal direction. This is the quantity whose
time derivative drives the motion-induced noise voltage; see
:func:`simulate_motion_induced_voltage`.
Parameters
----------
yaw, pitch, roll : array-like
Attitude time series, broadcastable to a common shape.
inclination, declination : array-like
Geomagnetic field geometry, broadcastable against the attitude
shape (a single site typically supplies scalars here while
attitude varies over time).
degrees : bool, default True
Whether all five angle inputs are in degrees.
Returns
-------
ndarray
:math:`\cos\theta(t)`, broadcast shape of the inputs, values
in ``[-1, 1]``.
Examples
--------
At identity attitude the coil normal points straight up; at 90
degrees inclination (a magnetic pole) the field points straight
down, so the two are exactly anti-aligned and
:math:`\cos\theta = -1`:
>>> from pycsamt.emtools.afmag import motion_coupling_cosine
>>> motion_coupling_cosine(0.0, 0.0, 0.0, 90.0, 0.0)
-1.0
"""
n_l = coil_normal_direction(yaw, pitch, roll, degrees=degrees)
b_e = geomagnetic_field_direction(
inclination, declination, degrees=degrees
)
n_l, b_e = np.broadcast_arrays(n_l, b_e)
cos_theta = np.sum(b_e * n_l, axis=-1)
return np.clip(cos_theta, -1.0, 1.0)
[docs]
def motion_coupling_angle(
yaw: Any,
pitch: Any,
roll: Any,
inclination: Any,
declination: Any,
*,
degrees: bool = True,
) -> np.ndarray:
r"""Return :math:`\theta(t)` in degrees.
See :func:`motion_coupling_cosine` for the underlying computation.
"""
cos_theta = motion_coupling_cosine(
yaw, pitch, roll, inclination, declination, degrees=degrees
)
return np.rad2deg(np.arccos(cos_theta))
[docs]
def simulate_motion_induced_voltage(
cos_theta: Any,
*,
dt: float,
gain: float = 1.0,
axis: int = -1,
) -> np.ndarray:
r"""Return the simulated motion-induced noise voltage :math:`V(t)`.
Implements Liu et al. (2018) Eq. 2, :math:`V(t) \propto
-\,d(\cos\theta(t))/dt`, via a centred finite difference
(:func:`numpy.gradient`).
Parameters
----------
cos_theta : array-like
:math:`\cos\theta(t)`, typically from
:func:`motion_coupling_cosine`.
dt : float
Sample interval in seconds along *axis*.
gain : float, default 1.0
Bundles the instrument-specific scale factor :math:`S \cdot N
\cdot |B_E|` (coil area x turns x field magnitude) from Eq. 2.
The paper calibrates this from the instrument rather than
deriving it analytically, so it is left as an explicit,
user-supplied factor rather than a fabricated constant.
axis : int, default -1
Time axis of *cos_theta*.
Returns
-------
ndarray
Simulated noise voltage, same shape as *cos_theta*.
"""
arr = np.asarray(cos_theta, dtype=float)
if dt <= 0.0 or not np.isfinite(dt):
raise ValueError("dt must be finite and positive")
return -float(gain) * np.gradient(arr, dt, axis=axis)
[docs]
def correct_motion_induced_noise(
measured: Any,
predicted_noise: Any,
) -> np.ndarray:
"""Return the motion-noise-corrected signal (Liu et al. 2018 Sec. 5).
The corrected signal is the measured signal minus the predicted
noise voltage from :func:`simulate_motion_induced_voltage`. This
function exists to give that one-line step an obvious, documented
name rather than leaving every caller to subtract the two arrays
themselves.
Parameters
----------
measured : array-like
Raw movement-system signal.
predicted_noise : array-like
Predicted motion-induced noise, same shape as *measured*.
Returns
-------
ndarray
``measured - predicted_noise``.
Raises
------
ValueError
If *measured* and *predicted_noise* have different shapes.
"""
m = np.asarray(measured, dtype=float)
n = np.asarray(predicted_noise, dtype=float)
if m.shape != n.shape:
raise ValueError(
"measured and predicted_noise must have the same shape: "
f"{m.shape} != {n.shape}"
)
return m - n
# ─────────────────────────────────────────────────────────────────────────
# Tilt-angle diagnostics (classical AFMAG readout <-> Site.tipper)
# ─────────────────────────────────────────────────────────────────────────
def _tilt_components(t: np.ndarray) -> dict[str, np.ndarray]:
"""Return real/imag magnitude, azimuth, and resultant for one tipper."""
tx, ty = t[:, 0], t[:, 1]
out = {}
for label, tx_c, ty_c in (
("real", np.real(tx), np.real(ty)),
("imag", np.imag(tx), np.imag(ty)),
):
out[f"tilt_{label}_deg"] = np.rad2deg(
np.arctan(np.hypot(tx_c, ty_c))
)
out[f"tilt_{label}_azimuth_deg"] = np.rad2deg(
np.arctan2(ty_c, tx_c)
)
out["tilt_resultant_deg"] = np.rad2deg(
np.arctan(np.hypot(np.abs(tx), np.abs(ty)))
)
return out
[docs]
def afmag_tilt_angles(
sites: Any,
*,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
r"""Return per-station, per-frequency AFMAG tilt angles as a table.
The classical AFMAG comparator readout is, in modern tipper terms,
:math:`\arctan|T|` decomposed into in-phase ("real") and
quadrature ("imag") components (Ward 1959) — the same real/imag
split :mod:`pycsamt.emtools.tf`'s induction-arrow functions already
use (:func:`~pycsamt.emtools.tf.plot_induction_arrows`), computed
directly from :attr:`~pycsamt.site.base.Site.tipper`. No electric
field or apparent-resistivity quantity is involved or derived.
Parameters
----------
sites : Sites-like or AirborneSites-like
Anything accepted by
:func:`~pycsamt.emtools._core.ensure_any_sites`. Ground
tipper-shaped input goes through
:class:`~pycsamt.site.base.Sites` as before; airborne input
goes through :class:`~pycsamt.airborne.site.AirborneSites`
but only finds data here when it carries a ZTEM-style
tipper transfer function -- see :func:`airmt_tilt_angles`/
:func:`original_afmag_tilt_table` for the real AFMAG-family
shapes instead.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`.
Returns
-------
pandas.DataFrame
Columns: ``station``, ``freq``, ``period``,
``tilt_real_deg``, ``tilt_real_azimuth_deg``,
``tilt_imag_deg``, ``tilt_imag_azimuth_deg``,
``tilt_resultant_deg``. Stations with no tipper are omitted,
not filled with a fabricated zero.
"""
S = ensure_any_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
rows: list[dict[str, Any]] = []
for i, ed in enumerate(_iter_items(S)):
station = _name(ed, i)
_, t, fr = _get_t_block(ed)
if t is None or fr is None:
continue
comp = _tilt_components(t)
for k in range(fr.size):
row = {
"station": station,
"freq": float(fr[k]),
"period": float(1.0 / fr[k]) if fr[k] != 0 else np.nan,
}
row.update({key: float(val[k]) for key, val in comp.items()})
rows.append(row)
return pd.DataFrame.from_records(rows)
# ─────────────────────────────────────────────────────────────────────────
# AFMAG-family tilt diagnostics (the two real, non-tipper shapes)
# ─────────────────────────────────────────────────────────────────────────
[docs]
def airmt_tilt_angles(
sites: Any,
*,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
r"""Per-station, per-frequency tensor AFMAG/AirMt tilt angles.
Applies the exact same real/imaginary tilt-angle decomposition
as :func:`afmag_tilt_angles` (:func:`_tilt_components`) -- but to
the ``(Hzx, Hzy)`` row of
:attr:`~pycsamt.airborne.site.AirborneSite.interstation_tensor`
rather than to :attr:`~pycsamt.site.base.Site.tipper`. That row
is physically the same Hz-to-horizontal relation a ZTEM tipper
describes; the other two rows (``Hxx, Hxy, Hyx, Hyy``, the
horizontal-to-horizontal part of the tensor) are not used here.
Parameters
----------
sites : AirborneSites-like
Anything accepted by
:func:`~pycsamt.airborne.site.ensure_asites`. Sites with no
``interstation_transfer_functions`` transfer function
attached (including original-comparator AFMAG sites -- see
:func:`original_afmag_tilt_table` for those) are silently
omitted, not filled with a fabricated zero.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.airborne.site.ensure_asites`.
Returns
-------
pandas.DataFrame
Same columns as :func:`afmag_tilt_angles`: ``station``,
``freq``, ``period``, ``tilt_real_deg``,
``tilt_real_azimuth_deg``, ``tilt_imag_deg``,
``tilt_imag_azimuth_deg``, ``tilt_resultant_deg``.
"""
from ..airborne.site import ensure_asites
S = ensure_asites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
rows: list[dict[str, Any]] = []
for ed in S:
tensor = ed.interstation_tensor
fr = ed.freq
if tensor is None or fr is None:
continue
t = np.asarray(tensor)[:, 2, :] # (Hzx, Hzy), shape (nf, 2)
comp = _tilt_components(t)
for k in range(fr.size):
row = {
"station": ed.name,
"freq": float(fr[k]),
"period": float(1.0 / fr[k]) if fr[k] != 0 else np.nan,
}
row.update({key: float(val[k]) for key, val in comp.items()})
rows.append(row)
return pd.DataFrame.from_records(rows)
[docs]
def original_afmag_tilt_table(
sites: Any,
*,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
r"""Per-station, per-frequency original comparator AFMAG tilt table.
Reads :attr:`~pycsamt.airborne.site.AirborneSite.afmag_tilt_deg`
directly -- the historical comparator measurement already *is*
a tilt angle in degrees, not a complex tipper pair, so there is
no ``arctan``/decomposition step here the way there is in
:func:`afmag_tilt_angles`/:func:`airmt_tilt_angles`: a single
real number per frequency is all the instrument ever reported
(see the module docstring).
Parameters
----------
sites : AirborneSites-like
Anything accepted by
:func:`~pycsamt.airborne.site.ensure_asites`. Sites with no
``afmag_tilt`` transfer function attached are silently
omitted, not filled with a fabricated zero.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.airborne.site.ensure_asites`.
Returns
-------
pandas.DataFrame
Columns: ``station``, ``freq``, ``period``, ``tilt_deg``.
"""
from ..airborne.site import ensure_asites
S = ensure_asites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
rows: list[dict[str, Any]] = []
for ed in S:
tilt = ed.afmag_tilt_deg
fr = ed.freq
if tilt is None or fr is None:
continue
for k in range(fr.size):
rows.append(
{
"station": ed.name,
"freq": float(fr[k]),
"period": (
float(1.0 / fr[k]) if fr[k] != 0 else np.nan
),
"tilt_deg": float(tilt[k]),
}
)
return pd.DataFrame.from_records(rows)
# ─────────────────────────────────────────────────────────────────────────
# Motion-coupling QC (bridges the physics and tipper-diagnostic halves)
# ─────────────────────────────────────────────────────────────────────────
def _station_coordinates(ed: Any) -> tuple[float, float] | None:
coords = getattr(ed, "coords", None)
if coords is None:
return None
try:
lat, lon = float(coords[0]), float(coords[1])
except (TypeError, ValueError, IndexError):
return None
if np.isfinite(lat) and np.isfinite(lon):
return lat, lon
return None
def _susceptibility_rows(
S: Any,
*,
inclination: float,
declination: float,
roll_amplitude_deg: float,
pitch_amplitude_deg: float,
yaw_amplitude_deg: float,
n_phase: int = 180,
) -> list[dict[str, Any]]:
"""Sweep a nominal attitude envelope and score the resulting swing."""
phase = np.linspace(0.0, 2.0 * np.pi, int(n_phase), endpoint=False)
yaw = yaw_amplitude_deg * np.sin(phase)
pitch = pitch_amplitude_deg * np.sin(phase)
roll = roll_amplitude_deg * np.cos(phase)
cos_theta = motion_coupling_cosine(
yaw, pitch, roll, inclination, declination
)
swing = float(np.max(cos_theta) - np.min(cos_theta))
rows: list[dict[str, Any]] = []
for i, ed in enumerate(_iter_items(S)):
rows.append(
{
"station": _name(ed, i),
"inclination_deg": float(inclination),
"declination_deg": float(declination),
"cos_theta_min": float(np.min(cos_theta)),
"cos_theta_max": float(np.max(cos_theta)),
"susceptibility_score": swing,
}
)
return rows
[docs]
def motion_susceptibility_table(
sites: Any,
*,
inclination: float,
declination: float,
roll_amplitude_deg: float,
pitch_amplitude_deg: float,
yaw_amplitude_deg: float = 0.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
r"""Score each station's exposure to motion-induced noise.
Sweeps a nominal sinusoidal attitude envelope (amplitude only, no
assumed platform frequency) through
:func:`motion_coupling_cosine` at the survey's geomagnetic
geometry, and reports the resulting :math:`\cos\theta` swing —
the geometric factor that Liu et al. (2018) show drives
motion-induced noise. This is independent of EM frequency; the
paper's finding that the noise concentrates at low EM frequency
comes from the platform's own physical oscillation spectrum
(typically a few Hz), not from a frequency-dependence of this
coupling geometry itself.
Parameters
----------
sites : Sites-like or AirborneSites-like
Anything accepted by
:func:`~pycsamt.emtools._core.ensure_any_sites`. Ground
tipper-shaped input goes through
:class:`~pycsamt.site.base.Sites` as before; airborne input
goes through :class:`~pycsamt.airborne.site.AirborneSites`
but only finds data here when it carries a ZTEM-style
tipper transfer function -- see :func:`airmt_tilt_angles`/
:func:`original_afmag_tilt_table` for the real AFMAG-family
shapes instead.
inclination, declination : float
Geomagnetic inclination/declination in degrees, assumed
common to the survey (a local, small-region approximation
matching the paper's own "geomagnetic field is a constant
vector in a local region" assumption).
roll_amplitude_deg, pitch_amplitude_deg : float
Nominal peak platform roll/pitch amplitude in degrees.
yaw_amplitude_deg : float, default 0.0
Nominal peak yaw amplitude; the paper finds yaw has no effect
for a z-axis coil (rotation about the coil's own normal), so
this defaults to zero rather than an assumed value.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`.
Returns
-------
pandas.DataFrame
Columns: ``station``, ``inclination_deg``, ``declination_deg``,
``cos_theta_min``, ``cos_theta_max``, ``susceptibility_score``
(``cos_theta_max - cos_theta_min``; larger means more exposed).
"""
S = ensure_any_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
rows = _susceptibility_rows(
S,
inclination=inclination,
declination=declination,
roll_amplitude_deg=roll_amplitude_deg,
pitch_amplitude_deg=pitch_amplitude_deg,
yaw_amplitude_deg=yaw_amplitude_deg,
)
return pd.DataFrame.from_records(rows)
[docs]
def flag_motion_susceptible_band(
sites: Any,
*,
inclination: float,
declination: float,
roll_amplitude_deg: float,
pitch_amplitude_deg: float,
yaw_amplitude_deg: float = 0.0,
band_hz: tuple[float, float] = _DEFAULT_SUSCEPTIBLE_BAND_HZ,
threshold: float = 0.05,
action: str = "mask",
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Any:
r"""Flag/mask a station's low-frequency tipper band as motion-suspect.
For stations whose :func:`motion_susceptibility_table` score
exceeds *threshold*, the tipper values within *band_hz* are either
masked (set to ``nan``, distinguishing "suspect" from "measured
zero") or dropped entirely. For ground ``Sites`` input, this
mirrors the same ``ensure_sites`` -> ``_apply_each`` mutation
contract used by
:func:`~pycsamt.emtools.remove_noise.notch_powerline` and
:func:`~pycsamt.emtools.remove_noise.drop_freqs_manual`. Only
``action="mask"`` is offered for
:class:`~pycsamt.airborne.site.AirborneSites` input (see Raises),
matching :func:`~pycsamt.emtools.ztem.mask_outside_ztem_band`'s
identical restriction for the identical reason -- this function
only ever finds real tipper data on an ``AirborneSite`` in the
first place when it carries a ZTEM-style tipper transfer
function, since neither real AFMAG-family shape
(:attr:`~pycsamt.airborne.site.AirborneSite.afmag_tilt_deg`,
:attr:`~pycsamt.airborne.site.AirborneSite.interstation_tensor`)
is one.
This does not attempt the paper's literal time-domain subtraction
(see the module docstring for why: ``Sites`` carries no raw time
series to subtract from). It is a QC gate, not a noise-removal
algorithm — flagging a plausibly contaminated band so it can be
reviewed or excluded, the same way the rest of ``emtools`` treats
other unreliable-frequency conditions.
Parameters
----------
sites : Sites-like or AirborneSites-like
Anything accepted by
:func:`~pycsamt.emtools._core.ensure_any_sites`. Ground
tipper-shaped input goes through
:class:`~pycsamt.site.base.Sites` as before; airborne input
goes through :class:`~pycsamt.airborne.site.AirborneSites`
but only finds data here when it carries a ZTEM-style
tipper transfer function -- see :func:`airmt_tilt_angles`/
:func:`original_afmag_tilt_table` for the real AFMAG-family
shapes instead.
inclination, declination : float
Forwarded to :func:`motion_susceptibility_table`.
roll_amplitude_deg, pitch_amplitude_deg : float
Forwarded to :func:`motion_susceptibility_table`.
yaw_amplitude_deg : float, default 0.0
Forwarded to :func:`motion_susceptibility_table`.
band_hz : (float, float), default (150.0, 510.0)
Frequency band to flag on a susceptible station, in Hz.
Defaults to the historical original-AFMAG comparator band.
threshold : float, default 0.05
Minimum ``susceptibility_score`` (see
:func:`motion_susceptibility_table`) for a station to be
flagged at all.
action : {"mask", "drop"}, default "mask"
``"mask"`` sets flagged tipper values to ``nan`` in place;
``"drop"`` removes the corresponding frequency rows entirely.
inplace, recursive, on_dup, strict, verbose
Standard ``emtools`` processing-function tail; see
:func:`~pycsamt.emtools.remove_noise.notch_powerline` for the
established convention this mirrors.
Returns
-------
Sites
The (optionally new) sites collection with flagged bands
masked or dropped.
Raises
------
ValueError
If *action* is not ``"mask"`` or ``"drop"``, or if *action*
is ``"drop"`` and *sites* resolves to
:class:`~pycsamt.airborne.site.AirborneSites`.
"""
from ..airborne.site import AirborneSites
action = str(action).strip().lower()
if action not in {"mask", "drop"}:
raise ValueError("action must be 'mask' or 'drop'")
lo, hi = float(band_hz[0]), float(band_hz[1])
def _one(Si: Any) -> Any:
rows = _susceptibility_rows(
Si,
inclination=inclination,
declination=declination,
roll_amplitude_deg=roll_amplitude_deg,
pitch_amplitude_deg=pitch_amplitude_deg,
yaw_amplitude_deg=yaw_amplitude_deg,
)
if not rows or rows[0]["susceptibility_score"] < threshold:
return Si
for ed in _iter_items(Si):
T, t, fr = _get_t_block(ed)
if T is None or t is None:
continue
in_band = (fr >= lo) & (fr <= hi)
if not np.any(in_band):
continue
if action == "mask":
t[in_band, :] = np.nan + 1j * np.nan
if hasattr(T, "tipper"):
T.tipper = t
elif hasattr(T, "T"):
T.T = t
else:
keep = ~in_band
new_t = t[keep, :]
new_fr = fr[keep]
for name in ("tipper", "T"):
if hasattr(T, name):
setattr(T, name, new_t)
break
if hasattr(T, "freq"):
T.freq = new_fr
elif hasattr(ed, "freq"):
ed.freq = new_fr
return Si
S = ensure_any_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if isinstance(S, AirborneSites):
if action == "drop":
raise ValueError(
"action='drop' is not supported for AirborneSites "
"(it would leave the EMTF document's shared period "
"axis inconsistent with the tipper transfer "
"function's own periods); use action='mask' instead"
)
if not inplace:
import copy
S = copy.deepcopy(S)
_one(S)
return S
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# ─────────────────────────────────────────────────────────────────────────
# Plots
# ─────────────────────────────────────────────────────────────────────────
[docs]
def plot_afmag_tilt_profile(
sites: Any,
*,
component: str = "real",
frequency_hz: float | None = None,
period_s: float | None = None,
figsize: tuple[float, float] = (9.5, 4.0),
station_label_step: int | None = 1,
station_preset: str = "pseudosection",
station_style: Any | None = None,
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot the classic AFMAG flight-line tilt-angle profile.
One value per station at a single reference frequency/period — the
historical field presentation of AFMAG data, analogous to
:func:`~pycsamt.emtools.remove_noise.plot_emap_filter_profile`'s
station-profile layout.
Parameters
----------
sites : Sites-like or AirborneSites-like
Anything accepted by
:func:`~pycsamt.emtools._core.ensure_any_sites`. Ground
tipper-shaped input goes through
:class:`~pycsamt.site.base.Sites` as before; airborne input
goes through :class:`~pycsamt.airborne.site.AirborneSites`
but only finds data here when it carries a ZTEM-style
tipper transfer function -- see :func:`airmt_tilt_angles`/
:func:`original_afmag_tilt_table` for the real AFMAG-family
shapes instead.
component : {"real", "imag", "resultant"}, default "real"
Which :func:`afmag_tilt_angles` column to plot.
frequency_hz, period_s : float, optional
Reference frequency/period; nearest available value is used
per station. Exactly one may be given; the median frequency
across all stations is used when neither is given.
figsize : (float, float), default (9.5, 4.0)
Used only when *ax* is not supplied.
station_label_step, station_preset, station_style
Forwarded to
:meth:`~pycsamt.api.station.PyCSAMTStationRendering.style_for`
via :func:`_apply_station_rendering`, matching the top-of-
section station convention used throughout pyCSAMT.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
table = afmag_tilt_angles(sites)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if table.empty:
ax.text(0.5, 0.5, "no tipper data", ha="center", va="center")
return ax
col = {
"real": "tilt_real_deg",
"imag": "tilt_imag_deg",
"resultant": "tilt_resultant_deg",
}.get(component, "tilt_real_deg")
if frequency_hz is None and period_s is not None:
frequency_hz = 1.0 / max(float(period_s), 1e-24)
if frequency_hz is None:
frequency_hz = float(table["freq"].median())
stations = list(dict.fromkeys(table["station"]))
values = []
used_freqs = []
for station in stations:
sub = table[table["station"] == station]
idx = (sub["freq"] - frequency_hz).abs().idxmin()
values.append(float(sub.loc[idx, col]))
used_freqs.append(float(sub.loc[idx, "freq"]))
x = np.arange(len(stations))
_ml = PYCSAMT_STYLE.multiline
ax.plot(x, values, lw=_ml.lw, alpha=_ml.alpha, marker="o")
_apply_station_rendering(
ax,
stations,
station_label_step=station_label_step,
station_preset=station_preset,
station_style=station_style,
)
ref = float(np.nanmedian(used_freqs))
ax.set_ylabel(f"AFMAG tilt angle ({component}), deg")
ax.set_title(f"AFMAG tilt profile at {ref:.4g} Hz", fontsize=10)
ax.grid(True, ls=":", alpha=0.35)
return ax
[docs]
def plot_afmag_tilt_psection(
sites: Any,
*,
component: str = "resultant",
cmap: str = "RdYlBu_r",
clim: tuple[float, float] | None = None,
clim_pct: tuple[float, float] = (2.0, 98.0),
figsize: tuple[float, float] = (9.0, 5.0),
station_label_step: int | None = 1,
station_preset: str = "pseudosection",
station_style: Any | None = None,
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot an AFMAG tilt-angle pseudosection (station x log-period).
Parameters
----------
sites : Sites-like or AirborneSites-like
Anything accepted by
:func:`~pycsamt.emtools._core.ensure_any_sites`. Ground
tipper-shaped input goes through
:class:`~pycsamt.site.base.Sites` as before; airborne input
goes through :class:`~pycsamt.airborne.site.AirborneSites`
but only finds data here when it carries a ZTEM-style
tipper transfer function -- see :func:`airmt_tilt_angles`/
:func:`original_afmag_tilt_table` for the real AFMAG-family
shapes instead.
component : {"real", "imag", "resultant"}, default "resultant"
Which :func:`afmag_tilt_angles` column to image.
cmap : str, default "RdYlBu_r"
Colormap name.
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.
station_label_step, station_preset, station_style
See :func:`plot_afmag_tilt_profile`.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
table = afmag_tilt_angles(sites)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if table.empty:
ax.text(0.5, 0.5, "no tipper data", ha="center", va="center")
return ax
col = {
"real": "tilt_real_deg",
"imag": "tilt_imag_deg",
"resultant": "tilt_resultant_deg",
}.get(component, "tilt_resultant_deg")
stations = list(dict.fromkeys(table["station"]))
freqs = np.sort(table["freq"].unique())[::-1]
grid = np.full((freqs.size, len(stations)), np.nan, dtype=float)
for j, station in enumerate(stations):
sub = table[table["station"] == station]
for _, row in sub.iterrows():
i = int(np.argmin(np.abs(freqs - row["freq"])))
grid[i, j] = row[col]
if clim is None:
finite = grid[np.isfinite(grid)]
vmin, vmax = (
np.percentile(finite, clim_pct) if finite.size else (0.0, 1.0)
)
else:
vmin, vmax = float(clim[0]), float(clim[1])
extent = (
-0.5,
len(stations) - 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,
)
ax.set_ylabel(LOG10_PERIOD_LABEL)
ax.set_title(f"AFMAG tilt pseudosection [{component}]", fontsize=10)
_apply_station_rendering(
ax,
stations,
station_label_step=station_label_step,
station_preset=station_preset,
station_style=station_style,
)
add_colorbar(im, ax, label="tilt angle (deg)")
return ax
[docs]
def plot_afmag_tilt_polar(
sites: Any,
*,
station: str | None = None,
component: str = "real",
cmap: str = "viridis",
figsize: tuple[float, float] = (5.5, 5.5),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Polar view of AFMAG tilt: azimuth and magnitude vs. period.
Direct AFMAG-labelled counterpart of
:func:`~pycsamt.emtools.tf.plot_tipper_polar`: each frequency is
one scatter point, colour encodes :math:`\log_{10}(\text{period})`.
Parameters
----------
sites : Sites-like
station : str, optional
Station to plot; defaults to the first (sorted) station.
component : {"real", "imag"}, default "real"
cmap : str, default "viridis"
figsize : (float, float), default (5.5, 5.5)
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`.
ax : matplotlib.axes.Axes, optional
Existing polar axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
import matplotlib.colors as mcolors
S = ensure_any_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
pool = {_name(ed, i): ed for i, ed in enumerate(_iter_items(S))}
if not pool:
raise RuntimeError("no sites")
st = station if station is not None else sorted(pool)[0]
ed = pool.get(st)
if ed is None:
raise RuntimeError(f"station not found: {st!r}")
_, t, fr = _get_t_block(ed)
if ax is None:
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111, polar=True)
if t is None or fr is None:
hide_polar_radius_labels(ax)
ax.set_title("no tipper")
return ax
tx, ty = t[:, 0], t[:, 1]
u, v = (
(np.real(tx), np.real(ty))
if component == "real"
else (np.imag(tx), np.imag(ty))
)
azimuth = np.arctan2(v, u)
magnitude = np.rad2deg(np.arctan(np.hypot(u, v)))
per = 1.0 / fr
log_per = np.log10(np.maximum(per, 1e-9))
norm = mcolors.Normalize(vmin=log_per.min(), vmax=log_per.max())
sc = ax.scatter(
azimuth,
magnitude,
c=log_per,
cmap=cmap,
norm=norm,
s=30,
edgecolors="none",
zorder=4,
)
order = np.argsort(per)
ax.plot(
azimuth[order], magnitude[order], lw=1.0, alpha=0.4, color="0.6"
)
ax.set_theta_zero_location("N")
ax.set_theta_direction(-1)
ax.grid(True, alpha=0.3)
hide_polar_radius_labels(ax)
ax.set_title(f"{st} — AFMAG tilt polar [{component}]", fontsize=10)
cbar = add_polar_colorbar(sc, ax, label=r"$\log_{10}T$ (s)")
cbar.ax.tick_params(labelsize=7)
return ax
[docs]
def plot_airmt_tilt_profile(
sites: Any,
*,
component: str = "real",
frequency_hz: float | None = None,
period_s: float | None = None,
figsize: tuple[float, float] = (9.5, 4.0),
station_label_step: int | None = 1,
station_preset: str = "pseudosection",
station_style: Any | None = None,
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot the tensor AFMAG/AirMt flight-line tilt-angle profile.
Direct AirMt-tensor analogue of :func:`plot_afmag_tilt_profile`;
see :func:`airmt_tilt_angles` for the underlying table.
Parameters
----------
sites : AirborneSites-like
Anything accepted by
:func:`~pycsamt.airborne.site.ensure_asites`.
component : {"real", "imag", "resultant"}, default "real"
frequency_hz, period_s : float, optional
Reference frequency/period; nearest available value is used
per station. At most one may be given; the median frequency
across all stations is used when neither is given.
figsize : (float, float), default (9.5, 4.0)
Used only when *ax* is not supplied.
station_label_step, station_preset, station_style
Forwarded to
:meth:`~pycsamt.api.station.PyCSAMTStationRendering.style_for`
via :func:`_apply_station_rendering`.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
table = airmt_tilt_angles(sites)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if table.empty:
ax.text(0.5, 0.5, "no interstation tensor data", ha="center",
va="center")
return ax
col = {
"real": "tilt_real_deg",
"imag": "tilt_imag_deg",
"resultant": "tilt_resultant_deg",
}.get(component, "tilt_real_deg")
if frequency_hz is None and period_s is not None:
frequency_hz = 1.0 / max(float(period_s), 1e-24)
if frequency_hz is None:
frequency_hz = float(table["freq"].median())
stations = list(dict.fromkeys(table["station"]))
values = []
used_freqs = []
for station in stations:
sub = table[table["station"] == station]
idx = (sub["freq"] - frequency_hz).abs().idxmin()
values.append(float(sub.loc[idx, col]))
used_freqs.append(float(sub.loc[idx, "freq"]))
x = np.arange(len(stations))
_ml = PYCSAMT_STYLE.multiline
ax.axhline(0.0, color="0.85", lw=0.8)
ax.plot(x, values, lw=_ml.lw, alpha=_ml.alpha, marker="o")
_apply_station_rendering(
ax,
stations,
station_label_step=station_label_step,
station_preset=station_preset,
station_style=station_style,
)
ref = float(np.nanmedian(used_freqs))
ax.set_ylabel(f"AirMt tilt angle ({component}), deg")
ax.set_title(f"AirMt tilt profile at {ref:.4g} Hz", fontsize=10)
ax.grid(True, ls=":", alpha=0.35)
return ax
[docs]
def plot_original_afmag_tilt_profile(
sites: Any,
*,
frequency_hz: float | None = None,
period_s: float | None = None,
figsize: tuple[float, float] = (9.5, 4.0),
station_label_step: int | None = 1,
station_preset: str = "pseudosection",
station_style: Any | None = None,
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot the original comparator AFMAG flight-line tilt profile.
Direct original-comparator analogue of
:func:`plot_afmag_tilt_profile`; see
:func:`original_afmag_tilt_table` for the underlying table. There
is no ``component`` argument here (unlike
:func:`plot_afmag_tilt_profile`/:func:`plot_airmt_tilt_profile`):
the historical measurement is a single real number per frequency,
not a complex pair with a real/imaginary/resultant split.
Parameters
----------
sites : AirborneSites-like
Anything accepted by
:func:`~pycsamt.airborne.site.ensure_asites`.
frequency_hz, period_s : float, optional
Reference frequency/period; nearest available value is used
per station. At most one may be given; the median frequency
across all stations is used when neither is given.
figsize : (float, float), default (9.5, 4.0)
Used only when *ax* is not supplied.
station_label_step, station_preset, station_style
Forwarded to
:meth:`~pycsamt.api.station.PyCSAMTStationRendering.style_for`
via :func:`_apply_station_rendering`.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
table = original_afmag_tilt_table(sites)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if table.empty:
ax.text(0.5, 0.5, "no original AFMAG tilt data", ha="center",
va="center")
return ax
if frequency_hz is None and period_s is not None:
frequency_hz = 1.0 / max(float(period_s), 1e-24)
if frequency_hz is None:
frequency_hz = float(table["freq"].median())
stations = list(dict.fromkeys(table["station"]))
values = []
used_freqs = []
for station in stations:
sub = table[table["station"] == station]
idx = (sub["freq"] - frequency_hz).abs().idxmin()
values.append(float(sub.loc[idx, "tilt_deg"]))
used_freqs.append(float(sub.loc[idx, "freq"]))
x = np.arange(len(stations))
_ml = PYCSAMT_STYLE.multiline
ax.axhline(0.0, color="0.85", lw=0.8)
ax.plot(
x, values, lw=_ml.lw, alpha=_ml.alpha, marker="o", color="tab:blue"
)
_apply_station_rendering(
ax,
stations,
station_label_step=station_label_step,
station_preset=station_preset,
station_style=station_style,
)
ref = float(np.nanmedian(used_freqs))
ax.set_ylabel("Original AFMAG tilt angle (deg)")
ax.set_title(
f"Original comparator AFMAG tilt profile at {ref:.4g} Hz",
fontsize=10,
)
ax.grid(True, ls=":", alpha=0.35)
return ax
[docs]
def original_afmag_conductor_diagnostics(
sites: Any,
*,
freq_low_hz: float | None = None,
freq_high_hz: float | None = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> dict[str, Any]:
r"""Ward (1959)-style dual-frequency conductor diagnostics.
Reproduces the two semi-quantitative measurements Ward's own
worked example computes from a dual-frequency tilt profile
(Ward 1959, Fig. 16 and Results section (a)-(b)):
* the along-line **crossover position** at each frequency -- the
station-to-station zero-crossing of the signed
:func:`original_afmag_tilt_table` tilt angle nearest the
profile's own peak-to-trough swing, i.e. the conductor axis --
and the **shift** between the two frequencies' crossovers,
which Ward reads as evidence of inhomogeneity along the
conductor;
* the **peak-to-peak amplitude ratio** between the two
frequencies, :math:`(\max-\min)_{\text{low}} /
(\max-\min)_{\text{high}}`, Ward's semi-quantitative
conductivity indicator (his own worked example on a
pyrite-pyrrhotite body gives :math:`0.74`; values range from 0,
very poor, toward 1, excellent).
Ward's further estimates -- depth to the top of the conductor
from the crossover slope, depth extent from the profile's
beyond-peak slope, and dip from profile asymmetry -- all require
a scale-model calibration curve that the paper does not give in
closed form, so, matching this module's stated policy of not
inventing a formula the cited literature does not itself supply,
they are not computed here.
Parameters
----------
sites : AirborneSites-like
Anything accepted by
:func:`~pycsamt.airborne.site.ensure_asites`.
freq_low_hz, freq_high_hz : float, optional
The two frequencies to compare. Default to the minimum and
maximum frequency actually present (Ward's own instrument
always operated 150 and 510 Hz together, but this stays
general rather than hardcoding that pair).
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.airborne.site.ensure_asites`.
Returns
-------
dict
Keys: ``freq_low_hz``, ``freq_high_hz``, ``crossover_low_m``,
``crossover_high_m`` (along-line position in metres, ``nan``
if the profile does not change sign), ``crossover_shift_m``
(``crossover_high_m - crossover_low_m``),
``peak_to_peak_low``, ``peak_to_peak_high``,
``peak_to_peak_ratio`` (degrees; ``low / high``), and
``profile`` -- a :class:`pandas.DataFrame` with columns
``station``, ``position_m``, ``tilt_low_deg``,
``tilt_high_deg``, the shared input the companion plot
function draws from.
Raises
------
ValueError
If fewer than 2 distinct frequencies are present, or a
requested *freq_low_hz*/*freq_high_hz* is not found.
"""
from ..airborne.site import ensure_asites
from ._core import _station_positions
S = ensure_asites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
items = list(S)
table = original_afmag_tilt_table(S)
freqs_available = np.sort(table["freq"].unique()) if not table.empty else np.array([])
if freqs_available.size < 2:
raise ValueError(
"original_afmag_conductor_diagnostics requires at least 2 "
f"distinct frequencies; found {freqs_available.size}"
)
f_lo = float(freqs_available[0]) if freq_low_hz is None else float(freq_low_hz)
f_hi = float(freqs_available[-1]) if freq_high_hz is None else float(freq_high_hz)
def _nearest_freq(target: float) -> float:
idx = int(np.argmin(np.abs(freqs_available - target)))
return float(freqs_available[idx])
f_lo, f_hi = _nearest_freq(f_lo), _nearest_freq(f_hi)
positions = _station_positions(items)
pos_by_station = {
ed.name: float(positions[i]) for i, ed in enumerate(items)
}
stations = list(dict.fromkeys(table["station"]))
rows = []
for station in stations:
sub = table[table["station"] == station]
row = {"station": station, "position_m": pos_by_station.get(station, np.nan)}
for label, f0 in (("tilt_low_deg", f_lo), ("tilt_high_deg", f_hi)):
idx = (sub["freq"] - f0).abs().idxmin()
row[label] = float(sub.loc[idx, "tilt_deg"])
rows.append(row)
profile = pd.DataFrame(rows).sort_values("position_m").reset_index(drop=True)
def _crossover(x: np.ndarray, y: np.ndarray) -> float:
if x.size < 2 or not np.any(np.isfinite(y)):
return float("nan")
i_max, i_min = int(np.nanargmax(y)), int(np.nanargmin(y))
lo, hi = (i_max, i_min) if i_max < i_min else (i_min, i_max)
for i in range(lo, hi):
y0, y1 = y[i], y[i + 1]
if not (np.isfinite(y0) and np.isfinite(y1)):
continue
if y0 == 0.0:
return float(x[i])
if np.sign(y0) != np.sign(y1):
t = y0 / (y0 - y1)
return float(x[i] + t * (x[i + 1] - x[i]))
return float("nan")
x = profile["position_m"].to_numpy()
y_lo = profile["tilt_low_deg"].to_numpy()
y_hi = profile["tilt_high_deg"].to_numpy()
cross_lo = _crossover(x, y_lo)
cross_hi = _crossover(x, y_hi)
pp_lo = float(np.nanmax(y_lo) - np.nanmin(y_lo)) if y_lo.size else float("nan")
pp_hi = float(np.nanmax(y_hi) - np.nanmin(y_hi)) if y_hi.size else float("nan")
return {
"freq_low_hz": f_lo,
"freq_high_hz": f_hi,
"crossover_low_m": cross_lo,
"crossover_high_m": cross_hi,
"crossover_shift_m": (
cross_hi - cross_lo
if np.isfinite(cross_lo) and np.isfinite(cross_hi)
else float("nan")
),
"peak_to_peak_low": pp_lo,
"peak_to_peak_high": pp_hi,
"peak_to_peak_ratio": (
pp_lo / pp_hi if pp_hi not in (0.0, float("nan")) else float("nan")
),
"profile": profile,
}
[docs]
def plot_original_afmag_dual_frequency_profile(
sites: Any,
*,
freq_low_hz: float | None = None,
freq_high_hz: float | None = None,
figsize: tuple[float, float] = (9.5, 4.5),
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Ward (1959) Fig. 16-style dual-frequency tilt-angle profile.
Plots both frequencies together along real chainage -- the
classic AFMAG field product, not a single-frequency curve -- and
marks the crossover ("axis of conductor") at each frequency using
:func:`original_afmag_conductor_diagnostics`, the same
interpretation Ward's own worked example applies.
Parameters
----------
sites : AirborneSites-like
Anything accepted by
:func:`~pycsamt.airborne.site.ensure_asites`.
freq_low_hz, freq_high_hz : float, optional
Forwarded to :func:`original_afmag_conductor_diagnostics`.
figsize : (float, float), default (9.5, 4.5)
Used only when *ax* is not supplied.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
if ax is None:
_, ax = plt.subplots(figsize=figsize)
try:
diag = original_afmag_conductor_diagnostics(
sites, freq_low_hz=freq_low_hz, freq_high_hz=freq_high_hz,
)
except ValueError:
ax.text(
0.5, 0.5, "no original AFMAG tilt data", ha="center",
va="center",
)
return ax
profile = diag["profile"]
x = profile["position_m"].to_numpy()
ax.axhline(0.0, color="0.85", lw=0.8)
ax.plot(
x, profile["tilt_low_deg"], "-", color="tab:blue", marker="o",
ms=4, label=f"{diag['freq_low_hz']:.0f} Hz",
)
ax.plot(
x, profile["tilt_high_deg"], "--", color="tab:red", marker="x",
ms=5, label=f"{diag['freq_high_hz']:.0f} Hz",
)
for cross, color in (
(diag["crossover_low_m"], "tab:blue"),
(diag["crossover_high_m"], "tab:red"),
):
if np.isfinite(cross):
ax.axvline(cross, color=color, lw=1.0, ls=":", alpha=0.7)
if np.isfinite(diag["crossover_low_m"]):
ax.annotate(
"axis of\nconductor",
xy=(diag["crossover_low_m"], 0.0),
xytext=(0, 14),
textcoords="offset points",
ha="center",
fontsize=8,
)
ax.set_xlabel("Position along line (m)")
ax.set_ylabel("Tilt angle (deg)")
ratio = diag["peak_to_peak_ratio"]
ratio_txt = f"{ratio:.2f}" if np.isfinite(ratio) else "n/a"
ax.set_title(
"Original comparator AFMAG dual-frequency profile "
f"(peak-to-peak ratio {ratio_txt})",
fontsize=10,
)
ax.legend(fontsize=8, loc="best")
ax.grid(True, ls=":", alpha=0.35)
return ax
[docs]
def plot_airmt_tilt_psection(
sites: Any,
*,
component: str = "resultant",
cmap: str = "RdYlBu_r",
clim: tuple[float, float] | None = None,
clim_pct: tuple[float, float] = (2.0, 98.0),
show_grid: bool = True,
show_contour: bool = True,
n_contour_levels: int = 3,
figsize: tuple[float, float] = (9.0, 5.0),
station_label_step: int | None = 1,
station_preset: str = "pseudosection",
station_style: Any | None = None,
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Plot an AirMt tilt-angle pseudosection (station x log-period).
Direct AirMt-tensor analogue of :func:`plot_afmag_tilt_psection`,
reading :func:`airmt_tilt_angles` instead -- the same
station-x-log-period layout every other airborne technology in
this project already gets
(:func:`~pycsamt.emtools.ztem.plot_ztem_divergence_psection`,
:func:`~pycsamt.emtools.mobilemt.plot_mobilemt_conductivity_psection`),
closing the one real gap left when only a single-frequency profile
(:func:`plot_airmt_tilt_profile`) existed for this technology.
The underlying image stays a plain :func:`~matplotlib.pyplot.imshow`
grid (matching the rest of this module's psection functions), with
two optional overlays matching the cell-boundary/level-line
convention already used for :mod:`pycsamt.emtools.fieldzone`'s own
``imshow``/``contour`` pseudosections: cell-boundary gridlines
(*show_grid*) and white contour level lines with inline labels
(*show_contour*) tracing the same imaged field.
Parameters
----------
sites : AirborneSites-like
Anything accepted by
:func:`~pycsamt.airborne.site.ensure_asites`.
component : {"real", "imag", "resultant"}, default "resultant"
Which :func:`airmt_tilt_angles` column to image.
cmap : str, default "RdYlBu_r"
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.
show_grid : bool, default True
Draw thin gridlines at every station/period cell boundary.
show_contour : bool, default True
Overlay *n_contour_levels* - 2 evenly-spaced contour lines
(dropping the two outermost levels, which would otherwise
hug the colour-scale edges) with inline value labels, tracing
the same imaged field -- a visual aid for the broad trend on
top of a necessarily coarse, noisy station/period grid, not a
claim of smooth structure between stations.
n_contour_levels : int, default 3
Number of evenly-spaced levels spanning the image's own
colour range before dropping the two outermost; must be at
least 3 for any contour line to be drawn.
figsize : (float, float), default (9.0, 5.0)
Used only when *ax* is not supplied.
station_label_step, station_preset, station_style
See :func:`plot_afmag_tilt_profile`.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
table = airmt_tilt_angles(sites)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if table.empty:
ax.text(
0.5, 0.5, "no interstation tensor data", ha="center",
va="center",
)
return ax
col = {
"real": "tilt_real_deg",
"imag": "tilt_imag_deg",
"resultant": "tilt_resultant_deg",
}.get(component, "tilt_resultant_deg")
stations = list(dict.fromkeys(table["station"]))
freqs = np.sort(table["freq"].unique())[::-1]
grid = np.full((freqs.size, len(stations)), np.nan, dtype=float)
for j, station in enumerate(stations):
sub = table[table["station"] == station]
for _, row in sub.iterrows():
i = int(np.argmin(np.abs(freqs - row["freq"])))
grid[i, j] = row[col]
if clim is None:
finite = grid[np.isfinite(grid)]
vmin, vmax = (
np.percentile(finite, clim_pct) if finite.size else (0.0, 1.0)
)
else:
vmin, vmax = float(clim[0]), float(clim[1])
extent = (
-0.5,
len(stations) - 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,
)
ax.set_ylabel(LOG10_PERIOD_LABEL)
ax.set_title(f"AirMt tilt pseudosection [{component}]", fontsize=10)
nf, ns = grid.shape
y_top, y_bottom = extent[3], extent[2]
dy = (y_top - y_bottom) / nf
y_centers = y_top - (np.arange(nf) + 0.5) * dy
if show_grid:
ax.set_xticks(np.arange(-0.5, ns, 1.0), minor=True)
ax.set_yticks(
y_top - np.arange(nf + 1) * dy, minor=True,
)
ax.grid(
which="minor", color="white", linewidth=0.6, alpha=0.6,
)
ax.tick_params(which="minor", length=0)
if show_contour and n_contour_levels >= 3 and nf >= 2 and ns >= 2:
finite = grid[np.isfinite(grid)]
if finite.size:
levels = np.linspace(
float(finite.min()), float(finite.max()), n_contour_levels,
)[1:-1]
if levels.size:
xx, yy = np.meshgrid(np.arange(ns), y_centers)
masked = np.ma.masked_invalid(grid)
cs = ax.contour(
xx, yy, masked, levels=levels, colors="white",
linewidths=0.8,
)
ax.clabel(cs, fmt="%.1f", fontsize=6, inline=True)
_apply_station_rendering(
ax,
stations,
station_label_step=station_label_step,
station_preset=station_preset,
station_style=station_style,
)
add_colorbar(im, ax, label="tilt angle (deg)")
return ax
[docs]
def plot_motion_coupling(
yaw: Any,
pitch: Any,
roll: Any,
inclination: float,
declination: float,
*,
x: Any | None = None,
xlabel: str = "sample",
degrees: bool = True,
ax: plt.Axes | None = None,
figsize: tuple[float, float] = (8.0, 4.0),
) -> plt.Axes:
r"""Plot :math:`\theta(t)` and :math:`\cos\theta(t)` for attitude data.
Direct visualization of the paper's own Fig. 2-5 style curves,
from raw attitude arrays -- not ``Sites``-based (see the module
docstring for why).
Parameters
----------
yaw, pitch, roll : array-like
Attitude time series (or a swept parameter), same shape.
inclination, declination : float
Geomagnetic field geometry in degrees.
x : array-like, optional
X-axis values (time or sample index); defaults to a plain
integer index.
xlabel : str, default "sample"
X-axis label used when *x* is not supplied a label of its own.
degrees : bool, default True
Whether *yaw*/*pitch*/*roll* are in degrees.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on; a right-hand twin axes is added for
:math:`\cos\theta`.
figsize : (float, float), default (8.0, 4.0)
Used only when *ax* is not supplied.
Returns
-------
matplotlib.axes.Axes
The primary (:math:`\theta`) axes; the twin axes is reachable
via ``ax.figure.axes[-1]``.
"""
theta = motion_coupling_angle(
yaw, pitch, roll, inclination, declination, degrees=degrees
)
cos_theta = motion_coupling_cosine(
yaw, pitch, roll, inclination, declination, degrees=degrees
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
xs = np.arange(theta.size) if x is None else np.asarray(x)
ax.plot(xs, theta, color="tab:blue", label=r"$\theta(t)$")
ax.set_ylabel(r"$\theta(t)$ (deg)", color="tab:blue")
ax.tick_params(axis="y", labelcolor="tab:blue")
ax.set_xlabel(xlabel if x is None else "")
twin = ax.twinx()
twin.plot(xs, cos_theta, color="tab:red", label=r"$\cos\theta(t)$")
twin.set_ylabel(r"$\cos\theta(t)$", color="tab:red")
twin.tick_params(axis="y", labelcolor="tab:red")
ax.set_title("Motion-coupling angle (Liu et al. 2018)", fontsize=10)
ax.grid(True, ls=":", alpha=0.35)
return ax
[docs]
def plot_motion_susceptibility_map(
sites: Any,
*,
inclination: float,
declination: float,
roll_amplitude_deg: float,
pitch_amplitude_deg: float,
yaw_amplitude_deg: float = 0.0,
cmap: str = "magma_r",
figsize: tuple[float, float] = (7.0, 5.5),
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Map each station's motion-noise susceptibility score.
Uses station coordinates when available
(:attr:`~pycsamt.site.base.Site.coords`), otherwise falls back to
a station-index profile -- the same fallback strategy
:mod:`pycsamt.emtools.tf` uses for stations with no geo-referencing.
Parameters
----------
sites : Sites-like
inclination, declination : float
Forwarded to :func:`motion_susceptibility_table`.
roll_amplitude_deg, pitch_amplitude_deg : float
Forwarded to :func:`motion_susceptibility_table`.
yaw_amplitude_deg : float, default 0.0
Forwarded to :func:`motion_susceptibility_table`.
cmap : str, default "magma_r"
figsize : (float, float), default (7.0, 5.5)
Used only when *ax* is not supplied.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
Returns
-------
matplotlib.axes.Axes
"""
S = ensure_any_sites(sites, recursive=True, strict=False)
table = motion_susceptibility_table(
S,
inclination=inclination,
declination=declination,
roll_amplitude_deg=roll_amplitude_deg,
pitch_amplitude_deg=pitch_amplitude_deg,
yaw_amplitude_deg=yaw_amplitude_deg,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if table.empty:
ax.text(0.5, 0.5, "no sites", ha="center", va="center")
return ax
xs, ys = [], []
has_coords = True
for i, ed in enumerate(_iter_items(S)):
coord = _station_coordinates(ed)
if coord is None:
has_coords = False
xs.append(float(i))
ys.append(0.0)
else:
lat, lon = coord
xs.append(lon)
ys.append(lat)
sc = ax.scatter(
xs,
ys,
c=table["susceptibility_score"],
cmap=cmap,
s=80,
edgecolors="k",
linewidths=0.5,
)
ax.set_xlabel("Longitude" if has_coords else "Station index")
ax.set_ylabel("Latitude" if has_coords else "")
ax.set_title("AFMAG motion-noise susceptibility", fontsize=10)
add_colorbar(sc, ax, label=r"$\cos\theta$ swing (susceptibility)")
return ax
[docs]
def plot_afmag_correction_comparison(
before_sites: Any,
after_sites: Any | None = None,
*,
component: str = "resultant",
inclination: float | None = None,
declination: float | None = None,
roll_amplitude_deg: float | None = None,
pitch_amplitude_deg: float | None = None,
yaw_amplitude_deg: float = 0.0,
cmap: str = "RdYlBu_r",
delta_cmap: str = "RdBu_r",
figsize: tuple[float, float] = (11.0, 8.2),
axes: Any | None = None,
**flag_kws: Any,
) -> plt.Figure:
r"""Plot before/after/delta AFMAG tilt pseudosections.
Structurally identical to
:func:`~pycsamt.emtools.remove_noise.plot_emap_filter_psection`'s
triptych: before, after, and :math:`\Delta` (after - before)
panels. If *after_sites* is omitted, it is computed by calling
:func:`flag_motion_susceptible_band` on *before_sites* with
*inclination*, *declination*, *roll_amplitude_deg*, and
*pitch_amplitude_deg* (all then required).
Parameters
----------
before_sites : Sites-like
after_sites : Sites-like, optional
Already-corrected sites; computed from *before_sites* when
omitted.
component : {"real", "imag", "resultant"}, default "resultant"
inclination, declination : float, optional
Forwarded to :func:`flag_motion_susceptible_band` when
*after_sites* is omitted; required in that case.
roll_amplitude_deg, pitch_amplitude_deg : float, optional
Forwarded to :func:`flag_motion_susceptible_band` when
*after_sites* is omitted; required in that case.
yaw_amplitude_deg : float, default 0.0
Forwarded to :func:`flag_motion_susceptible_band` when
*after_sites* is omitted.
cmap, delta_cmap : str
Colormaps for the before/after and delta panels.
figsize : (float, float), default (11.0, 8.2)
Used only when *axes* is not supplied.
axes : sequence of 3 Axes, optional
Existing axes (before, after, delta) to draw on.
**flag_kws
Forwarded to :func:`flag_motion_susceptible_band` (for example
``band_hz``, ``threshold``, ``action``).
Returns
-------
matplotlib.Figure
Raises
------
ValueError
If *after_sites* is omitted and *inclination*, *declination*,
*roll_amplitude_deg*, or *pitch_amplitude_deg* is not given.
"""
if after_sites is None:
required = {
"inclination": inclination,
"declination": declination,
"roll_amplitude_deg": roll_amplitude_deg,
"pitch_amplitude_deg": pitch_amplitude_deg,
}
missing = [k for k, v in required.items() if v is None]
if missing:
raise ValueError(
"after_sites was not given, so "
f"{', '.join(missing)} must be; got None"
)
after_sites = flag_motion_susceptible_band(
before_sites,
inclination=inclination,
declination=declination,
roll_amplitude_deg=roll_amplitude_deg,
pitch_amplitude_deg=pitch_amplitude_deg,
yaw_amplitude_deg=yaw_amplitude_deg,
inplace=False,
**flag_kws,
)
col = {
"real": "tilt_real_deg",
"imag": "tilt_imag_deg",
"resultant": "tilt_resultant_deg",
}.get(component, "tilt_resultant_deg")
before = afmag_tilt_angles(before_sites)
after = afmag_tilt_angles(after_sites)
axes_given = _axes_list(axes, 3) if axes is not None else None
if axes_given is None:
fig, axes_arr = plt.subplots(
3, 1, figsize=figsize, sharex=True,
gridspec_kw={"hspace": 0.24},
)
axes_arr = np.asarray(axes_arr, dtype=object).ravel()
else:
axes_arr = np.asarray(axes_given, dtype=object)
fig = axes_arr[0].figure
if before.empty or after.empty:
axes_arr[1].text(
0.5, 0.5, "no paired data", ha="center", va="center"
)
return fig
stations = [
s for s in dict.fromkeys(before["station"])
if s in set(after["station"])
]
freqs = np.sort(before["freq"].unique())[::-1]
def _grid(table: pd.DataFrame) -> np.ndarray:
g = np.full((freqs.size, len(stations)), np.nan, dtype=float)
for j, station in enumerate(stations):
sub = table[table["station"] == station]
for _, row in sub.iterrows():
i = int(np.argmin(np.abs(freqs - row["freq"])))
g[i, j] = row[col]
return g
before_grid = _grid(before)
after_grid = _grid(after)
delta_grid = after_grid - before_grid
finite = np.concatenate([before_grid.ravel(), after_grid.ravel()])
finite = finite[np.isfinite(finite)]
vmin, vmax = (
np.percentile(finite, (2.0, 98.0)) if finite.size else (0.0, 1.0)
)
dvals = np.abs(delta_grid[np.isfinite(delta_grid)])
dvlim = max(
float(np.nanpercentile(dvals, 95.0)) if dvals.size else 1e-6,
1e-6,
)
extent = (
-0.5,
len(stations) - 0.5,
np.log10(1.0 / freqs[0]),
np.log10(1.0 / freqs[-1]),
)
panels = [
(before_grid, axes_arr[0], cmap, vmin, vmax, "Before"),
(after_grid, axes_arr[1], cmap, vmin, vmax, "After"),
(delta_grid, axes_arr[2], delta_cmap, -dvlim, dvlim, r"$\Delta$"),
]
images = []
for data, ax, cm, lo, hi, title in panels:
im = ax.imshow(
data,
aspect="auto",
origin="upper",
interpolation="nearest",
cmap=cm,
vmin=lo,
vmax=hi,
extent=extent,
)
images.append(im)
ax.set_ylabel(LOG10_PERIOD_LABEL)
ax.set_title(title, fontsize=9)
_apply_station_rendering(axes_arr[0], stations)
for ax in axes_arr[1:]:
ax.tick_params(
axis="x", labeltop=False, labelbottom=False, top=False,
bottom=False,
)
cb_main = fig.colorbar(
images[0], ax=[axes_arr[0], axes_arr[1]], fraction=0.018, pad=0.012,
)
cb_main.set_label(f"AFMAG tilt ({component}), deg")
cb_delta = fig.colorbar(
images[2], ax=axes_arr[2], fraction=0.018, pad=0.012,
)
cb_delta.set_label(r"$\Delta$ tilt (deg)")
return fig
def _apply_station_rendering(
ax: plt.Axes,
labels: list[str],
*,
station_label_step: int | None = 1,
station_preset: str = "pseudosection",
station_style: Any | None = None,
) -> None:
"""Apply the shared top-of-section station rendering convention."""
import copy
style = station_style or PYCSAMT_STATION_RENDERING.style_for(
station_preset
)
style = copy.copy(style)
style.side = "top"
style.max_labels = max(int(style.max_labels), len(labels))
style.every = (
1 if station_label_step is None else int(station_label_step)
)
x = np.arange(len(labels), dtype=float)
style.apply(ax, x, labels, xlim=(-0.5, len(labels) - 0.5))