"""
Phased-array (PAS) transmitter design and radiation pattern analysis for CSAMT.
Implements the element and array-factor formulas from:
gxac023 : Fan, Zhang & Wang (2022), "A novel phased-array transmitting
source in controlled-source audio-frequency magnetotellurics",
J. Geophys. Eng. 19, 595–614.
A traditional CSAMT transmitter is a single-dipole antenna source (SDAS).
The novel PAS consists of N co-linear SDASes with independent phase control.
The energy is focussed into a steerable beam, improving SNR and enlarging
the area of interest (AoI) without increasing transmitter power.
Key formulas
------------
Element pattern (eq. 7):
F(θ) = [cos(kl cosθ/2) − cos(kl/2)] / sinθ
θ measured from dipole/array axis (y). θ=0 → null; θ=90° → maximum.
Array factor (eq. 19):
AF_n = sin(N ψ/2) / [N sin(ψ/2)], ψ = k d sin θ_b + β
θ_b = broadside angle from perpendicular to array.
Beam-steering condition (eq. 23):
β = −k d sin θ_m
Earth wavenumber for CSAMT (real part of complex k₁):
k_eff = sqrt(π f μ₀ / ρ) [m⁻¹]
"""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
from ._core import hide_polar_radius_labels
__all__ = [
"wavenumber",
"sdas_element_pattern",
"array_factor",
"pas_pattern",
"beam_steer",
"steering_angles",
"sdas_directivity",
"snr_gain_db",
"plot_radiation_pattern",
]
_MU0: float = 4.0 * np.pi * 1e-7 # H/m
_C0: float = 299_792_458.0 # m/s (free-space speed of light)
# ─────────────────────────────────────────────────────────────────────────────
# Wavenumber helper
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def wavenumber(
freq: float,
rho: float | None = None,
) -> float:
"""
Effective real wavenumber k [m⁻¹] for CSAMT or free-space propagation.
Parameters
----------
freq : float
Frequency [Hz].
rho : float or None
Half-space resistivity [Ω·m]. If given, returns the earth (CSAMT)
effective wavenumber ``Re(k₁) = sqrt(π f μ₀ / ρ)``. If *None*,
returns the free-space wavenumber ``2π f / c``.
Returns
-------
k : float
Wavenumber [m⁻¹].
Notes
-----
The complex earth wavenumber is k₁ = √(i ω μ₀ / ρ). Its real part
equals |k₁| / √2 = √(π f μ₀ / ρ). The corresponding wavelength is
λ = 2π / k_eff ≈ 2π × 503 × √(ρ/f) [m].
"""
if rho is None:
return float(2.0 * np.pi * freq / _C0)
return float(np.sqrt(np.pi * freq * _MU0 / max(rho, 1e-12)))
# ─────────────────────────────────────────────────────────────────────────────
# Single-dipole (SDAS) element pattern
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def sdas_element_pattern(
theta_deg: float | np.ndarray,
l: float,
k: float,
*,
normalize: bool = True,
) -> np.ndarray:
"""
Far-field element pattern for a single finite-length SDAS (eq. 7).
The dipole / array axis is the y-axis. The angle θ is measured FROM
the y-axis, so θ = 0° is along the dipole (null) and θ = 90° is
broadside (maximum).
Parameters
----------
theta_deg : float or ndarray
Angle(s) from the dipole axis [degrees], range [0, 180].
l : float
SDAS (dipole) physical length [m].
k : float
Wavenumber [m⁻¹]. Use :func:`wavenumber` to compute for given
frequency and resistivity.
normalize : bool
Normalize the peak to 1.0 (default *True*).
Returns
-------
F : ndarray
|F(θ)| pattern values (≥ 0).
Notes
-----
F(θ) = |[cos(kl cosθ/2) − cos(kl/2)]| / |sinθ|.
The singularity at θ = 0° and 180° resolves to zero by L'Hôpital's rule.
"""
theta = np.deg2rad(np.asarray(theta_deg, dtype=float))
cos_t = np.cos(theta)
sin_t = np.sin(theta)
num = np.cos(k * l * cos_t / 2.0) - np.cos(k * l / 2.0)
with np.errstate(divide="ignore", invalid="ignore"):
F = np.where(np.abs(sin_t) < 1e-10, 0.0, np.abs(num / sin_t))
if normalize:
peak = F.max() if F.ndim > 0 else float(F)
if peak > 0.0:
F = F / peak
return F
# ─────────────────────────────────────────────────────────────────────────────
# Array factor
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def array_factor(
theta_b_deg: float | np.ndarray,
N: int,
d: float,
k: float,
beta: float = 0.0,
) -> np.ndarray:
"""
Normalised array factor AF_n for an N-element linear PAS (eq. 19).
The angle θ_b is measured FROM BROADSIDE (perpendicular to the array
axis). θ_b = 0° is the maximum direction when β = 0; θ_b = ±90° is
along the array (end-fire direction).
Parameters
----------
theta_b_deg : float or ndarray
Broadside angle(s) [degrees], range [−90, 90].
N : int
Number of SDAS elements.
d : float
Element-to-element spacing [m].
k : float
Wavenumber [m⁻¹].
beta : float
Inter-element phase shift [rad]. β = 0 → broadside array;
use :func:`beam_steer` to compute β for a target angle.
Returns
-------
AF : ndarray
Normalised |AF_n(θ_b)| ∈ [0, 1].
Notes
-----
AF_n = sin(N ψ/2) / [N sin(ψ/2)], ψ = k d sinθ_b + β.
"""
theta_b = np.deg2rad(np.asarray(theta_b_deg, dtype=float))
psi = k * d * np.sin(theta_b) + beta
half = psi / 2.0
with np.errstate(divide="ignore", invalid="ignore"):
AF = np.where(
np.abs(half) < 1e-10,
1.0,
np.abs(np.sin(N * half)) / (N * np.abs(np.sin(half))),
)
return AF
# ─────────────────────────────────────────────────────────────────────────────
# Combined PAS pattern
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def pas_pattern(
theta_b_deg: float | np.ndarray,
N: int,
d: float,
k: float,
beta: float = 0.0,
l: float = 1000.0,
*,
normalize: bool = True,
) -> np.ndarray:
"""
Total normalised far-field pattern of an N-element PAS.
The combined pattern is the product of the SDAS element pattern and the
array factor, evaluated at the same observation angle.
Parameters
----------
theta_b_deg : float or ndarray
Broadside angle(s) [degrees], range [−90, 90].
N : int
Number of SDAS elements.
d : float
Element spacing [m].
k : float
Wavenumber [m⁻¹].
beta : float
Inter-element phase shift [rad].
l : float
SDAS length [m] (default 1000 m matching gxac023).
normalize : bool
Normalize peak to 1.0 (default *True*).
Returns
-------
pattern : ndarray
Combined |E_total(θ_b)| pattern (≥ 0).
"""
theta_b_deg = np.asarray(theta_b_deg, dtype=float)
# Element pattern: θ from y-axis = 90° − broadside angle
theta_dipole_deg = 90.0 - np.abs(theta_b_deg) # [0°, 90°]
F = sdas_element_pattern(theta_dipole_deg, l, k, normalize=False)
AF = array_factor(theta_b_deg, N, d, k, beta)
pattern = F * AF
if normalize:
peak = pattern.max() if pattern.ndim > 0 else float(pattern)
if peak > 0.0:
pattern = pattern / peak
return pattern
# ─────────────────────────────────────────────────────────────────────────────
# Beam-steering design
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def beam_steer(
theta_m_deg: float,
d: float,
k: float,
) -> float:
"""
Inter-element phase shift β [rad] to steer the main lobe to θ_m (eq. 23).
Parameters
----------
theta_m_deg : float
Target main-lobe broadside angle [degrees].
d : float
Element spacing [m].
k : float
Wavenumber [m⁻¹].
Returns
-------
beta : float
Required phase shift [rad]. Apply the same β to each SDAS via the
feed-network inductance delay.
Notes
-----
Condition (eq. 23): β = −k d sinθ_m.
"""
return float(-k * d * np.sin(np.deg2rad(theta_m_deg)))
[docs]
def steering_angles(
N: int,
d: float,
k: float,
beta: float,
*,
n_range: int = 3,
) -> np.ndarray:
"""
All main-lobe broadside angles [degrees] for the given PAS configuration.
Solves k d sinθ + β = ±2nπ (eq. 21) for n = 0, ±1, ±2, ...
Parameters
----------
N, d, k : int / float
Array parameters (as in :func:`array_factor`).
beta : float
Inter-element phase shift [rad].
n_range : int
Search over n = −n_range … +n_range (default 3).
Returns
-------
angles : ndarray
Sorted array of main-lobe broadside angles [degrees] inside [−90°, 90°].
"""
angles = []
for n in range(-n_range, n_range + 1):
val = (-beta + 2.0 * np.pi * n) / (k * d)
if abs(val) <= 1.0:
angles.append(float(np.rad2deg(np.arcsin(val))))
return np.sort(np.unique(np.round(angles, 6)))
# ─────────────────────────────────────────────────────────────────────────────
# Directivity and gain
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def sdas_directivity(
l: float,
k: float,
*,
n_theta: int = 2000,
) -> float:
"""
2-D horizontal-plane directivity D₀ = 2π U_max / ∫ U(θ) dθ (eq. 12).
Parameters
----------
l : float
SDAS length [m].
k : float
Wavenumber [m⁻¹].
n_theta : int
Number of angular samples for numerical integration.
Returns
-------
D0 : float
Directivity (dimensionless). A perfect omnidirectional source has
D₀ = 1 in 2-D.
"""
# Integrate over full circle, using element pattern vs dipole axis angle
theta = np.linspace(1e-6, np.pi - 1e-6, n_theta)
F = sdas_element_pattern(np.rad2deg(theta), l, k, normalize=False)
U = F**2
P_rad = float(np.trapezoid(U * np.sin(theta), theta) * 2.0 * np.pi)
U_max = float(U.max())
if P_rad < 1e-100:
return np.nan
return float(4.0 * np.pi * U_max / P_rad)
[docs]
def snr_gain_db(N: int) -> float:
"""
SNR improvement of an N-element PAS relative to a single SDAS [dB].
For coherent beam forming, the gain scales as N²:
G_PAS / G_SDAS = N² → 10 log₁₀(N²) = 20 log₁₀(N) dB.
Parameters
----------
N : int
Number of SDAS elements.
Returns
-------
gain_dB : float
SNR gain [dB].
"""
return float(20.0 * np.log10(max(N, 1)))
# ─────────────────────────────────────────────────────────────────────────────
# Visualisation
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_radiation_pattern(
theta_b_deg: np.ndarray | Sequence,
patterns: np.ndarray | Sequence,
*,
labels: Sequence[str] | None = None,
polar: bool = True,
normalize: bool = True,
log_scale: bool = False,
db_floor: float = -40.0,
title: str = "Radiation pattern",
figsize: tuple[float, float] = (7.0, 7.0),
ax=None,
):
"""
Plot one or more radiation patterns in polar or Cartesian format.
Parameters
----------
theta_b_deg : array-like
Broadside angles [degrees], range [−90, 90].
patterns : array-like or list of array-like
Pattern amplitude(s). A 2-D array is treated as multiple patterns
with shape (n_patterns, n_angles).
labels : list of str or None
Legend labels for each pattern.
polar : bool
Polar (default) or Cartesian plot.
normalize : bool
Normalise each pattern to its peak before plotting.
log_scale : bool
Convert to dB (20 log₁₀) for the radial / y-axis.
db_floor : float
Minimum dB value when ``log_scale=True`` (default −40 dB).
title : str
Axes title.
figsize : tuple
Figure size if a new figure is created.
ax : matplotlib.axes.Axes or None
Axes to draw on; created if *None*.
Returns
-------
ax : matplotlib.axes.Axes
"""
import matplotlib.pyplot as plt
theta_b = np.asarray(theta_b_deg, dtype=float)
pats = np.atleast_2d(np.asarray(patterns, dtype=float))
if normalize:
peak = pats.max(axis=1, keepdims=True)
peak[peak == 0] = 1.0
pats = pats / peak
if log_scale:
with np.errstate(divide="ignore"):
pats = 20.0 * np.log10(np.maximum(pats, 1e-10))
pats = np.maximum(pats, db_floor) - db_floor # shift to ≥ 0
n_pat = pats.shape[0]
lbls = (
labels
if labels is not None
else [f"pattern {i + 1}" for i in range(n_pat)]
)
if ax is None:
if polar:
_, ax = plt.subplots(
subplot_kw={"projection": "polar"}, figsize=figsize
)
else:
_, ax = plt.subplots(figsize=figsize)
for i in range(n_pat):
if polar:
# Polar: angle 0 at top (N), clockwise → use θ measured from top
# Map broadside (θ_b) to polar angle: θ_b=0 → π/2 (right)
phi = np.deg2rad(90.0 - theta_b)
ax.plot(phi, pats[i], label=lbls[i])
else:
ax.plot(theta_b, pats[i], label=lbls[i])
if polar:
ax.set_theta_zero_location("N")
ax.set_theta_direction(-1)
hide_polar_radius_labels(ax)
ax.set_title(title, pad=15)
else:
ax.set_xlabel("Broadside angle θ [deg]")
y_label = "Amplitude [dB]" if log_scale else "Normalised amplitude"
ax.set_ylabel(y_label)
ax.set_title(title)
ax.grid(True, linestyle=":")
if n_pat > 1 or labels is not None:
ax.legend(fontsize=8)
return ax