# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0-or-later
from __future__ import annotations
from collections.abc import Iterable
import numpy as np
__all__ = [
"MU0",
"rotate_impedance",
"rotate_tipper",
"z_to_rho_phi",
"rho_phi_to_z",
"time_vector",
"coherence_ms",
"pack_hermitian",
"unpack_hermitian",
"rotate_spectra",
"amp_or_psd",
"synthesize_spectra_from_z",
]
# magnetic permeability of free space [H/m]
MU0: float = 4.0e-7 * np.pi
def _rot_mat(theta_deg: float | np.ndarray) -> np.ndarray:
"""2-D rotation matrix R(θ) with our SEG axis convention.
R = [[ cosθ, sinθ],
[-sinθ, cosθ]]
"""
th = np.asarray(theta_deg, float) * np.pi / 180.0
c = np.cos(th)
s = np.sin(th)
R = np.empty((2, 2) + th.shape, float)
R[0, 0] = c
R[0, 1] = s
R[1, 0] = -s
R[1, 1] = c
return np.moveaxis(R, (2, 3), (0, 1)) if R.ndim > 2 else R
[docs]
def rotate_impedance(
z: np.ndarray,
theta_deg: float | np.ndarray,
) -> np.ndarray:
"""Rotate 2x2 impedance per freq: Z' = R Z R^T.
Shapes supported:
- z: (nfreq, 2, 2) or (2, 2)
- theta: scalar or (nfreq,)
"""
Z = np.asarray(z)
if Z.ndim == 2:
Z = Z[np.newaxis, ...]
n = Z.shape[0]
th = np.asarray(theta_deg, float)
if th.ndim == 0:
th = np.full(n, float(th))
R = np.stack([_rot_mat(t) for t in th], axis=0)
Rt = np.swapaxes(R, -1, -2)
return (R @ Z @ Rt).squeeze()
[docs]
def rotate_tipper(
t: np.ndarray,
theta_deg: float | np.ndarray,
) -> np.ndarray:
"""Rotate tipper horizontal vector(s): t' = R t.
Accepts:
- t: (nfreq, 2) or (nfreq, 1, 2) or (2,)
- theta: scalar or (nfreq,)
"""
T = np.asarray(t)
if T.ndim == 3 and T.shape[1] == 1:
T = T[:, 0, :]
if T.ndim == 1:
T = T[np.newaxis, ...]
n = T.shape[0]
th = np.asarray(theta_deg, float)
if th.ndim == 0:
th = np.full(n, float(th))
R = np.stack([_rot_mat(t0) for t0 in th], axis=0)
out = (R @ T[..., np.newaxis]).squeeze(-1)
return out.squeeze()
[docs]
def z_to_rho_phi(
z: np.ndarray,
freq: np.ndarray | Iterable[float],
*,
mu0: float = MU0,
) -> tuple[np.ndarray, np.ndarray]:
"""Component-wise apparent resistivity and phase from Z.
ρ = |Z|^2 / (μ0 ω) ; φ = atan2(Im(Z), Re(Z)) [deg]
"""
Z = np.asarray(z)
if Z.ndim == 2:
Z = Z[np.newaxis, ...]
f = np.asarray(freq, float).reshape(-1)
if f.size != Z.shape[0]:
raise ValueError("freq and Z length mismatch")
w = 2.0 * np.pi * f
mag2 = Z.real**2 + Z.imag**2
rho = mag2 / (mu0 * w)[:, None, None]
phi = np.degrees(np.arctan2(Z.imag, Z.real))
return rho.squeeze(), phi.squeeze()
[docs]
def rho_phi_to_z(
rho: np.ndarray,
phi_deg: np.ndarray,
freq: np.ndarray | Iterable[float],
*,
mu0: float = MU0,
) -> np.ndarray:
"""Build Z from ρ, φ, and freq (per component)."""
R = np.asarray(rho, float)
P = np.asarray(phi_deg, float)
if R.ndim == 2:
R = R[np.newaxis, ...]
P = P[np.newaxis, ...]
f = np.asarray(freq, float).reshape(-1)
if f.size != R.shape[0]:
raise ValueError("freq and rho length mismatch")
w = 2.0 * np.pi * f
mag = np.sqrt(R * (mu0 * w)[:, None, None])
ph = np.radians(P)
Z = mag * (np.cos(ph) + 1j * np.sin(ph))
return Z.squeeze()
[docs]
def time_vector(npts: int, dt: float) -> np.ndarray:
"""Evenly spaced time vector [s] from N and Δt."""
n = int(max(0, npts))
return np.arange(n, dtype=float) * float(dt)
[docs]
def coherence_ms(
Sxy: np.ndarray,
Sxx: np.ndarray,
Syy: np.ndarray,
) -> np.ndarray:
"""Magnitude-squared coherency γ^2."""
Sxy = np.asarray(Sxy)
Sxx = np.asarray(Sxx)
Syy = np.asarray(Syy)
num = np.abs(Sxy) ** 2
den = np.abs(Sxx) * np.abs(Syy)
with np.errstate(divide="ignore", invalid="ignore"):
out = np.where(den > 0.0, num / den, 0.0)
return np.clip(out, 0.0, 1.0)
[docs]
def pack_hermitian(C: np.ndarray) -> np.ndarray:
"""Pack Hermitian NxN into upper-triangular vector.
Output length = N*(N+1)/2, complex dtype preserved.
"""
A = np.asarray(C)
if A.ndim != 2 or A.shape[0] != A.shape[1]:
raise ValueError("C must be square (N x N)")
n = A.shape[0]
idx = np.triu_indices(n)
return A[idx]
[docs]
def unpack_hermitian(v: np.ndarray, n: int) -> np.ndarray:
"""Unpack upper-triangular vector into Hermitian matrix."""
v = np.asarray(v)
tri_len = n * (n + 1) // 2
if v.size != tri_len:
raise ValueError("vector size incompatible with n")
A = np.zeros((n, n), complex)
iu = np.triu_indices(n)
A[iu] = v
# mirror to lower triangle (conjugate symmetry)
il = (iu[1], iu[0])
A[il] = np.conjugate(A[iu])
# ensure diagonal is real-valued
d = np.diag_indices(n)
A[d] = np.real(A[d])
return A
[docs]
def rotate_spectra(
C: np.ndarray,
theta_deg: float,
) -> np.ndarray:
"""Rotate cross-power matrix: C' = R C Rᵀ (per freq)."""
A = np.asarray(C)
if A.ndim == 2:
A = A[np.newaxis, ...]
n = A.shape[0]
R = _rot_mat(theta_deg)
if R.ndim == 2:
R = np.broadcast_to(R, (n, 2, 2))
Rt = np.swapaxes(R, -1, -2)
return (R @ A @ Rt).squeeze()
[docs]
def amp_or_psd(
x: np.ndarray,
fs: float,
*,
mode: str = "amp",
) -> tuple[np.ndarray, np.ndarray]:
"""Return (f, y) where y is amplitude or PSD.
mode="amp": |X(f)| / N
mode="psd": (|X(f)|^2) / (fs * N)
"""
sig = np.asarray(x, float).ravel()
N = sig.size
if N == 0:
return np.asarray([]), np.asarray([])
X = np.fft.rfft(sig)
f = np.fft.rfftfreq(N, d=1.0 / float(fs))
if mode.lower() == "psd":
y = (np.abs(X) ** 2) / (float(fs) * N)
else:
y = np.abs(X) / max(1, N)
return f, y
[docs]
def synthesize_spectra_from_z(
Z: np.ndarray,
*,
S_HH: np.ndarray | None = None,
H_psd: tuple[np.ndarray, np.ndarray, np.ndarray | None] | None = None,
tipper: np.ndarray | None = None, # shape (nf,1,2) or (nf,2) accepted
include_hz: bool = False,
chan_order: tuple[str, ...] = ("HX", "HY", "EX", "EY"),
e_noise: float | np.ndarray | None = None,
h_noise: float | np.ndarray | None = None,
) -> tuple[np.ndarray, list[str]]:
r"""
Synthesize a full Hermitian cross–spectral tensor from an
impedance tensor ``Z(f)`` and optional tipper.
Given electric–magnetic relation ``E = Z H``, blocks are:
* ``S_EH = Z S_HH``
* ``S_EE = Z S_HH Z^H``
If a horizontal tipper ``T`` (TX, TY) is supplied and HZ is
requested, additional blocks are:
* ``S_ZH = T S_HH``, ``S_ZZ = T S_HH T^H``
* ``S_EZ = Z S_HH T^H``
If magnetic spectra are not provided, a unit–power model is
used (``S_HH = I``). The result is explicitly symmetrized
to be Hermitian.
Parameters
----------
Z : ndarray
Impedance per frequency. Shape ``(n, 2, 2)`` or
``(2, 2)``. If 2–D, it is broadcast to one
frequency.
S_HH : ndarray, optional
Magnetic spectra per frequency, shape ``(n, 2, 2)``.
Must be Hermitian. Overrides ``H_psd`` if both are
given.
H_psd : tuple of ndarray, optional
``(Pxx, Pyy, Pxy)`` where ``Pxx, Pyy`` are real
``(n,)`` and ``Pxy`` is optional complex ``(n,)``.
Used to assemble ``S_HH`` when it is not supplied.
tipper : ndarray, optional
Horizontal tipper as ``(n, 1, 2)`` or ``(n, 2)``.
Required when ``include_hz`` is ``True`` or when
``"HZ"`` is in ``chan_order``.
include_hz : bool, default False
If ``True``, add HZ and its cross–terms. Requires
``tipper``.
chan_order : tuple of str, optional
Channel order of the output spectra. Defaults to
``("HX","HY","EX","EY")``. Include ``"HZ"`` to
place the vertical magnetic channel.
e_noise : float or ndarray, optional
Diagonal noise power added to the electric block
``S_EE``. Scalar or length ``n``.
h_noise : float or ndarray, optional
Diagonal noise power added to the magnetic block
``S_HH``. Scalar or length ``n``.
Returns
-------
Sfull : ndarray
Cross–spectral tensor, shape ``(n, m, m)``, complex,
Hermitian. ``m = len(chan_order)``.
chan_ids : list of str
Channel names in the order used for ``Sfull``.
Raises
------
ValueError
If shapes are incompatible, the tipper is missing
when HZ is requested, or dimensions do not match.
Notes
-----
Absolute scaling of spectra depends on ``S_HH``. If
neither ``S_HH`` nor ``H_psd`` is provided, the unit–
power model is used. This is convenient for tests and
structure checks, but does not preserve physical power.
The function enforces Hermitian symmetry by conjugating
off–diagonal blocks and real–ifying the diagonal.
Examples
--------
Minimal, unit–power synthesis:
>>> S, order = synthesize_spectra_from_z(Z_arr)
With magnetic PSDs and tipper, including HZ:
>>> Pxx = np.full(n, 1e-2)
>>> Pyy = np.full(n, 1e-2)
>>> S, order = synthesize_spectra_from_z(
... Z_arr,
... H_psd=(Pxx, Pyy, None),
... tipper=T_arr, # (n,1,2) or (n,2)
... include_hz=True,
... chan_order=("HX","HY","HZ","EX","EY"),
... )
See Also
--------
spectra_from_Z
Builds a :class:`~pycsamt.seg.spectra.Spectra` using
this tensor.
Spectra.from_Z
Class method wrapper returning a spectra object.
Spectra.to_Z
Inverse operation (spectra → transfer functions).
References
----------
.. [1] Chave, A. D., & Jones, A. G. (2012). *The
Magnetotelluric Method: Theory and Practice*.
Cambridge Univ. Press.
.. [2] Bendat, J. S., & Piersol, A. G. (2011).
*Random Data: Analysis and Measurement
Procedures*. Wiley.
.. [3] SEG EDI MT/EMAP standard (1987). MTNet.
"""
Zm = np.asarray(Z, complex)
if Zm.ndim == 2:
Zm = Zm[np.newaxis, ...]
nf = Zm.shape[0]
# ---- S_HH assembly
if S_HH is None:
if H_psd is not None:
Pxx, Pyy, Pxy = H_psd
Pxx = np.asarray(Pxx, float).reshape(nf)
Pyy = np.asarray(Pyy, float).reshape(nf)
if Pxy is None:
Pxy = np.zeros(nf, complex)
else:
Pxy = np.asarray(Pxy, complex).reshape(nf)
S_HH = np.zeros((nf, 2, 2), complex)
S_HH[:, 0, 0] = Pxx
S_HH[:, 1, 1] = Pyy
S_HH[:, 0, 1] = Pxy
S_HH[:, 1, 0] = np.conjugate(Pxy)
else:
# unit-power default (good for tests, not absolute scaling)
S_HH = np.zeros((nf, 2, 2), complex)
S_HH[:, 0, 0] = 1.0
S_HH[:, 1, 1] = 1.0
else:
S_HH = np.asarray(S_HH, complex)
if S_HH.shape != (nf, 2, 2):
raise ValueError("S_HH must have shape (nf,2,2)")
# ---- optional diag noise
def _add_diag_noise(Sblk: np.ndarray, noise):
if noise is None:
return
if np.isscalar(noise):
Sblk[:, 0, 0] += float(noise)
Sblk[:, 1, 1] += float(noise)
else:
v = np.asarray(noise, float).reshape(nf)
Sblk[:, 0, 0] += v
Sblk[:, 1, 1] += v
# E/H and E/E
S_EH = np.einsum("fij,fjk->fik", Zm, S_HH) # Z S_HH
S_EE = np.einsum(
"fij,fjk,flk->fil", Zm, S_HH, np.conjugate(Zm)
) # Z S_HH Z^H
_add_diag_noise(S_EE, e_noise)
_add_diag_noise(S_HH, h_noise)
# Tipper / HZ (optional)
T = None
if include_hz:
if tipper is None:
raise ValueError("include_hz=True requires tipper.")
T = np.asarray(tipper, complex)
if T.ndim == 2 and T.shape[1] == 2:
T = T[:, np.newaxis, :] # (nf,1,2)
if T.shape != (nf, 1, 2):
raise ValueError("tipper must have shape (nf,1,2) or (nf,2)")
S_ZH = np.einsum("fik,fkj->fij", T, S_HH) # (nf,1,2)
S_ZZ = np.einsum(
"fik,fkj,flk->fil", T, S_HH, np.conjugate(T)
) # (nf,1,1)
S_EZ = np.einsum("fij,fkj->fik", Zm, np.conjugate(S_ZH)) # (nf,2,1)
order = [s.upper() for s in chan_order]
nchan = len(order)
Sfull = np.zeros((nf, nchan, nchan), complex)
idx = {lbl: i for i, lbl in enumerate(order)}
def _put(lbl_i, lbl_j, arr):
i = idx[lbl_i]
j = idx[lbl_j]
Sfull[:, i, j] = arr
# H/H
_put("HX", "HX", S_HH[:, 0, 0])
_put("HX", "HY", S_HH[:, 0, 1])
_put("HY", "HX", np.conjugate(S_HH[:, 0, 1]))
_put("HY", "HY", S_HH[:, 1, 1])
# E/H
_put("EX", "HX", S_EH[:, 0, 0])
_put("EX", "HY", S_EH[:, 0, 1])
_put("EY", "HX", S_EH[:, 1, 0])
_put("EY", "HY", S_EH[:, 1, 1])
# H/E (Hermitian)
_put("HX", "EX", np.conjugate(S_EH[:, 0, 0]))
_put("HY", "EX", np.conjugate(S_EH[:, 0, 1]))
_put("HX", "EY", np.conjugate(S_EH[:, 1, 0]))
_put("HY", "EY", np.conjugate(S_EH[:, 1, 1]))
# E/E
_put("EX", "EX", S_EE[:, 0, 0])
_put("EX", "EY", S_EE[:, 0, 1])
_put("EY", "EX", np.conjugate(S_EE[:, 0, 1]))
_put("EY", "EY", S_EE[:, 1, 1])
# Z blocks if requested
if "HZ" in order:
if T is None:
raise ValueError(
"HZ requested in chan_order but no tipper provided."
)
_put("HZ", "HX", S_ZH[:, 0, 0])
_put("HZ", "HY", S_ZH[:, 0, 1])
_put("HX", "HZ", np.conjugate(S_ZH[:, 0, 0]))
_put("HY", "HZ", np.conjugate(S_ZH[:, 0, 1]))
_put("HZ", "HZ", S_ZZ[:, 0, 0])
_put("EX", "HZ", S_EZ[:, 0, 0])
_put("EY", "HZ", S_EZ[:, 1, 0])
_put("HZ", "EX", np.conjugate(S_EZ[:, 0, 0]))
_put("HZ", "EY", np.conjugate(S_EZ[:, 1, 0]))
# ensure exact Hermitian symmetry on the diagonal
d = np.diag_indices(nchan)
Sfull[:, d[0], d[1]] = np.real(Sfull[:, d[0], d[1]])
return Sfull, order
def effective_dof_from_meta(
*,
segnum: int | np.ndarray | None = None,
avgt: float | np.ndarray | None = None,
bw: float | np.ndarray | None = None,
min_dof: int = 1,
) -> int | np.ndarray | None:
r"""
Estimate effective DoF (independent averages) from
per-frequency metadata. If ``segnum`` is given, use it.
Else, if both ``avgt`` and ``bw`` are finite, use
``round(avgt * bw)``. Returns ``None`` if nothing can be
inferred.
"""
if segnum is not None:
return np.asarray(segnum).astype(int)
if avgt is None or bw is None:
return None
a = np.asarray(avgt, float)
b = np.asarray(bw, float)
with np.errstate(invalid="ignore"):
m = np.rint(a * b)
m = np.where(np.isfinite(m), m, 0.0).astype(int)
if np.ndim(m) == 0:
return int(max(min_dof, m))
return np.maximum(m, int(min_dof))
def _safe_inv2(
a: np.ndarray,
ridge: float | None = None,
) -> np.ndarray:
r"""
Invert 2x2 complex matrix with optional Tikhonov
regularization. Adds ``ridge * I`` if ``ridge`` > 0.
"""
a = np.asarray(a, complex)
if ridge is not None and ridge > 0.0:
a = a + float(ridge) * np.eye(2, dtype=float)
return np.linalg.inv(a)
def z_error_from_blocks(
S_EE: np.ndarray,
S_EH: np.ndarray,
S_HH: np.ndarray,
*,
M: float | None,
ridge: float | None = None,
) -> np.ndarray:
r"""
Diagonal σ estimate for Z components from spectral
blocks. Uses Whittle/Wishart large-sample approx:
Var(Z_ij) ≈ (E_ii * G_jj) / M
with ``G = inv(S_HH) @ inv(S_HH)ᴴ`` and
``E = S_EE - S_EH @ inv(S_HH) @ S_EHᴴ``.
Returns a (2, 2) float array. Zeros if ``M`` is falsy.
"""
if M is None or M <= 0:
return np.zeros((2, 2), float)
Ginv = _safe_inv2(S_HH, ridge=ridge)
E = S_EE - S_EH @ Ginv @ np.conjugate(S_EH.T)
gdiag = np.diag(Ginv @ np.conjugate(Ginv.T)).real
ediag = np.diag(E).real
out = np.zeros((2, 2), float)
for i in range(2):
for j in range(2):
v = (ediag[i] * gdiag[j]) / float(M)
out[i, j] = np.sqrt(max(0.0, float(v)))
return out
def tipper_error_from_blocks(
S_ZH: np.ndarray,
S_ZZ: np.ndarray,
S_HH: np.ndarray,
*,
M: float | None,
ridge: float | None = None,
) -> np.ndarray:
r"""
Diagonal σ estimate for tipper components. Uses
Var(T_j) ≈ (Ezz * G_jj) / M
with ``Ezz = S_ZZ - S_ZH @ inv(S_HH) @ S_ZHᴴ`` and
``G = inv(S_HH) @ inv(S_HH)ᴴ``. Returns shape (1, 2).
Zeros if ``M`` is falsy.
"""
if M is None or M <= 0:
return np.zeros((1, 2), float)
Ginv = _safe_inv2(S_HH, ridge=ridge)
# ensure scalar (1x1) for S_ZZ
S_ZZ = np.asarray(S_ZZ, complex)
if S_ZZ.shape == ():
S_ZZ = S_ZZ.reshape(1, 1)
Ezz = S_ZZ - S_ZH @ Ginv @ np.conjugate(S_ZH.T)
ezz = float(np.real(Ezz.squeeze()))
gdiag = np.diag(Ginv @ np.conjugate(Ginv.T)).real
out = np.zeros((1, 2), float)
for j in range(2):
v = (ezz * gdiag[j]) / float(M)
out[0, j] = np.sqrt(max(0.0, float(v)))
return out
def compute_errors_from_S(
S: np.ndarray,
e_idx: tuple[int, int],
h_idx: tuple[int, int],
hz_idx: int | None = None,
*,
M: float | None,
ridge: float | None = None,
) -> tuple[np.ndarray, np.ndarray | None]:
r"""
Estimate 1-sigma uncertainties for the impedance tensor
and, optionally, the tipper at a single frequency.
The function operates on one complex, Hermitian cross-
spectral density matrix ``S`` of shape ``(nchan, nchan)``.
It extracts the required sub-blocks using ``e_idx``,
``h_idx`` and (optionally) ``hz_idx``, applies an
optional ridge term to stabilize the magnetic sub-block,
and performs first-order error propagation under a
complex-Wishart model with effective degrees of freedom
``M``.
Parameters
----------
S : ndarray
Complex Hermitian spectra of shape ``(nchan, nchan)``.
e_idx : tuple of int
Indices of the two electric channels, ordered as
``(EX, EY)`` within ``S``.
h_idx : tuple of int
Indices of the two horizontal magnetic channels,
ordered as ``(HX, HY)``.
hz_idx : int, optional
Index of the vertical magnetic channel ``HZ`` within
``S``. If omitted, no tipper uncertainty is computed.
M : float, optional
Effective degrees of freedom used for the variance
scaling. Larger values reduce the estimated error as
``1 / sqrt(M)``. If ``None``, no uncertainty is
computed and NaNs are returned.
ridge : float, optional
Non-negative Tikhonov regularization added to the
magnetic auto/cross block prior to inversion,
``S_HH + ridge * I``.
Returns
-------
z_err : ndarray
Real array of shape ``(2, 2)`` with the 1-sigma
standard errors for ``Z`` components in the order
``[[zxx, zxy], [zyx, zyy]]``.
tip_err : ndarray or None
Real array of shape ``(1, 2)`` with the 1-sigma
standard errors for ``(tx, ty)``. ``None`` if
``hz_idx`` is not provided.
Raises
------
ValueError
If shapes or indices are inconsistent with ``S``.
LinAlgError
If the stabilized magnetic block is singular.
Notes
-----
Let ``Z = S_EH @ inv(S_HH)`` and
``T = S_ZH @ inv(S_HH)``. Under a complex-Wishart noise
model the covariance of the least-squares estimators can
be approximated with standard first-order error
propagation, leading to uncertainties that scale with
``1 / sqrt(M)``. The returned errors are real magnitudes
(per complex component) suitable for populating
``Z.z_err`` and tipper error arrays.
Examples
--------
>>> z_e, t_e = compute_errors_from_S(
... S, e_idx=(2, 3), h_idx=(0, 1),
... hz_idx=4, M=24.0, ridge=1e-6,
... )
>>> z_e.shape, (t_e is None) or t_e.shape
((2, 2), (1, 2))
See Also
--------
effective_dof_from_meta
Helper to infer ``M`` from metadata.
Spectra.to_Z
Uses this routine when ``estimate_error=True``.
References
----------
.. [1] Chave, A. D., & Jones, A. G. (2012). *The
Magnetotelluric Method: Theory and Practice*.
Cambridge University Press.
.. [2] Bendat, J. S., & Piersol, A. G. (2011). *Random
Data: Analysis and Measurement Procedures*. Wiley.
"""
S = np.asarray(S, complex)
ex, ey = e_idx
hx, hy = h_idx
H = S[np.ix_((hx, hy), (hx, hy))]
EH = S[np.ix_((ex, ey), (hx, hy))]
EE = S[np.ix_((ex, ey), (ex, ey))]
z_err = z_error_from_blocks(
EE,
EH,
H,
M=M,
ridge=ridge,
)
tip_err = None
if hz_idx is not None:
ZH = S[np.ix_((hz_idx,), (hx, hy))]
ZZ = S[np.ix_((hz_idx,), (hz_idx,))]
tip_err = tipper_error_from_blocks(
ZH,
ZZ,
H,
M=M,
ridge=ridge,
)
return z_err, tip_err