"""
Coordinate system transformations for phased array antennas.
Includes conversions between antenna, radar, and cone/clock coordinate systems,
as well as rotation matrices for pattern rotation.
"""
from typing import Tuple, Union
import numpy as np
from scipy import interpolate
ArrayLike = Union[np.ndarray, float]
[docs]
def antenna_to_radar(
theta_ant: ArrayLike,
phi_ant: ArrayLike
) -> Tuple[np.ndarray, np.ndarray]:
"""
Convert antenna coordinates (theta/phi) to radar coordinates (az/el).
Antenna coordinates:
- theta: angle from boresight (z-axis), 0 at boresight
- phi: azimuthal angle in x-y plane, 0 along x-axis
Radar coordinates:
- az: azimuth in horizontal plane, 0 at boresight
- el: elevation from horizon, 0 at horizon, positive up
Parameters
----------
theta_ant : array_like
Theta angle in radians (from boresight)
phi_ant : array_like
Phi angle in radians (azimuthal)
Returns
-------
az : ndarray
Azimuth angle in radians
el : ndarray
Elevation angle in radians
Examples
--------
Boresight in antenna coords is also boresight in radar:
>>> import numpy as np
>>> import phased_array as pa
>>> az, el = pa.antenna_to_radar(0.0, 0.0)
>>> np.isclose(az, 0.0) and np.isclose(el, np.pi/2)
True
Off-boresight conversion:
>>> az, el = pa.antenna_to_radar(np.pi/6, 0.0) # 30 deg in x-z plane
>>> np.isclose(np.rad2deg(az), 0.0, atol=1e-10)
True
"""
theta_ant = np.asarray(theta_ant)
phi_ant = np.asarray(phi_ant)
# Convert to direction cosines
u = np.sin(theta_ant) * np.cos(phi_ant)
v = np.sin(theta_ant) * np.sin(phi_ant)
w = np.cos(theta_ant)
# Radar coordinates: az measured from x-axis in x-y plane
# el measured from x-y plane toward z-axis
az = np.arctan2(v, u)
el = np.arcsin(np.clip(w, -1, 1))
return az, el
[docs]
def radar_to_antenna(
az: ArrayLike,
el: ArrayLike
) -> Tuple[np.ndarray, np.ndarray]:
"""
Convert radar coordinates (az/el) to antenna coordinates (theta/phi).
Parameters
----------
az : array_like
Azimuth angle in radians
el : array_like
Elevation angle in radians (from horizon)
Returns
-------
theta : ndarray
Theta angle in radians (from boresight)
phi : ndarray
Phi angle in radians (azimuthal)
Examples
--------
>>> import numpy as np
>>> import phased_array as pa
>>> theta, phi = pa.radar_to_antenna(0.0, np.pi/2) # Boresight
>>> np.isclose(theta, 0.0, atol=1e-10)
True
Round-trip conversion:
>>> theta_orig, phi_orig = np.pi/4, np.pi/3
>>> az, el = pa.antenna_to_radar(theta_orig, phi_orig)
>>> theta, phi = pa.radar_to_antenna(az, el)
>>> np.isclose(theta, theta_orig, atol=1e-10)
True
"""
az = np.asarray(az)
el = np.asarray(el)
# Direction cosines from radar coords
u = np.cos(el) * np.cos(az)
v = np.cos(el) * np.sin(az)
w = np.sin(el)
# Convert to antenna theta/phi
theta = np.arccos(np.clip(w, -1, 1))
phi = np.arctan2(v, u)
return theta, phi
[docs]
def antenna_to_cone(
theta: ArrayLike,
phi: ArrayLike
) -> Tuple[np.ndarray, np.ndarray]:
"""
Convert antenna coordinates (theta/phi) to cone/clock coordinates.
Cone/clock coordinates are useful for describing patterns on
aircraft radomes or for visualizing scan limits.
Parameters
----------
theta : array_like
Theta angle in radians (from boresight)
phi : array_like
Phi angle in radians (azimuthal)
Returns
-------
cone : ndarray
Cone angle in radians (distance from boresight, same as theta)
clock : ndarray
Clock angle in radians (azimuthal position, same as phi)
Examples
--------
>>> import numpy as np
>>> import phased_array as pa
>>> cone, clock = pa.antenna_to_cone(np.pi/6, np.pi/4)
>>> np.isclose(cone, np.pi/6) and np.isclose(clock, np.pi/4)
True
"""
theta = np.asarray(theta)
phi = np.asarray(phi)
# Cone angle is the same as theta (angle from boresight)
cone = theta
# Clock angle is the same as phi (azimuthal)
clock = phi
return cone, clock
[docs]
def cone_to_antenna(
cone: ArrayLike,
clock: ArrayLike
) -> Tuple[np.ndarray, np.ndarray]:
"""
Convert cone/clock coordinates to antenna coordinates (theta/phi).
Parameters
----------
cone : array_like
Cone angle in radians
clock : array_like
Clock angle in radians
Returns
-------
theta : ndarray
Theta angle in radians
phi : ndarray
Phi angle in radians
Examples
--------
>>> import numpy as np
>>> import phased_array as pa
>>> theta, phi = pa.cone_to_antenna(np.pi/6, np.pi/4)
>>> np.isclose(theta, np.pi/6) and np.isclose(phi, np.pi/4)
True
"""
cone = np.asarray(cone)
clock = np.asarray(clock)
theta = cone
phi = clock
return theta, phi
[docs]
def rotation_matrix_roll(angle: float) -> np.ndarray:
"""
Create 3x3 rotation matrix for roll (rotation about x-axis).
Parameters
----------
angle : float
Roll angle in radians (positive = right wing down)
Returns
-------
R : ndarray
3x3 rotation matrix
Examples
--------
>>> import numpy as np
>>> import phased_array as pa
>>> R = pa.rotation_matrix_roll(0.0)
>>> np.allclose(R, np.eye(3))
True
90 degree roll:
>>> R = pa.rotation_matrix_roll(np.pi/2)
>>> v = np.array([0, 1, 0]) # y-axis
>>> v_rot = R @ v
>>> np.allclose(v_rot, [0, 0, 1], atol=1e-10) # Rotates to z-axis
True
"""
c = np.cos(angle)
s = np.sin(angle)
return np.array([
[1, 0, 0],
[0, c, -s],
[0, s, c]
])
[docs]
def rotation_matrix_pitch(angle: float) -> np.ndarray:
"""
Create 3x3 rotation matrix for pitch (rotation about y-axis).
Parameters
----------
angle : float
Pitch angle in radians (positive = nose up)
Returns
-------
R : ndarray
3x3 rotation matrix
Examples
--------
>>> import numpy as np
>>> import phased_array as pa
>>> R = pa.rotation_matrix_pitch(0.0)
>>> np.allclose(R, np.eye(3))
True
90 degree pitch:
>>> R = pa.rotation_matrix_pitch(np.pi/2)
>>> v = np.array([0, 0, 1]) # z-axis (boresight)
>>> v_rot = R @ v
>>> np.allclose(v_rot, [1, 0, 0], atol=1e-10) # Rotates to x-axis
True
"""
c = np.cos(angle)
s = np.sin(angle)
return np.array([
[c, 0, s],
[0, 1, 0],
[-s, 0, c]
])
[docs]
def rotation_matrix_yaw(angle: float) -> np.ndarray:
"""
Create 3x3 rotation matrix for yaw (rotation about z-axis).
Parameters
----------
angle : float
Yaw angle in radians (positive = nose left)
Returns
-------
R : ndarray
3x3 rotation matrix
Examples
--------
>>> import numpy as np
>>> import phased_array as pa
>>> R = pa.rotation_matrix_yaw(0.0)
>>> np.allclose(R, np.eye(3))
True
90 degree yaw:
>>> R = pa.rotation_matrix_yaw(np.pi/2)
>>> v = np.array([1, 0, 0]) # x-axis
>>> v_rot = R @ v
>>> np.allclose(v_rot, [0, 1, 0], atol=1e-10) # Rotates to y-axis
True
"""
c = np.cos(angle)
s = np.sin(angle)
return np.array([
[c, -s, 0],
[s, c, 0],
[0, 0, 1]
])
[docs]
def rotate_pattern(
theta: np.ndarray,
phi: np.ndarray,
pattern: np.ndarray,
roll_deg: float,
pitch_deg: float,
yaw_deg: float
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Rotate a radiation pattern by specified Euler angles.
The rotation order is: yaw -> pitch -> roll (intrinsic rotations),
which corresponds to standard aerospace convention.
Parameters
----------
theta : ndarray
Original theta angles in radians (1D or 2D grid)
phi : ndarray
Original phi angles in radians (same shape as theta)
pattern : ndarray
Pattern values (complex or magnitude, same shape as theta)
roll_deg : float
Roll angle in degrees
pitch_deg : float
Pitch angle in degrees
yaw_deg : float
Yaw angle in degrees
Returns
-------
theta_new : ndarray
New theta coordinates after rotation
phi_new : ndarray
New phi coordinates after rotation
pattern_interp : ndarray
Pattern values interpolated onto the new grid
Examples
--------
Rotate pattern by 30 degrees in yaw:
>>> import numpy as np
>>> import phased_array as pa
>>> theta = np.linspace(0, np.pi/2, 46)
>>> phi = np.linspace(0, 2*np.pi, 73)
>>> theta_grid, phi_grid = np.meshgrid(theta, phi, indexing='ij')
>>> pattern = np.cos(theta_grid) # Simple cosine pattern
>>> theta_r, phi_r, pattern_r = pa.rotate_pattern(
... theta_grid, phi_grid, pattern,
... roll_deg=0, pitch_deg=0, yaw_deg=30
... )
>>> pattern_r.shape == pattern.shape
True
Notes
-----
For points that rotate outside the original grid, extrapolation
may produce artifacts. Consider padding the original pattern.
"""
theta = np.asarray(theta)
phi = np.asarray(phi)
pattern = np.asarray(pattern)
original_shape = theta.shape
# Flatten for processing
theta_flat = theta.ravel()
phi_flat = phi.ravel()
pattern_flat = pattern.ravel()
# Create rotation matrix (yaw * pitch * roll)
R_roll = rotation_matrix_roll(np.deg2rad(roll_deg))
R_pitch = rotation_matrix_pitch(np.deg2rad(pitch_deg))
R_yaw = rotation_matrix_yaw(np.deg2rad(yaw_deg))
R = R_yaw @ R_pitch @ R_roll
# Convert theta/phi to Cartesian direction vectors
x = np.sin(theta_flat) * np.cos(phi_flat)
y = np.sin(theta_flat) * np.sin(phi_flat)
z = np.cos(theta_flat)
# Stack into (3, N) array and rotate
vectors = np.vstack([x, y, z])
vectors_rot = R @ vectors
# Convert back to theta/phi
x_rot = vectors_rot[0]
y_rot = vectors_rot[1]
z_rot = vectors_rot[2]
theta_new_flat = np.arccos(np.clip(z_rot, -1, 1))
phi_new_flat = np.arctan2(y_rot, x_rot)
# For interpolation, we need the inverse rotation to find where
# each output point came from in the original pattern
R_inv = R.T
# Create output grid at original theta/phi locations
theta_out_flat = theta_flat.copy()
phi_out_flat = phi_flat.copy()
# Convert output grid to Cartesian
x_out = np.sin(theta_out_flat) * np.cos(phi_out_flat)
y_out = np.sin(theta_out_flat) * np.sin(phi_out_flat)
z_out = np.cos(theta_out_flat)
# Apply inverse rotation to find source locations
vectors_out = np.vstack([x_out, y_out, z_out])
vectors_src = R_inv @ vectors_out
# Convert source locations to theta/phi
theta_src = np.arccos(np.clip(vectors_src[2], -1, 1))
phi_src = np.arctan2(vectors_src[1], vectors_src[0])
# Normalize phi to [0, 2*pi]
phi_src = np.mod(phi_src, 2 * np.pi)
# Interpolate pattern values
# Create interpolator from original pattern
if pattern.ndim == 2:
# 2D grid case - use theta_1d, phi_1d for interpolation
theta_1d = theta[:, 0] if theta.ndim == 2 else np.unique(theta)
phi_1d = phi[0, :] if phi.ndim == 2 else np.unique(phi)
# Handle complex patterns
if np.iscomplexobj(pattern):
interp_real = interpolate.RegularGridInterpolator(
(theta_1d, phi_1d), np.real(pattern),
method='linear', bounds_error=False, fill_value=0
)
interp_imag = interpolate.RegularGridInterpolator(
(theta_1d, phi_1d), np.imag(pattern),
method='linear', bounds_error=False, fill_value=0
)
points = np.column_stack([theta_src, phi_src])
pattern_interp_flat = interp_real(points) + 1j * interp_imag(points)
else:
interp = interpolate.RegularGridInterpolator(
(theta_1d, phi_1d), pattern,
method='linear', bounds_error=False, fill_value=0
)
points = np.column_stack([theta_src, phi_src])
pattern_interp_flat = interp(points)
else:
# 1D case - just do nearest neighbor or simple interpolation
pattern_interp_flat = pattern_flat.copy()
# Reshape outputs
theta_new = theta_new_flat.reshape(original_shape)
phi_new = phi_new_flat.reshape(original_shape)
pattern_interp = pattern_interp_flat.reshape(original_shape)
return theta_new, phi_new, pattern_interp