Current section
Files
Jump to
Current section
Files
lib/state/transforms.ex
defmodule SpaceDust.State.Transforms do
@moduledoc """
High-performance state vector transformations using Nx.
Provides coordinate frame transformations between:
- TEME (True Equator Mean Equinox) - SGP4 output frame
- ECI J2000 (Earth-Centered Inertial) - Standard inertial frame
- ECEF (Earth-Centered Earth-Fixed) - Rotating with Earth
Also provides conversions between:
- Cartesian state vectors and Keplerian elements
"""
import Nx.Defn
alias SpaceDust.State.TEMEState
alias SpaceDust.State.ECIState
alias SpaceDust.State.ECEFState
alias SpaceDust.State.KeplerianElements
alias SpaceDust.Bodies.Earth
# Earth's gravitational parameter in km^3/s^2
@earth_mu 398600.4418
# Earth's rotation rate in rad/s
@earth_omega 7.292115e-5
# =============================================================================
# Rotation Matrix Builders (Nx.defn for GPU/TPU acceleration)
# =============================================================================
@doc """
Build a rotation matrix about the X-axis.
"""
defn rotation_x(angle) do
c = Nx.cos(angle)
s = Nx.sin(angle)
zero = Nx.tensor(0.0, type: :f64)
one = Nx.tensor(1.0, type: :f64)
Nx.stack([
Nx.stack([one, zero, zero]),
Nx.stack([zero, c, s]),
Nx.stack([zero, Nx.negate(s), c])
])
end
@doc """
Build a rotation matrix about the Y-axis.
"""
defn rotation_y(angle) do
c = Nx.cos(angle)
s = Nx.sin(angle)
zero = Nx.tensor(0.0, type: :f64)
one = Nx.tensor(1.0, type: :f64)
Nx.stack([
Nx.stack([c, zero, Nx.negate(s)]),
Nx.stack([zero, one, zero]),
Nx.stack([s, zero, c])
])
end
@doc """
Build a rotation matrix about the Z-axis.
"""
defn rotation_z(angle) do
c = Nx.cos(angle)
s = Nx.sin(angle)
zero = Nx.tensor(0.0, type: :f64)
one = Nx.tensor(1.0, type: :f64)
Nx.stack([
Nx.stack([c, s, zero]),
Nx.stack([Nx.negate(s), c, zero]),
Nx.stack([zero, zero, one])
])
end
@doc """
Apply a rotation matrix to a vector.
"""
defn apply_rotation(matrix, vector) do
Nx.dot(matrix, vector)
end
@doc """
Multiply two 3x3 rotation matrices.
"""
defn multiply_matrices(a, b) do
Nx.dot(a, b)
end
# =============================================================================
# TEME to ECI J2000 Transformation
# =============================================================================
@doc """
Build the precession matrix from precession angles.
Transforms from Mean of Date (MOD) to J2000.
"""
defn precession_matrix(zeta, theta, z) do
# P = Rz(-z) * Ry(theta) * Rz(-zeta)
rz_neg_zeta = rotation_z(Nx.negate(zeta))
ry_theta = rotation_y(theta)
rz_neg_z = rotation_z(Nx.negate(z))
multiply_matrices(rz_neg_z, multiply_matrices(ry_theta, rz_neg_zeta))
end
@doc """
Build the nutation matrix from nutation angles.
Transforms from True of Date (TOD) to Mean of Date (MOD).
"""
defn nutation_matrix(mean_eps, delta_psi, delta_eps) do
eps = mean_eps + delta_eps
# N = Rx(mean_eps) * Rz(delta_psi) * Rx(-eps)
rx_mean_eps = rotation_x(mean_eps)
rz_delta_psi = rotation_z(delta_psi)
rx_neg_eps = rotation_x(Nx.negate(eps))
multiply_matrices(rx_mean_eps, multiply_matrices(rz_delta_psi, rx_neg_eps))
end
@doc """
Build the equation of equinoxes rotation matrix.
Transforms from TEME to True of Date (TOD).
"""
defn equinox_matrix(delta_psi, eps) do
# The equation of equinoxes term: delta_psi * cos(eps)
eq_eq = delta_psi * Nx.cos(eps)
rotation_z(Nx.negate(eq_eq))
end
@doc """
Perform the full TEME to ECI J2000 transformation on position/velocity tensors.
"""
defn teme_to_eci_tensors(pos, vel, zeta, theta, z, mean_eps, delta_psi, delta_eps) do
eps = mean_eps + delta_eps
# Build transformation matrices
eq_mat = equinox_matrix(delta_psi, eps)
nut_mat = nutation_matrix(mean_eps, delta_psi, delta_eps)
prec_mat = precession_matrix(zeta, theta, z)
# Combined transformation: P * N * E
combined = multiply_matrices(prec_mat, multiply_matrices(nut_mat, eq_mat))
# Apply to position and velocity
pos_eci = apply_rotation(combined, pos)
vel_eci = apply_rotation(combined, vel)
{pos_eci, vel_eci}
end
@doc """
Convert a TEME state to ECI J2000.
## Parameters
- teme_state: TEMEState struct with position/velocity in TEME frame
## Returns
- ECIState struct with position/velocity in ECI J2000 frame
"""
@spec teme_to_eci(TEMEState.t()) :: ECIState.t()
def teme_to_eci(%TEMEState{epoch: epoch} = teme_state) do
# Get precession and nutation angles from Earth module
precession = Earth.precessionAngles(epoch)
nutation = Earth.nutationAngles(epoch)
# Convert to tensors
{pos, vel} = TEMEState.to_tensors(teme_state)
# Build angle tensors
zeta = Nx.tensor(precession.zeta, type: :f64)
theta = Nx.tensor(precession.theta, type: :f64)
z = Nx.tensor(precession.z, type: :f64)
mean_eps = Nx.tensor(nutation.mEps, type: :f64)
delta_psi = Nx.tensor(nutation.dPsi, type: :f64)
delta_eps = Nx.tensor(nutation.dEps, type: :f64)
# Perform transformation
{pos_eci, vel_eci} = teme_to_eci_tensors(pos, vel, zeta, theta, z, mean_eps, delta_psi, delta_eps)
ECIState.from_tensors(epoch, pos_eci, vel_eci)
end
# =============================================================================
# ECI J2000 to TEME Transformation (inverse)
# =============================================================================
@doc """
Perform the full ECI J2000 to TEME transformation on position/velocity tensors.
"""
defn eci_to_teme_tensors(pos, vel, zeta, theta, z, mean_eps, delta_psi, delta_eps) do
eps = mean_eps + delta_eps
# Build transformation matrices (transposed for inverse)
eq_mat = Nx.transpose(equinox_matrix(delta_psi, eps))
nut_mat = Nx.transpose(nutation_matrix(mean_eps, delta_psi, delta_eps))
prec_mat = Nx.transpose(precession_matrix(zeta, theta, z))
# Combined transformation: E^T * N^T * P^T
combined = multiply_matrices(eq_mat, multiply_matrices(nut_mat, prec_mat))
# Apply to position and velocity
pos_teme = apply_rotation(combined, pos)
vel_teme = apply_rotation(combined, vel)
{pos_teme, vel_teme}
end
@doc """
Convert an ECI J2000 state to TEME.
## Parameters
- eci_state: ECIState struct with position/velocity in ECI J2000 frame
## Returns
- TEMEState struct with position/velocity in TEME frame
"""
@spec eci_to_teme(ECIState.t()) :: TEMEState.t()
def eci_to_teme(%ECIState{epoch: epoch} = eci_state) do
# Get precession and nutation angles from Earth module
precession = Earth.precessionAngles(epoch)
nutation = Earth.nutationAngles(epoch)
# Convert to tensors
{pos, vel} = ECIState.to_tensors(eci_state)
# Build angle tensors
zeta = Nx.tensor(precession.zeta, type: :f64)
theta = Nx.tensor(precession.theta, type: :f64)
z = Nx.tensor(precession.z, type: :f64)
mean_eps = Nx.tensor(nutation.mEps, type: :f64)
delta_psi = Nx.tensor(nutation.dPsi, type: :f64)
delta_eps = Nx.tensor(nutation.dEps, type: :f64)
# Perform transformation
{pos_teme, vel_teme} = eci_to_teme_tensors(pos, vel, zeta, theta, z, mean_eps, delta_psi, delta_eps)
TEMEState.from_tensors(epoch, pos_teme, vel_teme)
end
# =============================================================================
# ECI to ECEF Transformation
# =============================================================================
@doc """
Perform ECI to ECEF transformation on position/velocity tensors.
Accounts for Earth rotation and velocity contribution from rotation.
"""
defn eci_to_ecef_tensors(pos, vel, gast, omega) do
# Rotation matrix from ECI to ECEF
rot = rotation_z(gast)
# Position transformation is straightforward
pos_ecef = apply_rotation(rot, pos)
# Velocity needs correction for Earth's rotation
# v_ecef = R * v_eci - omega x r_ecef
vel_rotated = apply_rotation(rot, vel)
# omega cross r_ecef (omega is along z-axis)
omega_cross_r = Nx.stack([
Nx.negate(omega) * pos_ecef[1],
omega * pos_ecef[0],
Nx.tensor(0.0, type: :f64)
])
vel_ecef = vel_rotated - omega_cross_r
{pos_ecef, vel_ecef}
end
@doc """
Convert an ECI J2000 state to ECEF.
## Parameters
- eci_state: ECIState struct with position/velocity in ECI J2000 frame
## Returns
- ECEFState struct with position/velocity in ECEF frame
"""
@spec eci_to_ecef(ECIState.t()) :: ECEFState.t()
def eci_to_ecef(%ECIState{epoch: epoch} = eci_state) do
# Get GAST (Greenwich Apparent Sidereal Time)
nutation = Earth.nutationAngles(epoch)
gast = Nx.tensor(nutation.gast, type: :f64)
omega = Nx.tensor(@earth_omega, type: :f64)
# Convert to tensors
{pos, vel} = ECIState.to_tensors(eci_state)
# Perform transformation
{pos_ecef, vel_ecef} = eci_to_ecef_tensors(pos, vel, gast, omega)
ECEFState.from_tensors(epoch, pos_ecef, vel_ecef)
end
# =============================================================================
# ECEF to ECI Transformation (inverse)
# =============================================================================
@doc """
Perform ECEF to ECI transformation on position/velocity tensors.
"""
defn ecef_to_eci_tensors(pos, vel, gast, omega) do
# Inverse rotation matrix (transpose of Rz)
rot_inv = Nx.transpose(rotation_z(gast))
# First correct velocity for Earth's rotation
# v_eci = R^T * (v_ecef + omega x r_ecef)
omega_cross_r = Nx.stack([
Nx.negate(omega) * pos[1],
omega * pos[0],
Nx.tensor(0.0, type: :f64)
])
vel_corrected = vel + omega_cross_r
# Apply rotation
pos_eci = apply_rotation(rot_inv, pos)
vel_eci = apply_rotation(rot_inv, vel_corrected)
{pos_eci, vel_eci}
end
@doc """
Convert an ECEF state to ECI J2000.
## Parameters
- ecef_state: ECEFState struct with position/velocity in ECEF frame
## Returns
- ECIState struct with position/velocity in ECI J2000 frame
"""
@spec ecef_to_eci(ECEFState.t()) :: ECIState.t()
def ecef_to_eci(%ECEFState{epoch: epoch} = ecef_state) do
# Get GAST (Greenwich Apparent Sidereal Time)
nutation = Earth.nutationAngles(epoch)
gast = Nx.tensor(nutation.gast, type: :f64)
omega = Nx.tensor(@earth_omega, type: :f64)
# Convert to tensors
{pos, vel} = ECEFState.to_tensors(ecef_state)
# Perform transformation
{pos_eci, vel_eci} = ecef_to_eci_tensors(pos, vel, gast, omega)
ECIState.from_tensors(epoch, pos_eci, vel_eci)
end
# =============================================================================
# TEME to ECEF (via ECI)
# =============================================================================
@doc """
Convert a TEME state to ECEF.
## Parameters
- teme_state: TEMEState struct with position/velocity in TEME frame
## Returns
- ECEFState struct with position/velocity in ECEF frame
"""
@spec teme_to_ecef(TEMEState.t()) :: ECEFState.t()
def teme_to_ecef(%TEMEState{} = teme_state) do
teme_state
|> teme_to_eci()
|> eci_to_ecef()
end
@doc """
Convert an ECEF state to TEME.
## Parameters
- ecef_state: ECEFState struct with position/velocity in ECEF frame
## Returns
- TEMEState struct with position/velocity in TEME frame
"""
@spec ecef_to_teme(ECEFState.t()) :: TEMEState.t()
def ecef_to_teme(%ECEFState{} = ecef_state) do
ecef_state
|> ecef_to_eci()
|> eci_to_teme()
end
# =============================================================================
# Cartesian to Keplerian Elements Conversion
# =============================================================================
@doc """
Convert Cartesian state to Keplerian elements using Nx.
"""
defn cartesian_to_keplerian_tensors(pos, vel, mu) do
# Position and velocity magnitudes
r = Nx.sqrt(Nx.sum(pos * pos))
v = Nx.sqrt(Nx.sum(vel * vel))
# Specific angular momentum h = r x v
h = Nx.stack([
pos[1] * vel[2] - pos[2] * vel[1],
pos[2] * vel[0] - pos[0] * vel[2],
pos[0] * vel[1] - pos[1] * vel[0]
])
h_mag = Nx.sqrt(Nx.sum(h * h))
# Node vector n = k x h (k is z-unit vector)
n = Nx.stack([Nx.negate(h[1]), h[0], Nx.tensor(0.0, type: :f64)])
n_mag = Nx.sqrt(Nx.sum(n * n))
# Eccentricity vector e = ((v^2 - mu/r)*r - (r.v)*v) / mu
rdotv = Nx.sum(pos * vel)
e_vec = ((v * v - mu / r) * pos - rdotv * vel) / mu
e = Nx.sqrt(Nx.sum(e_vec * e_vec))
# Semi-major axis
energy = v * v / 2.0 - mu / r
a = Nx.negate(mu) / (2.0 * energy)
# Inclination
i = Nx.acos(h[2] / h_mag)
# RAAN (Right Ascension of Ascending Node)
# Handle case when n_mag is near zero (equatorial orbit)
raan = Nx.select(
n_mag > 1.0e-10,
Nx.select(n[1] >= 0, Nx.acos(n[0] / n_mag), 2.0 * Nx.Constants.pi() - Nx.acos(n[0] / n_mag)),
Nx.tensor(0.0, type: :f64)
)
# Argument of perigee
n_dot_e = Nx.sum(n * e_vec)
w = Nx.select(
n_mag > 1.0e-10 and e > 1.0e-10,
Nx.select(e_vec[2] >= 0, Nx.acos(n_dot_e / (n_mag * e)), 2.0 * Nx.Constants.pi() - Nx.acos(n_dot_e / (n_mag * e))),
Nx.tensor(0.0, type: :f64)
)
# True anomaly
e_dot_r = Nx.sum(e_vec * pos)
nu = Nx.select(
e > 1.0e-10,
Nx.select(rdotv >= 0, Nx.acos(e_dot_r / (e * r)), 2.0 * Nx.Constants.pi() - Nx.acos(e_dot_r / (e * r))),
Nx.tensor(0.0, type: :f64)
)
Nx.stack([a, e, i, raan, w, nu])
end
@doc """
Convert a Cartesian state (ECI) to Keplerian elements.
## Parameters
- state: ECIState, TEMEState, or ECEFState struct
- opts: Options including :mu for gravitational parameter
## Returns
- KeplerianElements struct
"""
@spec to_keplerian(ECIState.t() | TEMEState.t(), keyword()) :: KeplerianElements.t()
def to_keplerian(state, opts \\ [])
def to_keplerian(%ECIState{epoch: epoch} = state, opts) do
mu = Keyword.get(opts, :mu, @earth_mu)
{pos, vel} = ECIState.to_tensors(state)
mu_tensor = Nx.tensor(mu, type: :f64)
elements_tensor = cartesian_to_keplerian_tensors(pos, vel, mu_tensor)
KeplerianElements.from_tensor(elements_tensor, epoch: epoch, mu: mu)
end
def to_keplerian(%TEMEState{} = state, opts) do
state
|> teme_to_eci()
|> to_keplerian(opts)
end
# =============================================================================
# Keplerian Elements to Cartesian Conversion
# =============================================================================
@doc """
Convert Keplerian elements to Cartesian state using Nx.
"""
defn keplerian_to_cartesian_tensors(elements, mu) do
a = elements[0]
e = elements[1]
i = elements[2]
raan = elements[3]
w = elements[4]
nu = elements[5]
# Semi-latus rectum
p = a * (1.0 - e * e)
# Position and velocity in perifocal frame
cos_nu = Nx.cos(nu)
sin_nu = Nx.sin(nu)
r_mag = p / (1.0 + e * cos_nu)
# Position in perifocal frame
r_pf = Nx.stack([r_mag * cos_nu, r_mag * sin_nu, Nx.tensor(0.0, type: :f64)])
# Velocity in perifocal frame
sqrt_mu_p = Nx.sqrt(mu / p)
v_pf = Nx.stack([
Nx.negate(sqrt_mu_p) * sin_nu,
sqrt_mu_p * (e + cos_nu),
Nx.tensor(0.0, type: :f64)
])
# Rotation matrices
cos_raan = Nx.cos(raan)
sin_raan = Nx.sin(raan)
cos_w = Nx.cos(w)
sin_w = Nx.sin(w)
cos_i = Nx.cos(i)
sin_i = Nx.sin(i)
# Combined rotation matrix from perifocal to inertial
# R = Rz(-RAAN) * Rx(-i) * Rz(-w)
r11 = cos_raan * cos_w - sin_raan * sin_w * cos_i
r12 = Nx.negate(cos_raan * sin_w) - sin_raan * cos_w * cos_i
r13 = sin_raan * sin_i
r21 = sin_raan * cos_w + cos_raan * sin_w * cos_i
r22 = Nx.negate(sin_raan * sin_w) + cos_raan * cos_w * cos_i
r23 = Nx.negate(cos_raan) * sin_i
r31 = sin_w * sin_i
r32 = cos_w * sin_i
r33 = cos_i
# Apply rotation to position
pos = Nx.stack([
r11 * r_pf[0] + r12 * r_pf[1] + r13 * r_pf[2],
r21 * r_pf[0] + r22 * r_pf[1] + r23 * r_pf[2],
r31 * r_pf[0] + r32 * r_pf[1] + r33 * r_pf[2]
])
# Apply rotation to velocity
vel = Nx.stack([
r11 * v_pf[0] + r12 * v_pf[1] + r13 * v_pf[2],
r21 * v_pf[0] + r22 * v_pf[1] + r23 * v_pf[2],
r31 * v_pf[0] + r32 * v_pf[1] + r33 * v_pf[2]
])
{pos, vel}
end
@doc """
Convert Keplerian elements to an ECI state.
## Parameters
- elements: KeplerianElements struct
## Returns
- ECIState struct with position/velocity in ECI J2000 frame
"""
@spec keplerian_to_eci(KeplerianElements.t()) :: ECIState.t()
def keplerian_to_eci(%KeplerianElements{epoch: epoch, mu: mu} = elements) do
mu = mu || @earth_mu
elements_tensor = KeplerianElements.to_tensor(elements)
mu_tensor = Nx.tensor(mu, type: :f64)
{pos, vel} = keplerian_to_cartesian_tensors(elements_tensor, mu_tensor)
ECIState.from_tensors(epoch || DateTime.utc_now(), pos, vel)
end
@doc """
Convert Keplerian elements to a TEME state.
## Parameters
- elements: KeplerianElements struct
## Returns
- TEMEState struct with position/velocity in TEME frame
"""
@spec keplerian_to_teme(KeplerianElements.t()) :: TEMEState.t()
def keplerian_to_teme(%KeplerianElements{} = elements) do
elements
|> keplerian_to_eci()
|> eci_to_teme()
end
end