use astrodyn_atmosphere::met;
use astrodyn_dynamics::{
rk4_sixdof_step, rk4_translational_step, MassProperties, RotationalState, SixDofState,
TranslationalState,
};
use astrodyn_gravity::calc_spherical;
use astrodyn_interactions::{
compute_ballistic_drag, compute_flat_plate_srp_thermal, compute_gravity_torque,
compute_shadow_fraction, solar_flux_at_distance, DragConfig, FlatPlate, FlatPlateParams,
FlatPlateThermal, SOLAR_RADIUS,
};
use astrodyn_math::{GeodeticState, JeodQuat};
use astrodyn_quantities::body_constants::{
EARTH_MU as MU_EARTH, EARTH_R_EQ as R_EARTH, EARTH_R_POL as R_EARTH_POL,
};
use astrodyn_quantities::ext::Vec3Ext;
use glam::{DMat3, DVec3};
fn compute_gmst(tjt: f64) -> f64 {
let tjt_prev_midnight = tjt.floor();
let fraction_of_day = tjt - tjt_prev_midnight;
let century_days = tjt_prev_midnight + 24980.0;
let century_frac = (century_days + 0.5) / 36525.0;
let minutes_of_day = fraction_of_day * 1440.0;
let greenwich_mean_position = (99.6909833
+ 36000.76892 * century_frac
+ 0.00038708 * century_frac * century_frac
+ 0.250684477 * minutes_of_day)
.rem_euclid(360.0);
greenwich_mean_position * 0.017453293
}
#[test]
fn drag_causes_altitude_decay() {
let altitude = 400_000.0; let r0 = R_EARTH + altitude;
let v0 = (MU_EARTH / r0).sqrt();
let mut state = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let drag_config = DragConfig {
cd: 2.2,
area: 1900.0, constant_density: None,
};
let atmos = met::SOLAR_MEAN;
let mass = 420_000.0; let tjt_start = astrodyn_time::epoch::J2000_TAI_TJT;
let dt = 60.0; let total_time = 86400.0; let steps = (total_time / dt) as usize;
let initial_energy = 0.5 * v0 * v0 - MU_EARTH / r0;
for step in 0..steps {
let tjt = tjt_start + (step as f64 * dt) / 86400.0;
let new_state = rk4_translational_step(
&state,
|s, _t| {
let grav = calc_spherical(MU_EARTH, s.position).grav_accel;
let gmst = compute_gmst(tjt);
let (cos_g, sin_g) = (gmst.cos(), gmst.sin());
let pfix = DVec3::new(
cos_g * s.position.x + sin_g * s.position.y,
-sin_g * s.position.x + cos_g * s.position.y,
s.position.z,
);
let geo = GeodeticState::from_planet_fixed(pfix, R_EARTH, R_EARTH_POL);
let atmos_state =
atmos.density(geo.altitude / 1000.0, geo.latitude, geo.longitude, tjt);
let drag = compute_ballistic_drag(
&drag_config,
&atmos_state,
s.velocity,
&DMat3::IDENTITY,
);
grav + drag.force / mass
},
dt,
);
state = new_state;
}
let final_r = state.position.length();
let final_v = state.velocity.length();
let final_energy = 0.5 * final_v * final_v - MU_EARTH / final_r;
assert!(
final_energy < initial_energy,
"Drag should reduce orbital energy: initial={initial_energy}, final={final_energy}"
);
let initial_sma = -MU_EARTH / (2.0 * initial_energy);
let final_sma = -MU_EARTH / (2.0 * final_energy);
let sma_decay = initial_sma - final_sma;
assert!(sma_decay > 0.0, "Semi-major axis should decrease with drag");
assert!(
sma_decay > 50.0 && sma_decay < 1000.0,
"SMA should decay ~100-300 m over 24h with MET solar mean, got {} m",
sma_decay
);
}
#[test]
fn srp_changes_eccentricity() {
let altitude = 36_000_000.0; let r0 = R_EARTH + altitude;
let v0 = (MU_EARTH / r0).sqrt();
let mut state = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let plates =
vec![(
FlatPlate {
area: 100.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<astrodyn_quantities::frame::StructuralFrame<
astrodyn_quantities::frame::SelfRef,
>>(),
},
FlatPlateParams {
albedo: 0.0,
diffuse: 0.0,
},
FlatPlateThermal {
emissivity: f64::EPSILON,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
},
)];
let t_pow4_cached = vec![270.0_f64.powi(4)];
let sun_pos = DVec3::new(1.496e11, 0.0, 0.0);
let mass = 1000.0;
let dt = 60.0;
let total_time = 86400.0 * 7.0; let steps = (total_time / dt) as usize;
for _ in 0..steps {
let new_state = rk4_translational_step(
&state,
|s, _t| {
let grav = calc_spherical(MU_EARTH, s.position).grav_accel;
let sun_to_vehicle = s.position - sun_pos;
let dist = sun_to_vehicle.length();
if dist < 1.0 {
return grav;
}
let flux_hat = sun_to_vehicle / dist;
let flux_mag = solar_flux_at_distance(dist);
let srp = compute_flat_plate_srp_thermal(
&plates,
&t_pow4_cached,
flux_hat,
flux_mag,
DVec3::ZERO,
1.0,
);
grav + srp.force / mass
},
dt,
);
state = new_state;
}
let r_vec = state.position;
let v_vec = state.velocity;
let r = r_vec.length();
let h = r_vec.cross(v_vec);
let e_vec = v_vec.cross(h) / MU_EARTH - r_vec / r;
let eccentricity = e_vec.length();
assert!(
eccentricity > 1e-8,
"SRP should introduce eccentricity, got {eccentricity}"
);
assert!(
eccentricity < 0.01,
"Eccentricity should be small, got {eccentricity}"
);
}
#[test]
fn gravity_torque_causes_libration() {
let altitude = 400_000.0;
let r0 = R_EARTH + altitude;
let v0 = (MU_EARTH / r0).sqrt();
let inertia = DMat3::from_diagonal(DVec3::new(1e7, 5e7, 5e7));
let inverse_inertia = DMat3::from_diagonal(DVec3::new(1e-7, 2e-8, 2e-8));
let mass_props = MassProperties {
mass: 420_000.0,
inverse_mass: 1.0 / 420_000.0,
inertia,
inverse_inertia,
position: DVec3::ZERO,
t_parent_this: DMat3::IDENTITY,
dirty: false,
};
let theta = 5.0_f64.to_radians();
let q = JeodQuat::new((theta / 2.0).cos(), 0.0, (theta / 2.0).sin(), 0.0);
let mut state = SixDofState {
trans: TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
},
rot: RotationalState {
quaternion: q,
ang_vel_body: DVec3::ZERO,
},
};
let dt = 1.0;
let steps = 3600; let mut max_omega = 0.0_f64;
for _ in 0..steps {
let grad = calc_spherical(MU_EARTH, state.trans.position).grav_grad;
let t_parent_this = state.rot.quaternion.left_quat_to_transformation();
let grav_torque = compute_gravity_torque(&grad, &t_parent_this, &inertia);
let new_state = rk4_sixdof_step(
&state,
|s, _t| calc_spherical(MU_EARTH, s.trans.position).grav_accel,
|_s| grav_torque, &mass_props,
dt,
);
max_omega = max_omega.max(new_state.rot.ang_vel_body.length());
state = new_state;
}
assert!(
max_omega > 1e-6,
"Gravity torque should cause angular motion, max omega = {max_omega}"
);
assert!(
max_omega < 0.1,
"Libration should be bounded, max omega = {max_omega} rad/s"
);
}
#[test]
fn leo_eclipse_fraction() {
let altitude = 400_000.0;
let r0 = R_EARTH + altitude;
let v0 = (MU_EARTH / r0).sqrt();
let period = 2.0 * std::f64::consts::PI * (r0.powi(3) / MU_EARTH).sqrt();
let sun_pos = DVec3::new(1.496e11, 0.0, 0.0);
let body_pos = DVec3::ZERO;
let dt = 10.0;
let steps = (period / dt) as usize;
let mut shadow_count = 0;
let mut state = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
for _ in 0..steps {
let frac =
compute_shadow_fraction(state.position, sun_pos, body_pos, R_EARTH, SOLAR_RADIUS);
if frac < 0.5 {
shadow_count += 1;
}
state = rk4_translational_step(
&state,
|s, _t| calc_spherical(MU_EARTH, s.position).grav_accel,
dt,
);
}
let eclipse_fraction = shadow_count as f64 / steps as f64;
assert!(
eclipse_fraction > 0.25 && eclipse_fraction < 0.45,
"Eclipse fraction should be ~35%, got {:.1}%",
eclipse_fraction * 100.0
);
}