use crate::rotational::RotationalState;
use crate::state::{TranslationalState, TranslationalStateTyped};
use astrodyn_math::{mat3_from_rows, GeodeticState, JeodQuat, OrbitalElements};
use astrodyn_quantities::aliases::{Position, Velocity};
use astrodyn_quantities::dims::GravParam;
use astrodyn_quantities::ext::Vec3Ext;
use astrodyn_quantities::frame::RootInertial;
use glam::{DMat3, DVec3};
use uom::si::angle::radian;
use uom::si::f64::{Angle, Length};
use uom::si::length::meter;
pub fn init_from_orbital_elements(
semi_major_axis: f64,
eccentricity: f64,
inclination: f64,
raan: f64,
arg_periapsis: f64,
true_anomaly: f64,
mu: f64,
) -> TranslationalState {
assert!(
mu > 0.0,
"init_from_orbital_elements: mu must be positive, got {mu}"
);
assert!(
semi_major_axis.is_finite(),
"init_from_orbital_elements: semi_major_axis must be finite, got {semi_major_axis}"
);
assert!(
(0.0..1.0).contains(&eccentricity),
"init_from_orbital_elements: eccentricity must be in [0, 1), got {eccentricity}"
);
use astrodyn_quantities::frame::SelfPlanet;
let mut oe = OrbitalElements::<SelfPlanet>::default();
oe.semi_major_axis = semi_major_axis;
oe.e_mag = eccentricity;
oe.inclination = inclination;
oe.long_asc_node = raan;
oe.arg_periapsis = arg_periapsis;
oe.semiparam = semi_major_axis * (1.0 - eccentricity * eccentricity);
oe.true_anom = true_anomaly;
oe.nu_to_anomalies();
let (position, velocity) = oe
.to_cartesian(mu)
.expect("init_from_orbital_elements: to_cartesian failed");
TranslationalState { position, velocity }
}
pub fn init_from_orbital_elements_typed<P: astrodyn_quantities::frame::Planet>(
semi_major_axis: Length,
eccentricity: f64,
inclination: Angle,
raan: Angle,
arg_periapsis: Angle,
true_anomaly: Angle,
mu: GravParam<P>,
) -> TranslationalStateTyped<RootInertial> {
let untyped = init_from_orbital_elements(
semi_major_axis.get::<meter>(),
eccentricity,
inclination.get::<radian>(),
raan.get::<radian>(),
arg_periapsis.get::<radian>(),
true_anomaly.get::<radian>(),
mu.value, );
TranslationalStateTyped::<RootInertial> {
position: Position::<RootInertial>::from_raw_si(untyped.position),
velocity: Velocity::<RootInertial>::from_raw_si(untyped.velocity),
}
}
pub fn init_from_mean_anomaly(
semi_major_axis: f64,
eccentricity: f64,
inclination: f64,
raan: f64,
arg_periapsis: f64,
mean_anomaly: f64,
mu: f64,
) -> TranslationalState {
assert!(
mu > 0.0,
"init_from_mean_anomaly: mu must be positive, got {mu}"
);
assert!(
semi_major_axis.is_finite(),
"init_from_mean_anomaly: semi_major_axis must be finite, got {semi_major_axis}"
);
assert!(
(0.0..1.0).contains(&eccentricity),
"init_from_mean_anomaly: eccentricity must be in [0, 1), got {eccentricity}"
);
use astrodyn_quantities::frame::SelfPlanet;
let mut oe = OrbitalElements::<SelfPlanet>::default();
oe.semi_major_axis = semi_major_axis;
oe.e_mag = eccentricity;
oe.inclination = inclination;
oe.long_asc_node = raan;
oe.arg_periapsis = arg_periapsis;
oe.semiparam = semi_major_axis * (1.0 - eccentricity * eccentricity);
oe.mean_anom = mean_anomaly;
oe.mean_anom_to_nu()
.expect("init_from_mean_anomaly: Kepler solver failed");
let (position, velocity) = oe
.to_cartesian(mu)
.expect("init_from_mean_anomaly: to_cartesian failed");
TranslationalState { position, velocity }
}
pub fn init_from_time_periapsis(
semi_major_axis: f64,
eccentricity: f64,
inclination: f64,
raan: f64,
arg_periapsis: f64,
time_periapsis: f64,
mu: f64,
) -> TranslationalState {
assert!(
mu > 0.0,
"init_from_time_periapsis: mu must be positive, got {mu}"
);
assert!(
semi_major_axis > 0.0 && semi_major_axis.is_finite(),
"init_from_time_periapsis: semi_major_axis must be positive and finite, got {semi_major_axis}"
);
let mean_anomaly = time_periapsis * (mu / semi_major_axis).sqrt() / semi_major_axis;
init_from_mean_anomaly(
semi_major_axis,
eccentricity,
inclination,
raan,
arg_periapsis,
mean_anomaly,
mu,
)
}
pub fn init_from_lvlh(
lvlh_pos: DVec3,
lvlh_vel: DVec3,
ref_position: DVec3,
ref_velocity: DVec3,
) -> TranslationalState {
use astrodyn_quantities::frame::{Earth, PlanetInertial};
let lvlh = astrodyn_math::LvlhFrame::compute(
ref_position.m_at::<PlanetInertial<Earth>>(),
ref_velocity.m_per_s_at::<PlanetInertial<Earth>>(),
);
let t_lvlh_to_inertial = lvlh.t_parent_this.transpose();
let position = ref_position + t_lvlh_to_inertial * lvlh_pos;
let velocity = ref_velocity + t_lvlh_to_inertial * lvlh_vel;
TranslationalState { position, velocity }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LvlhAngularVelocityFrame {
#[default]
Body,
Lvlh,
}
pub fn init_rot_from_lvlh(
q_lvlh_body: JeodQuat,
ang_vel_lvlh_to_body: DVec3,
ang_vel_frame: LvlhAngularVelocityFrame,
ref_position: DVec3,
ref_velocity: DVec3,
) -> RotationalState {
let mut q_lvlh_body = q_lvlh_body;
q_lvlh_body.normalize();
use astrodyn_quantities::frame::{Earth, PlanetInertial};
let lvlh = astrodyn_math::LvlhFrame::compute(
ref_position.m_at::<PlanetInertial<Earth>>(),
ref_velocity.m_per_s_at::<PlanetInertial<Earth>>(),
);
let q_inertial_lvlh = JeodQuat::left_quat_from_transformation(&lvlh.t_parent_this);
let q_inertial_body = q_lvlh_body.multiply(&q_inertial_lvlh);
let t_lvlh_body = q_lvlh_body.left_quat_to_transformation();
let ang_vel_lvlh_to_body_in_body = match ang_vel_frame {
LvlhAngularVelocityFrame::Body => ang_vel_lvlh_to_body,
LvlhAngularVelocityFrame::Lvlh => t_lvlh_body * ang_vel_lvlh_to_body,
};
let ang_vel_inertial_lvlh_in_body = t_lvlh_body * lvlh.ang_vel_this;
let ang_vel_body = ang_vel_inertial_lvlh_in_body + ang_vel_lvlh_to_body_in_body;
RotationalState {
quaternion: q_inertial_body,
ang_vel_body,
}
}
pub fn init_from_ned(
geodetic: &GeodeticState,
ned_velocity: DVec3,
r_eq: f64,
r_pol: f64,
t_eci_pcpf: &DMat3,
omega_planet: DVec3,
) -> TranslationalState {
let pcpf_pos = geodetic.to_planet_fixed(r_eq, r_pol);
let t_pcpf_ned = compute_ned_rotation(geodetic.latitude, geodetic.longitude);
let pcpf_vel = t_pcpf_ned.transpose() * ned_velocity;
let t_pcpf_to_eci = t_eci_pcpf.transpose();
let position = t_pcpf_to_eci * pcpf_pos;
let velocity = t_pcpf_to_eci * pcpf_vel + omega_planet.cross(position);
TranslationalState { position, velocity }
}
pub fn compute_ned_rotation(lat: f64, lon: f64) -> DMat3 {
let sin_lat = lat.sin();
let cos_lat = lat.cos();
let sin_lon = lon.sin();
let cos_lon = lon.cos();
let north = DVec3::new(-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat);
let east = DVec3::new(-sin_lon, cos_lon, 0.0);
let down = DVec3::new(-cos_lat * cos_lon, -cos_lat * sin_lon, -sin_lat);
mat3_from_rows(north, east, down)
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
const EARTH_MU: f64 = 3.986_004_415e14; const EARTH_R_EQ: f64 = 6_378_137.0; const EARTH_R_POL: f64 = EARTH_R_EQ * (1.0 - 1.0 / 298.257_223_563);
#[test]
fn circular_orbit_from_elements() {
let alt = 400_000.0; let r = EARTH_R_EQ + alt;
let a = r; let e = 0.0;
let inc = 0.0; let raan = 0.0;
let argp = 0.0;
let nu = 0.0;
let state = init_from_orbital_elements(a, e, inc, raan, argp, nu, EARTH_MU);
let r_mag = state.position.length();
assert!(
(r_mag - r).abs() < 1e-6,
"Position magnitude: expected {}, got {}, error = {} m",
r,
r_mag,
(r_mag - r).abs()
);
let v_circ = (EARTH_MU / r).sqrt();
let v_mag = state.velocity.length();
assert!(
(v_mag - v_circ).abs() < 1e-6,
"Velocity magnitude: expected {}, got {}, error = {} m/s",
v_circ,
v_mag,
(v_mag - v_circ).abs()
);
}
#[test]
fn iss_reference_state_from_elements() {
let init = astrodyn_verif_jeod_fixtures::orbital_init::load_orbital_init(
"ISS",
"trans_Orbit_inertial_body_set01",
);
let expected =
astrodyn_verif_jeod_fixtures::reference_state::load_reference_state("ISS", "inertial");
let t_peri = init
.time_periapsis
.expect("ISS set01 should have time_periapsis");
let state = init_from_time_periapsis(
init.semi_major_axis,
init.eccentricity,
init.inclination,
init.ascending_node,
init.arg_periapsis,
t_peri,
EARTH_MU,
);
let pos_err = (state.position - expected.position).length();
let vel_err = (state.velocity - expected.velocity).length();
println!("ISS position error: {:.2} m", pos_err);
println!("ISS velocity error: {:.6} m/s", vel_err);
println!(
"Computed pos: [{:.2}, {:.2}, {:.2}]",
state.position.x, state.position.y, state.position.z
);
println!(
"Expected pos: [{:.2}, {:.2}, {:.2}]",
expected.position.x, expected.position.y, expected.position.z
);
assert!(
pos_err < 1000.0,
"ISS position error {:.2} m exceeds 1 km tolerance",
pos_err
);
assert!(
vel_err < 1.0,
"ISS velocity error {:.6} m/s exceeds 1 m/s tolerance",
vel_err
);
}
#[test]
fn lvlh_zero_offset_returns_reference() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let ref_pos = DVec3::new(r, 0.0, 0.0);
let ref_vel = DVec3::new(0.0, v, 0.0);
let state = init_from_lvlh(DVec3::ZERO, DVec3::ZERO, ref_pos, ref_vel);
let pos_err = (state.position - ref_pos).length();
let vel_err = (state.velocity - ref_vel).length();
assert!(
pos_err < 1e-10,
"LVLH zero offset position error: {} m",
pos_err
);
assert!(
vel_err < 1e-10,
"LVLH zero offset velocity error: {} m/s",
vel_err
);
}
#[test]
fn lvlh_round_trip() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let inc = 51.6_f64.to_radians();
let ref_pos = DVec3::new(r, 0.0, 0.0);
let ref_vel = DVec3::new(0.0, v * inc.cos(), v * inc.sin());
let lvlh_offset_pos = DVec3::new(100.0, 20.0, 50.0); let lvlh_offset_vel = DVec3::new(0.1, 0.05, -0.02);
let state = init_from_lvlh(lvlh_offset_pos, lvlh_offset_vel, ref_pos, ref_vel);
use astrodyn_quantities::frame::{Earth, PlanetInertial};
let lvlh = astrodyn_math::LvlhFrame::compute(
ref_pos.m_at::<PlanetInertial<Earth>>(),
ref_vel.m_per_s_at::<PlanetInertial<Earth>>(),
);
let t = lvlh.t_parent_this;
let delta_pos = state.position - ref_pos;
let delta_vel = state.velocity - ref_vel;
let recovered_lvlh_pos = t * delta_pos;
let recovered_lvlh_vel = t * delta_vel;
let pos_err = (recovered_lvlh_pos - lvlh_offset_pos).length();
let vel_err = (recovered_lvlh_vel - lvlh_offset_vel).length();
assert!(
pos_err < 1e-10,
"LVLH round-trip position error: {} m",
pos_err
);
assert!(
vel_err < 1e-10,
"LVLH round-trip velocity error: {} m/s",
vel_err
);
}
#[test]
fn ned_equator_prime_meridian() {
let geodetic = GeodeticState {
latitude: 0.0,
longitude: 0.0,
altitude: 0.0,
};
let t_eci_pcpf = DMat3::IDENTITY;
let state = init_from_ned(
&geodetic,
DVec3::ZERO, EARTH_R_EQ,
EARTH_R_POL,
&t_eci_pcpf,
DVec3::ZERO, );
assert!(
(state.position.x - EARTH_R_EQ).abs() < 1e-6,
"Position X: expected {}, got {}",
EARTH_R_EQ,
state.position.x
);
assert!(
state.position.y.abs() < 1e-6,
"Position Y: expected 0, got {}",
state.position.y
);
assert!(
state.position.z.abs() < 1e-6,
"Position Z: expected 0, got {}",
state.position.z
);
}
#[test]
fn elements_round_trip() {
let a = 7_000_000.0; let e = 0.01;
let inc = 51.6_f64.to_radians();
let raan = 30.0_f64.to_radians();
let argp = 45.0_f64.to_radians();
let nu = 60.0_f64.to_radians();
let state = init_from_orbital_elements(a, e, inc, raan, argp, nu, EARTH_MU);
use astrodyn_quantities::frame::{Earth, PlanetInertial};
let oe = OrbitalElements::<Earth>::from_cartesian_typed(
astrodyn_quantities::ext::F64Ext::m3_per_s2_for::<Earth>(EARTH_MU),
state.position.m_at::<PlanetInertial<Earth>>(),
state.velocity.m_per_s_at::<PlanetInertial<Earth>>(),
)
.expect("from_cartesian_typed failed");
assert!(
(oe.semi_major_axis - a).abs() / a < 1e-10,
"semi_major_axis: expected {}, got {}, rel_err = {}",
a,
oe.semi_major_axis,
(oe.semi_major_axis - a).abs() / a
);
assert!(
(oe.e_mag - e).abs() < 1e-10,
"eccentricity: expected {}, got {}, error = {}",
e,
oe.e_mag,
(oe.e_mag - e).abs()
);
assert!(
(oe.inclination - inc).abs() < 1e-10,
"inclination: expected {}, got {}, error = {}",
inc,
oe.inclination,
(oe.inclination - inc).abs()
);
assert!(
(oe.long_asc_node - raan).abs() < 1e-10,
"RAAN: expected {}, got {}, error = {}",
raan,
oe.long_asc_node,
(oe.long_asc_node - raan).abs()
);
assert!(
(oe.arg_periapsis - argp).abs() < 1e-10,
"arg_periapsis: expected {}, got {}, error = {}",
argp,
oe.arg_periapsis,
(oe.arg_periapsis - argp).abs()
);
assert!(
(oe.true_anom - nu).abs() < 1e-10,
"true_anomaly: expected {}, got {}, error = {}",
nu,
oe.true_anom,
(oe.true_anom - nu).abs()
);
}
#[test]
fn mean_anomaly_agrees_with_true_anomaly_for_circular() {
let a = EARTH_R_EQ + 400_000.0;
let e = 0.0;
let inc = 0.0;
let raan = 0.0;
let argp = 0.0;
let nu = 1.0;
let state_true = init_from_orbital_elements(a, e, inc, raan, argp, nu, EARTH_MU);
let state_mean = init_from_mean_anomaly(a, e, inc, raan, argp, nu, EARTH_MU);
let pos_err = (state_true.position - state_mean.position).length();
let vel_err = (state_true.velocity - state_mean.velocity).length();
assert!(
pos_err < 1e-6,
"Circular orbit: true vs mean anomaly position error = {} m",
pos_err
);
assert!(
vel_err < 1e-6,
"Circular orbit: true vs mean anomaly velocity error = {} m/s",
vel_err
);
}
#[test]
fn ned_rotation_orthonormal() {
let test_cases = [
(0.0, 0.0), (PI / 4.0, PI / 3.0), (-PI / 6.0, -PI / 2.0), (PI / 2.0 - 0.01, 1.0), ];
for (lat, lon) in test_cases {
let t = compute_ned_rotation(lat, lon);
let product = t * t.transpose();
let diff = product - DMat3::IDENTITY;
assert!(
diff.x_axis.length() < 1e-14,
"NED rotation not orthonormal at lat={}, lon={}",
lat,
lon
);
assert!(diff.y_axis.length() < 1e-14);
assert!(diff.z_axis.length() < 1e-14);
assert!(
(t.determinant() - 1.0).abs() < 1e-14,
"NED rotation determinant != 1 at lat={}, lon={}",
lat,
lon
);
}
}
#[test]
fn ned_north_velocity_at_equator() {
let geodetic = GeodeticState {
latitude: 0.0,
longitude: 0.0,
altitude: 0.0,
};
let t_eci_pcpf = DMat3::IDENTITY;
let ned_vel = DVec3::new(1000.0, 0.0, 0.0); let state = init_from_ned(
&geodetic,
ned_vel,
EARTH_R_EQ,
EARTH_R_POL,
&t_eci_pcpf,
DVec3::ZERO,
);
assert!(
state.velocity.x.abs() < 1e-6,
"Vel X: expected 0, got {}",
state.velocity.x
);
assert!(
state.velocity.y.abs() < 1e-6,
"Vel Y: expected 0, got {}",
state.velocity.y
);
assert!(
(state.velocity.z - 1000.0).abs() < 1e-6,
"Vel Z: expected 1000, got {}",
state.velocity.z
);
}
#[test]
fn ned_omega_cross_r_contribution() {
let geodetic = GeodeticState {
latitude: 0.0,
longitude: 0.0,
altitude: 0.0,
};
let t_eci_pcpf = DMat3::IDENTITY;
let omega_earth = 7.292_115_0e-5; let omega = DVec3::new(0.0, 0.0, omega_earth);
let state = init_from_ned(
&geodetic,
DVec3::ZERO,
EARTH_R_EQ,
EARTH_R_POL,
&t_eci_pcpf,
omega,
);
let expected_vy = omega_earth * EARTH_R_EQ;
assert!(
state.velocity.x.abs() < 1e-6,
"Vel X: expected 0, got {}",
state.velocity.x
);
assert!(
(state.velocity.y - expected_vy).abs() < 1e-3,
"Vel Y: expected {:.1}, got {:.1}",
expected_vy,
state.velocity.y
);
assert!(
state.velocity.z.abs() < 1e-6,
"Vel Z: expected 0, got {}",
state.velocity.z
);
}
#[test]
fn lvlh_with_inclined_orbit() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let inc = 51.6_f64.to_radians();
let ref_pos = DVec3::new(r, 0.0, 0.0);
let ref_vel = DVec3::new(0.0, v * inc.cos(), v * inc.sin());
let state = init_from_lvlh(DVec3::ZERO, DVec3::ZERO, ref_pos, ref_vel);
assert!(
(state.position - ref_pos).length() < 1e-10,
"Inclined LVLH zero offset position error"
);
assert!(
(state.velocity - ref_vel).length() < 1e-10,
"Inclined LVLH zero offset velocity error"
);
let lvlh_pos = DVec3::new(0.0, 0.0, 1000.0);
let state_nadir = init_from_lvlh(lvlh_pos, DVec3::ZERO, ref_pos, ref_vel);
let r_offset = state_nadir.position.length();
assert!(
r_offset < r,
"1 km nadir offset should reduce position magnitude: {} vs {}",
r_offset,
r
);
let delta = (state_nadir.position - ref_pos).length();
assert!(
(delta - 1000.0).abs() < 1e-6,
"Offset magnitude: expected 1000 m, got {} m",
delta
);
}
#[test]
fn typed_orbital_init_matches_untyped_bit_for_bit() {
use astrodyn_quantities::ext::F64Ext;
use uom::si::angle::radian;
use uom::si::f64::{Angle, Length};
use uom::si::length::meter;
let alt = 400_000.0;
let r = EARTH_R_EQ + alt;
let a = r;
let e = 0.0;
let inc = 0.0;
let raan = 0.0;
let argp = 0.0;
let nu = 0.0;
let untyped = init_from_orbital_elements(a, e, inc, raan, argp, nu, EARTH_MU);
let typed = init_from_orbital_elements_typed(
Length::new::<meter>(a),
e,
Angle::new::<radian>(inc),
Angle::new::<radian>(raan),
Angle::new::<radian>(argp),
Angle::new::<radian>(nu),
EARTH_MU.m3_per_s2_for::<astrodyn_quantities::frame::Earth>(),
);
assert_eq!(typed.position.raw_si(), untyped.position);
assert_eq!(typed.velocity.raw_si(), untyped.velocity);
}
fn lvlh_frame_at(ref_pos: DVec3, ref_vel: DVec3) -> astrodyn_math::LvlhFrame {
use astrodyn_quantities::frame::{Earth, PlanetInertial};
astrodyn_math::LvlhFrame::compute(
ref_pos.m_at::<PlanetInertial<Earth>>(),
ref_vel.m_per_s_at::<PlanetInertial<Earth>>(),
)
}
#[test]
fn lvlh_rot_identity_attitude_zero_rate_recovers_lvlh_frame() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let inc = 51.6_f64.to_radians();
let ref_pos = DVec3::new(r, 0.0, 0.0);
let ref_vel = DVec3::new(0.0, v * inc.cos(), v * inc.sin());
let lvlh = lvlh_frame_at(ref_pos, ref_vel);
let expected_q = JeodQuat::left_quat_from_transformation(&lvlh.t_parent_this);
let state = init_rot_from_lvlh(
JeodQuat::identity(),
DVec3::ZERO,
LvlhAngularVelocityFrame::Body,
ref_pos,
ref_vel,
);
let dq: f64 = (0..4)
.map(|i| {
let a = state.quaternion.data[i];
let b = expected_q.data[i];
(a - b).powi(2)
})
.sum::<f64>()
.sqrt();
assert!(
dq < 1e-14,
"identity LVLH→body should match LVLH frame quaternion exactly: dq = {dq}"
);
let ang_err = (state.ang_vel_body - lvlh.ang_vel_this).length();
assert!(
ang_err < 1e-14,
"identity LVLH→body should recover LVLH ang vel: err = {ang_err}"
);
}
#[test]
fn lvlh_rot_inverse_rate_zeros_inertial_ang_vel() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let ref_pos = DVec3::new(r, 0.0, 0.0);
let ref_vel = DVec3::new(0.0, v, 0.0);
let lvlh = lvlh_frame_at(ref_pos, ref_vel);
let cancel = -lvlh.ang_vel_this;
let state = init_rot_from_lvlh(
JeodQuat::identity(),
cancel,
LvlhAngularVelocityFrame::Lvlh,
ref_pos,
ref_vel,
);
let mag = state.ang_vel_body.length();
assert!(
mag < 1e-14,
"cancelling LVLH-relative rate should null inertial ang vel: |w| = {mag}"
);
}
#[test]
fn lvlh_rot_nontrivial_attitude_round_trips() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let inc = 28.5_f64.to_radians();
let ref_pos = DVec3::new(r * 0.6, r * 0.8, 0.0);
let ref_vel = DVec3::new(-v * 0.8 * inc.cos(), v * 0.6 * inc.cos(), v * inc.sin());
let axis = DVec3::new(1.0, 2.0, 3.0).normalize();
let q_lvlh_body = JeodQuat::left_quat_from_eigen_rotation(1.2, axis);
let w_lvlh_body_in_body = DVec3::new(0.01, -0.02, 0.03);
let state = init_rot_from_lvlh(
q_lvlh_body,
w_lvlh_body_in_body,
LvlhAngularVelocityFrame::Body,
ref_pos,
ref_vel,
);
let lvlh = lvlh_frame_at(ref_pos, ref_vel);
let q_inertial_lvlh = JeodQuat::left_quat_from_transformation(&lvlh.t_parent_this);
let mut recovered = state.quaternion.multiply(&q_inertial_lvlh.conjugate());
recovered.normalize();
let dq: f64 = (0..4)
.map(|i| {
let a = recovered.data[i];
let b = q_lvlh_body.data[i];
(a - b).powi(2)
})
.sum::<f64>()
.sqrt();
assert!(
dq < 1e-12,
"recovered Q_lvlh_body should match input: dq = {dq}, recovered = {:?}, input = {:?}",
recovered.data,
q_lvlh_body.data
);
let t_lvlh_body = q_lvlh_body.left_quat_to_transformation();
let recovered_rate = state.ang_vel_body - t_lvlh_body * lvlh.ang_vel_this;
let rate_err = (recovered_rate - w_lvlh_body_in_body).length();
assert!(
rate_err < 1e-14,
"recovered LVLH-relative rate should match input: err = {rate_err}"
);
}
#[test]
fn lvlh_rot_body_vs_lvlh_rate_frame_agree_when_rotated_back() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let ref_pos = DVec3::new(r, 0.0, 0.0);
let ref_vel = DVec3::new(0.0, v, 0.0);
let axis = DVec3::new(0.0, 0.0, 1.0);
let q_lvlh_body = JeodQuat::left_quat_from_eigen_rotation(0.5, axis);
let w_in_lvlh = DVec3::new(0.001, -0.002, 0.003);
let t_lvlh_body = q_lvlh_body.left_quat_to_transformation();
let w_in_body = t_lvlh_body * w_in_lvlh;
let s_lvlh = init_rot_from_lvlh(
q_lvlh_body,
w_in_lvlh,
LvlhAngularVelocityFrame::Lvlh,
ref_pos,
ref_vel,
);
let s_body = init_rot_from_lvlh(
q_lvlh_body,
w_in_body,
LvlhAngularVelocityFrame::Body,
ref_pos,
ref_vel,
);
let dq: f64 = (0..4)
.map(|i| {
let a = s_lvlh.quaternion.data[i];
let b = s_body.quaternion.data[i];
(a - b).powi(2)
})
.sum::<f64>()
.sqrt();
assert!(dq < 1e-14, "quaternion mismatch across rate-frame: {dq}");
let dw = (s_lvlh.ang_vel_body - s_body.ang_vel_body).length();
assert!(dw < 1e-14, "ang vel mismatch across rate-frame: {dw}");
}
#[test]
fn lvlh_rot_renormalizes_off_unit_input_consistently() {
let r = EARTH_R_EQ + 400_000.0;
let v = (EARTH_MU / r).sqrt();
let inc = 51.6_f64.to_radians();
let ref_pos = DVec3::new(r, 0.0, 0.0);
let ref_vel = DVec3::new(0.0, v * inc.cos(), v * inc.sin());
let axis = DVec3::new(1.0, 2.0, 3.0).normalize();
let q_unit = JeodQuat::left_quat_from_eigen_rotation(0.5, axis);
let mut q_off = q_unit;
for d in q_off.data.iter_mut() {
*d *= 1.001;
}
let w_in_lvlh = DVec3::new(0.001, -0.002, 0.003);
let s_unit = init_rot_from_lvlh(
q_unit,
w_in_lvlh,
LvlhAngularVelocityFrame::Lvlh,
ref_pos,
ref_vel,
);
let s_off = init_rot_from_lvlh(
q_off,
w_in_lvlh,
LvlhAngularVelocityFrame::Lvlh,
ref_pos,
ref_vel,
);
let dq: f64 = (0..4)
.map(|i| (s_unit.quaternion.data[i] - s_off.quaternion.data[i]).powi(2))
.sum::<f64>()
.sqrt();
assert!(
dq < 1e-14,
"off-unit input must produce the same attitude as the pre-normalized input: dq = {dq}"
);
let dw = (s_unit.ang_vel_body - s_off.ang_vel_body).length();
assert!(
dw < 1e-14,
"off-unit input must produce the same ang vel as the pre-normalized input: dw = {dw}"
);
let lvlh = lvlh_frame_at(ref_pos, ref_vel);
let t_lvlh_body_unit = q_unit.left_quat_to_transformation();
let expected_w = t_lvlh_body_unit * lvlh.ang_vel_this + t_lvlh_body_unit * w_in_lvlh;
let err_unit = (s_unit.ang_vel_body - expected_w).length();
let err_off = (s_off.ang_vel_body - expected_w).length();
assert!(
err_unit < 1e-14,
"ang vel must match the renormalized-matrix formula (unit input): err = {err_unit}"
);
assert!(
err_off < 1e-14,
"ang vel must match the renormalized-matrix formula (off-unit input): err = {err_off}"
);
}
}