use astrodyn_atmosphere::AtmosphereState;
use astrodyn_quantities::aliases::{Force, Velocity};
use astrodyn_quantities::frame::{Planet, PlanetInertial, SelfPlanet, StructuralFrame, Vehicle};
use glam::{DMat3, DVec3};
use uom::si::f64::{Area, MassDensity, Ratio};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DragConfig {
pub cd: f64,
pub area: f64,
pub constant_density: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AerodynamicForce {
pub force: DVec3,
pub torque: DVec3,
}
impl Default for AerodynamicForce {
fn default() -> Self {
Self {
force: DVec3::ZERO,
torque: DVec3::ZERO,
}
}
}
pub fn compute_ballistic_drag(
config: &DragConfig,
atmos: &AtmosphereState<SelfPlanet>,
inertial_velocity: DVec3,
t_inertial_struct: &DMat3,
) -> AerodynamicForce {
let density = config.constant_density.unwrap_or(atmos.density);
if density <= 0.0 {
return AerodynamicForce::default();
}
let relative_vel_cm = inertial_velocity - atmos.wind.raw_si();
let rel_vel_cm_struct = *t_inertial_struct * relative_vel_cm;
let rel_vel_mag = rel_vel_cm_struct.length();
if rel_vel_mag < 1e-10 {
return AerodynamicForce::default();
}
let rel_vel_struct_hat = rel_vel_cm_struct / rel_vel_mag;
let dynamic_pressure = 0.5 * density * rel_vel_mag * rel_vel_mag;
let drag = -dynamic_pressure * config.area * config.cd;
let force = rel_vel_struct_hat * drag;
AerodynamicForce {
force,
torque: DVec3::ZERO,
}
}
#[derive(Debug, Clone, Copy)]
pub struct DragConfigTyped {
pub cd: Ratio,
pub area: Area,
pub constant_density: Option<MassDensity>,
}
impl DragConfigTyped {
pub fn to_untyped(&self) -> DragConfig {
DragConfig {
cd: self.cd.value,
area: self.area.value,
constant_density: self.constant_density.map(|d| d.value),
}
}
pub fn from_untyped_unchecked(c: &DragConfig) -> Self {
Self {
cd: Ratio::new::<uom::si::ratio::ratio>(c.cd),
area: Area::new::<uom::si::area::square_meter>(c.area),
constant_density: c
.constant_density
.map(MassDensity::new::<uom::si::mass_density::kilogram_per_cubic_meter>),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AerodynamicForceTyped<V: Vehicle> {
pub force: Force<StructuralFrame<V>>,
pub torque: astrodyn_quantities::aliases::Torque<StructuralFrame<V>>,
}
impl<V: Vehicle> Default for AerodynamicForceTyped<V> {
#[inline]
fn default() -> Self {
Self {
force: Force::<StructuralFrame<V>>::zero(),
torque: astrodyn_quantities::aliases::Torque::<StructuralFrame<V>>::zero(),
}
}
}
impl<V: Vehicle> AerodynamicForceTyped<V> {
#[inline]
pub fn to_untyped(&self) -> AerodynamicForce {
AerodynamicForce {
force: self.force.raw_si(),
torque: self.torque.raw_si(),
}
}
#[inline]
pub fn from_untyped_unchecked(a: &AerodynamicForce) -> Self {
Self {
force: Force::<StructuralFrame<V>>::from_raw_si(a.force),
torque: astrodyn_quantities::aliases::Torque::<StructuralFrame<V>>::from_raw_si(
a.torque,
),
}
}
}
pub fn compute_ballistic_drag_typed<P: Planet, V: Vehicle>(
config: &DragConfigTyped,
atmos: &AtmosphereState<P>,
inertial_velocity: Velocity<PlanetInertial<P>>,
t_inertial_struct: &DMat3,
) -> AerodynamicForceTyped<V> {
let atmos_self = AtmosphereState::<SelfPlanet>::from_raw(
atmos.density,
atmos.temperature,
atmos.pressure,
atmos.wind.raw_si(),
);
let untyped = compute_ballistic_drag(
&config.to_untyped(),
&atmos_self,
inertial_velocity.raw_si(),
t_inertial_struct,
);
AerodynamicForceTyped::<V>::from_untyped_unchecked(&untyped)
}
#[cfg(test)]
mod typed_tests {
use super::*;
use uom::si::area::square_meter;
use uom::si::f64::{Area, MassDensity, Ratio};
use uom::si::mass_density::kilogram_per_cubic_meter;
use uom::si::ratio::ratio;
#[test]
fn drag_config_typed_round_trip() {
let untyped = DragConfig {
cd: 2.2,
area: 4.5,
constant_density: Some(1e-12),
};
let typed = DragConfigTyped::from_untyped_unchecked(&untyped);
let back = typed.to_untyped();
assert_eq!(back.cd, untyped.cd);
assert_eq!(back.area, untyped.area);
assert_eq!(back.constant_density, untyped.constant_density);
}
#[test]
fn drag_config_typed_constant_density_none() {
let untyped = DragConfig {
cd: 2.0,
area: 1.0,
constant_density: None,
};
let typed = DragConfigTyped::from_untyped_unchecked(&untyped);
assert!(typed.constant_density.is_none());
}
#[test]
fn drag_config_typed_constructed_directly() {
let typed = DragConfigTyped {
cd: Ratio::new::<ratio>(2.2),
area: Area::new::<square_meter>(4.5),
constant_density: Some(MassDensity::new::<kilogram_per_cubic_meter>(1e-12)),
};
let untyped = typed.to_untyped();
assert_eq!(untyped.cd, 2.2);
assert_eq!(untyped.area, 4.5);
assert_eq!(untyped.constant_density, Some(1e-12));
}
#[test]
fn compute_ballistic_drag_typed_matches_untyped() {
use astrodyn_quantities::ext::F64Ext;
use astrodyn_quantities::frame::TestVehicle;
let config = DragConfig {
cd: 2.2,
area: 4.5,
constant_density: None,
};
let atmos =
AtmosphereState::<SelfPlanet>::from_raw(1e-12, 0.0, 0.0, DVec3::new(0.0, 50.0, 0.0));
let velocity = DVec3::new(7500.0, 0.0, 0.0);
let theta = std::f64::consts::FRAC_PI_6;
let (s, c) = theta.sin_cos();
let t_is = DMat3::from_cols(
DVec3::new(c, -s, 0.0),
DVec3::new(s, c, 0.0),
DVec3::new(0.0, 0.0, 1.0),
);
let untyped = compute_ballistic_drag(&config, &atmos, velocity, &t_is);
let atmos_earth = atmos.relabel::<astrodyn_quantities::frame::Earth>();
let typed = compute_ballistic_drag_typed::<astrodyn_quantities::frame::Earth, TestVehicle>(
&DragConfigTyped::from_untyped_unchecked(&config),
&atmos_earth,
Velocity::<PlanetInertial<astrodyn_quantities::frame::Earth>>::from_raw_si(velocity),
&t_is,
);
let typed_untyped = typed.to_untyped();
assert_eq!(typed_untyped.force, untyped.force);
assert_eq!(typed_untyped.torque, untyped.torque);
assert_eq!(typed.force.raw_si(), untyped.force);
let from_ext = compute_ballistic_drag_typed::<astrodyn_quantities::frame::Earth, TestVehicle>(
&DragConfigTyped {
cd: Ratio::new::<ratio>(2.2),
area: 4.5_f64.m2(),
constant_density: None,
},
&atmos_earth,
Velocity::<PlanetInertial<astrodyn_quantities::frame::Earth>>::from_raw_si(velocity),
&t_is,
);
assert_eq!(from_ext.force.raw_si(), untyped.force);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_drag_magnitude() {
let config = DragConfig {
cd: 2.2,
area: 10.0, constant_density: None,
};
let density = 1e-12; let velocity = 7600.0;
let atmos = AtmosphereState::<SelfPlanet>::from_raw(density, 0.0, 0.0, DVec3::ZERO);
let vel = DVec3::new(velocity, 0.0, 0.0);
let t = DMat3::IDENTITY;
let result = compute_ballistic_drag(&config, &atmos, vel, &t);
let expected_mag = 0.5 * density * velocity * velocity * config.cd * config.area;
let force_mag = result.force.length();
let rel_err = (force_mag - expected_mag).abs() / expected_mag;
assert!(
rel_err < 1e-12,
"Drag magnitude: expected {expected_mag}, got {force_mag}"
);
assert!(result.force.x < 0.0, "Drag should oppose motion");
assert!(result.force.y.abs() < 1e-20, "No Y component");
assert!(result.force.z.abs() < 1e-20, "No Z component");
}
#[test]
fn zero_density_zero_drag() {
let config = DragConfig {
cd: 2.2,
area: 10.0,
constant_density: None,
};
let atmos = AtmosphereState::<SelfPlanet>::default(); let vel = DVec3::new(7600.0, 0.0, 0.0);
let result = compute_ballistic_drag(&config, &atmos, vel, &DMat3::IDENTITY);
assert_eq!(result.force, DVec3::ZERO);
}
#[test]
fn drag_opposes_relative_velocity() {
let config = DragConfig {
cd: 2.0,
area: 1.0,
constant_density: None,
};
let atmos = AtmosphereState::<SelfPlanet>::from_raw(
1e-12,
0.0,
0.0,
DVec3::new(100.0, 0.0, 0.0), );
let vel = DVec3::new(7600.0, 0.0, 0.0);
let result = compute_ballistic_drag(&config, &atmos, vel, &DMat3::IDENTITY);
assert!(result.force.x < 0.0, "Drag should oppose relative velocity");
let atmos_no_wind = AtmosphereState::<SelfPlanet>::from_raw(
atmos.density,
atmos.temperature,
atmos.pressure,
DVec3::ZERO,
);
let result_no_wind = compute_ballistic_drag(&config, &atmos_no_wind, vel, &DMat3::IDENTITY);
assert!(
result.force.x.abs() < result_no_wind.force.x.abs(),
"Wind reduces relative velocity, thus reduces drag"
);
}
#[test]
fn ballistic_torque_is_zero() {
let config = DragConfig {
cd: 2.2,
area: 10.0,
constant_density: None,
};
let atmos = AtmosphereState::<SelfPlanet>::from_raw(1e-12, 0.0, 0.0, DVec3::ZERO);
let vel = DVec3::new(7600.0, 0.0, 0.0);
let result = compute_ballistic_drag(&config, &atmos, vel, &DMat3::IDENTITY);
assert_eq!(result.torque, DVec3::ZERO);
}
#[test]
fn drag_in_structural_frame() {
let config = DragConfig {
cd: 2.0,
area: 1.0,
constant_density: None,
};
let atmos = AtmosphereState::<SelfPlanet>::from_raw(1e-12, 0.0, 0.0, DVec3::ZERO);
let vel = DVec3::new(7600.0, 0.0, 0.0);
let t = DMat3::from_cols(
DVec3::new(0.0, 1.0, 0.0),
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(0.0, 0.0, 1.0),
);
let result = compute_ballistic_drag(&config, &atmos, vel, &t);
assert!(result.force.x.abs() < 1e-20, "No X component in body frame");
assert!(result.force.y < 0.0, "Drag along -Y in body frame");
assert!(result.force.z.abs() < 1e-20, "No Z component in body frame");
}
#[test]
fn iss_altitude_loss_order_of_magnitude() {
let mass = 420_000.0; let config = DragConfig {
cd: 2.2,
area: 1900.0, constant_density: None,
};
let density = 4e-12; let atmos = AtmosphereState::<SelfPlanet>::from_raw(density, 0.0, 0.0, DVec3::ZERO);
let velocity = 7670.0; let vel = DVec3::new(velocity, 0.0, 0.0);
let result = compute_ballistic_drag(&config, &atmos, vel, &DMat3::IDENTITY);
let drag_accel = result.force.length() / mass;
let a = 6_778_000.0; let da_dt = 2.0 * a * drag_accel / velocity; let da_day = da_dt * 86400.0;
assert!(
da_day > 50.0 && da_day < 1000.0,
"ISS altitude loss should be ~100-300 m/day, got {} m/day",
da_day
);
}
use astrodyn_quantities::frame::TestVehicle;
use proptest::prelude::*;
fn arb_finite_bounded() -> impl Strategy<Value = f64> {
prop_oneof![
(1.0e-9_f64..1.0e9_f64),
(1.0e-9_f64..1.0e9_f64).prop_map(|x| -x),
]
}
fn arb_dvec3() -> impl Strategy<Value = DVec3> {
(
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
)
.prop_map(|(x, y, z)| DVec3::new(x, y, z))
}
fn arb_drag_config() -> impl Strategy<Value = DragConfig> {
(
arb_finite_bounded(),
arb_finite_bounded(),
proptest::option::of(arb_finite_bounded()),
)
.prop_map(|(cd, area, constant_density)| DragConfig {
cd,
area,
constant_density,
})
}
fn arb_aerodynamic_force() -> impl Strategy<Value = AerodynamicForce> {
(arb_dvec3(), arb_dvec3()).prop_map(|(force, torque)| AerodynamicForce { force, torque })
}
proptest! {
#[test]
fn round_trip_drag_config_untyped_typed_untyped(orig in arb_drag_config()) {
let typed = DragConfigTyped::from_untyped_unchecked(&orig);
prop_assert_eq!(typed.to_untyped(), orig);
}
#[test]
fn round_trip_drag_config_typed_untyped_typed(orig in arb_drag_config()) {
let typed = DragConfigTyped::from_untyped_unchecked(&orig);
let lifted = DragConfigTyped::from_untyped_unchecked(&typed.to_untyped());
prop_assert_eq!(lifted.to_untyped(), typed.to_untyped());
}
#[test]
fn round_trip_aerodynamic_force_untyped_typed_untyped(orig in arb_aerodynamic_force()) {
let typed = AerodynamicForceTyped::<TestVehicle>::from_untyped_unchecked(&orig);
prop_assert_eq!(typed.to_untyped(), orig);
}
#[test]
fn round_trip_aerodynamic_force_typed_untyped_typed(orig in arb_aerodynamic_force()) {
let typed = AerodynamicForceTyped::<TestVehicle>::from_untyped_unchecked(&orig);
let lifted = AerodynamicForceTyped::<TestVehicle>::from_untyped_unchecked(&typed.to_untyped());
prop_assert_eq!(lifted.to_untyped(), typed.to_untyped());
}
}
}