"""
Spherical and polar coordinate conversions.
This module provides functions for converting between Cartesian and
spherical/polar coordinate systems, following tracking conventions.
"""
from typing import Literal, Tuple
import numpy as np
from numpy.typing import ArrayLike, NDArray
[docs]
def cart2sphere(
cart_points: ArrayLike,
system_type: Literal["standard", "az-el", "range-az-el"] = "standard",
) -> Tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]:
"""
Convert Cartesian coordinates to spherical coordinates.
Parameters
----------
cart_points : array_like
Cartesian coordinates. Can be:
- Shape (3,) for a single point [x, y, z]
- Shape (3, n) for n points (each column is a point)
- Shape (n, 3) will be transposed automatically
system_type : {'standard', 'az-el', 'range-az-el'}, optional
Spherical coordinate convention:
- 'standard': Physics convention (r, θ polar from +z, φ azimuth from +x)
- 'az-el': Tracking convention (r, azimuth from +x, elevation from xy-plane)
- 'range-az-el': Same as 'az-el' (alias)
Default is 'standard'.
Returns
-------
r : ndarray
Range (radial distance from origin).
az : ndarray
Azimuth angle in radians.
- 'standard': Angle in xy-plane from +x axis [0, 2π)
- 'az-el': Angle in xy-plane from +x axis [-π, π]
el : ndarray
Elevation/polar angle in radians.
- 'standard': Polar angle from +z axis [0, π]
- 'az-el': Elevation from xy-plane [-π/2, π/2]
Examples
--------
>>> x, y, z = 1.0, 1.0, 1.0
>>> r, az, el = cart2sphere([x, y, z], system_type='az-el')
>>> r
1.7320508075688772
>>> np.degrees(az)
45.0
>>> np.degrees(el)
35.26438968275465
See Also
--------
sphere2cart : Inverse conversion.
"""
cart_points = np.asarray(cart_points, dtype=np.float64)
# Handle different input shapes
if cart_points.ndim == 1:
cart_points = cart_points.reshape(3, 1)
elif cart_points.shape[0] != 3 and cart_points.shape[1] == 3:
cart_points = cart_points.T
x = cart_points[0]
y = cart_points[1]
z = cart_points[2]
# Range
r = np.sqrt(x**2 + y**2 + z**2)
# Azimuth (angle in xy-plane from +x)
az = np.arctan2(y, x)
if system_type == "standard":
# Standard physics convention: polar angle from +z
el = np.arccos(np.clip(z / np.maximum(r, 1e-15), -1, 1))
# Wrap azimuth to [0, 2π)
az = np.mod(az, 2 * np.pi)
else: # 'az-el' or 'range-az-el'
# Tracking convention: elevation from xy-plane
xy_range = np.sqrt(x**2 + y**2)
el = np.arctan2(z, xy_range)
# Squeeze if single point
if r.size == 1:
return r.item(), az.item(), el.item()
return r, az, el
[docs]
def sphere2cart(
r: ArrayLike,
az: ArrayLike,
el: ArrayLike,
system_type: Literal["standard", "az-el", "range-az-el"] = "standard",
) -> NDArray[np.floating]:
"""
Convert spherical coordinates to Cartesian coordinates.
Parameters
----------
r : array_like
Range (radial distance).
az : array_like
Azimuth angle in radians.
el : array_like
Elevation/polar angle in radians.
system_type : {'standard', 'az-el', 'range-az-el'}, optional
Spherical coordinate convention (see cart2sphere).
Returns
-------
cart_points : ndarray
Cartesian coordinates of shape (3,) or (3, n).
Examples
--------
>>> r, az, el = 1.732, np.radians(45), np.radians(35.26)
>>> cart2sphere(sphere2cart(r, az, el, 'az-el'), 'az-el')
(1.732..., 0.785..., 0.615...)
See Also
--------
cart2sphere : Inverse conversion.
"""
r = np.asarray(r, dtype=np.float64)
az = np.asarray(az, dtype=np.float64)
el = np.asarray(el, dtype=np.float64)
if system_type == "standard":
# Standard physics: el is polar angle from +z
x = r * np.sin(el) * np.cos(az)
y = r * np.sin(el) * np.sin(az)
z = r * np.cos(el)
else: # 'az-el' or 'range-az-el'
# Tracking: el is elevation from xy-plane
x = r * np.cos(el) * np.cos(az)
y = r * np.cos(el) * np.sin(az)
z = r * np.sin(el)
if np.isscalar(r) or r.size == 1:
return np.array(
[
x.item() if hasattr(x, "item") else x,
y.item() if hasattr(y, "item") else y,
z.item() if hasattr(z, "item") else z,
],
dtype=np.float64,
)
return np.array([x, y, z], dtype=np.float64)
[docs]
def cart2pol(
cart_points: ArrayLike,
) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
"""
Convert 2D Cartesian coordinates to polar coordinates.
Parameters
----------
cart_points : array_like
Cartesian coordinates. Can be:
- Shape (2,) for a single point [x, y]
- Shape (2, n) for n points
- Shape (n, 2) will be transposed
Returns
-------
r : ndarray
Radial distance from origin.
theta : ndarray
Angle in radians from +x axis, in range [-π, π].
Examples
--------
>>> r, theta = cart2pol([1, 1])
>>> r
1.4142135623730951
>>> np.degrees(theta)
45.0
See Also
--------
pol2cart : Inverse conversion.
"""
cart_points = np.asarray(cart_points, dtype=np.float64)
if cart_points.ndim == 1:
cart_points = cart_points.reshape(2, 1)
elif cart_points.shape[0] != 2 and cart_points.shape[1] == 2:
cart_points = cart_points.T
x = cart_points[0]
y = cart_points[1]
r = np.sqrt(x**2 + y**2)
theta = np.arctan2(y, x)
if r.size == 1:
return r.item(), theta.item()
return r, theta
[docs]
def pol2cart(
r: ArrayLike,
theta: ArrayLike,
) -> NDArray[np.floating]:
"""
Convert polar coordinates to 2D Cartesian coordinates.
Parameters
----------
r : array_like
Radial distance.
theta : array_like
Angle in radians from +x axis.
Returns
-------
cart_points : ndarray
Cartesian coordinates of shape (2,) or (2, n).
Examples
--------
>>> x, y = pol2cart(1.414, np.radians(45))
>>> x, y
(0.999..., 0.999...)
See Also
--------
cart2pol : Inverse conversion.
"""
r = np.asarray(r, dtype=np.float64)
theta = np.asarray(theta, dtype=np.float64)
x = r * np.cos(theta)
y = r * np.sin(theta)
if np.isscalar(r) or r.size == 1:
return np.array(
[
x.item() if hasattr(x, "item") else x,
y.item() if hasattr(y, "item") else y,
],
dtype=np.float64,
)
return np.array([x, y], dtype=np.float64)
[docs]
def cart2cyl(
cart_points: ArrayLike,
) -> Tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]:
"""
Convert 3D Cartesian coordinates to cylindrical coordinates.
Parameters
----------
cart_points : array_like
Cartesian coordinates [x, y, z].
Returns
-------
rho : ndarray
Radial distance in xy-plane.
phi : ndarray
Azimuth angle in radians from +x axis.
z : ndarray
Height (same as Cartesian z).
Examples
--------
>>> rho, phi, z = cart2cyl([1, 1, 5])
>>> rho
1.4142135623730951
>>> np.degrees(phi)
45.0
>>> z
5.0
See Also
--------
cyl2cart : Inverse conversion.
"""
cart_points = np.asarray(cart_points, dtype=np.float64)
if cart_points.ndim == 1:
cart_points = cart_points.reshape(3, 1)
elif cart_points.shape[0] != 3 and cart_points.shape[1] == 3:
cart_points = cart_points.T
x = cart_points[0]
y = cart_points[1]
z = cart_points[2]
rho = np.sqrt(x**2 + y**2)
phi = np.arctan2(y, x)
if rho.size == 1:
return rho.item(), phi.item(), z.item()
return rho, phi, z
[docs]
def cyl2cart(
rho: ArrayLike,
phi: ArrayLike,
z: ArrayLike,
) -> NDArray[np.floating]:
"""
Convert cylindrical coordinates to 3D Cartesian coordinates.
Parameters
----------
rho : array_like
Radial distance in xy-plane.
phi : array_like
Azimuth angle in radians from +x axis.
z : array_like
Height.
Returns
-------
cart_points : ndarray
Cartesian coordinates of shape (3,) or (3, n).
Examples
--------
>>> cart = cyl2cart(1.414, np.radians(45), 5.0)
>>> cart
array([1.00..., 1.00..., 5. ])
See Also
--------
cart2cyl : Inverse conversion.
"""
rho = np.asarray(rho, dtype=np.float64)
phi = np.asarray(phi, dtype=np.float64)
z = np.asarray(z, dtype=np.float64)
x = rho * np.cos(phi)
y = rho * np.sin(phi)
if np.isscalar(rho) or rho.size == 1:
return np.array(
[
x.item() if hasattr(x, "item") else x,
y.item() if hasattr(y, "item") else y,
z.item() if hasattr(z, "item") else z,
],
dtype=np.float64,
)
return np.array([x, y, z], dtype=np.float64)
[docs]
def ruv2cart(
r: ArrayLike,
u: ArrayLike,
v: ArrayLike,
) -> NDArray[np.floating]:
"""
Convert r-u-v (range, direction cosines) to Cartesian coordinates.
The r-u-v system uses direction cosines where:
- u = cos(az) * cos(el) = x/r
- v = sin(az) * cos(el) = y/r
- w = sin(el) = z/r (derived from u, v)
Parameters
----------
r : array_like
Range.
u : array_like
Direction cosine along x-axis.
v : array_like
Direction cosine along y-axis.
Returns
-------
cart_points : ndarray
Cartesian coordinates.
Examples
--------
>>> # Target at 45 deg azimuth, 30 deg elevation, range 100
>>> az, el = np.radians(45), np.radians(30)
>>> u = np.cos(az) * np.cos(el)
>>> v = np.sin(az) * np.cos(el)
>>> cart = ruv2cart(100, u, v)
>>> cart
array([61.23..., 61.23..., 50. ])
Notes
-----
This representation is common in radar tracking systems.
"""
r = np.asarray(r, dtype=np.float64)
u = np.asarray(u, dtype=np.float64)
v = np.asarray(v, dtype=np.float64)
# w = sqrt(1 - u^2 - v^2), assuming positive z
w_sq = 1.0 - u**2 - v**2
w = np.sqrt(np.maximum(w_sq, 0.0))
x = r * u
y = r * v
z = r * w
if np.isscalar(r) or r.size == 1:
return np.array(
[
x.item() if hasattr(x, "item") else x,
y.item() if hasattr(y, "item") else y,
z.item() if hasattr(z, "item") else z,
],
dtype=np.float64,
)
return np.array([x, y, z], dtype=np.float64)
[docs]
def cart2ruv(
cart_points: ArrayLike,
) -> Tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]:
"""
Convert Cartesian coordinates to r-u-v (range, direction cosines).
Parameters
----------
cart_points : array_like
Cartesian coordinates [x, y, z].
Returns
-------
r : ndarray
Range.
u : ndarray
Direction cosine along x-axis (x/r).
v : ndarray
Direction cosine along y-axis (y/r).
Examples
--------
>>> r, u, v = cart2ruv([100, 0, 0])
>>> r, u, v
(100.0, 1.0, 0.0)
>>> r, u, v = cart2ruv([50, 50, 50])
>>> r
86.602...
See Also
--------
ruv2cart : Inverse conversion.
"""
cart_points = np.asarray(cart_points, dtype=np.float64)
if cart_points.ndim == 1:
cart_points = cart_points.reshape(3, 1)
elif cart_points.shape[0] != 3 and cart_points.shape[1] == 3:
cart_points = cart_points.T
x = cart_points[0]
y = cart_points[1]
z = cart_points[2]
r = np.sqrt(x**2 + y**2 + z**2)
r_safe = np.maximum(r, 1e-15)
u = x / r_safe
v = y / r_safe
if r.size == 1:
return r.item(), u.item(), v.item()
return r, u, v
__all__ = [
"cart2sphere",
"sphere2cart",
"cart2pol",
"pol2cart",
"cart2cyl",
"cyl2cart",
"ruv2cart",
"cart2ruv",
]