use crate::model::{HasAttitude, HasMass, HasOrbit, Model};
use crate::perturbations::OMEGA_EARTH;
use arika::body::KnownBody;
use arika::earth::R as R_EARTH;
use arika::epoch::Epoch;
use nalgebra::Vector3;
use tobari::{AtmosphereInput, AtmosphereModel, Exponential};
use super::ExternalLoads;
#[derive(Debug, Clone, PartialEq)]
pub struct SurfacePanel {
pub area: f64,
pub normal: Vector3<f64>,
pub cd: f64,
pub cr: f64,
pub cp_offset: Vector3<f64>,
}
impl SurfacePanel {
pub fn at_com(area: f64, normal: Vector3<f64>, cd: f64) -> Self {
let n = normal.normalize();
assert!(n.magnitude() > 0.5, "Panel normal must be non-zero");
Self {
area,
normal: n,
cd,
cr: 1.5,
cp_offset: Vector3::zeros(),
}
}
pub fn with_cr(mut self, cr: f64) -> Self {
self.cr = cr;
self
}
}
#[derive(Debug, Clone)]
pub enum SpacecraftShape {
Sphere {
area: f64,
cd: f64,
cr: f64,
},
Panels(Vec<SurfacePanel>),
}
impl SpacecraftShape {
pub fn sphere(area: f64, cd: f64, cr: f64) -> Self {
assert!(area > 0.0, "area must be positive");
assert!(cd >= 0.0, "cd must be non-negative");
assert!(cr >= 0.0, "cr must be non-negative");
Self::Sphere { area, cd, cr }
}
pub fn panels(panels: Vec<SurfacePanel>) -> Self {
Self::Panels(panels)
}
pub fn cube(half_size: f64, cd: f64, cr: f64) -> Self {
let face_area = (2.0 * half_size) * (2.0 * half_size);
let panels = vec![
SurfacePanel {
area: face_area,
normal: Vector3::new(1.0, 0.0, 0.0),
cd,
cr,
cp_offset: Vector3::new(half_size, 0.0, 0.0),
},
SurfacePanel {
area: face_area,
normal: Vector3::new(-1.0, 0.0, 0.0),
cd,
cr,
cp_offset: Vector3::new(-half_size, 0.0, 0.0),
},
SurfacePanel {
area: face_area,
normal: Vector3::new(0.0, 1.0, 0.0),
cd,
cr,
cp_offset: Vector3::new(0.0, half_size, 0.0),
},
SurfacePanel {
area: face_area,
normal: Vector3::new(0.0, -1.0, 0.0),
cd,
cr,
cp_offset: Vector3::new(0.0, -half_size, 0.0),
},
SurfacePanel {
area: face_area,
normal: Vector3::new(0.0, 0.0, 1.0),
cd,
cr,
cp_offset: Vector3::new(0.0, 0.0, half_size),
},
SurfacePanel {
area: face_area,
normal: Vector3::new(0.0, 0.0, -1.0),
cd,
cr,
cp_offset: Vector3::new(0.0, 0.0, -half_size),
},
];
Self::Panels(panels)
}
}
pub struct PanelDrag {
shape: SpacecraftShape,
atmosphere: Box<dyn AtmosphereModel>,
body: Option<KnownBody>,
body_radius: f64,
omega_body: f64,
}
impl PanelDrag {
pub fn for_earth(shape: SpacecraftShape) -> Self {
Self {
shape,
atmosphere: Box::new(Exponential),
body: Some(KnownBody::Earth),
body_radius: R_EARTH,
omega_body: OMEGA_EARTH,
}
}
pub fn with_atmosphere(mut self, model: Box<dyn AtmosphereModel>) -> Self {
self.atmosphere = model;
self
}
}
impl PanelDrag {
fn is_inside(&self, position: &Vector3<f64>) -> bool {
match self.body {
Some(KnownBody::Earth) => {
let p2 = position.x * position.x + position.y * position.y;
let z2 = position.z * position.z;
p2 / (arika::earth::ellipsoid::WGS84_A * arika::earth::ellipsoid::WGS84_A)
+ z2 / (arika::earth::ellipsoid::WGS84_B * arika::earth::ellipsoid::WGS84_B)
< 1.0
}
_ => position.magnitude() < self.body_radius,
}
}
fn relative_velocity_from_orbit(&self, orbit: &crate::OrbitalState) -> Vector3<f64> {
let omega = Vector3::new(0.0, 0.0, self.omega_body);
*orbit.velocity() - omega.cross(orbit.position())
}
pub(crate) fn loads_from_state(
&self,
orbit: &crate::OrbitalState,
attitude: &crate::attitude::AttitudeState,
mass: f64,
epoch: Option<&Epoch<arika::epoch::Utc>>,
) -> ExternalLoads {
if self.is_inside(orbit.position()) {
return ExternalLoads::zeros();
}
let pos_eci = orbit.position_eci();
let dummy_epoch = arika::epoch::Epoch::from_jd(2451545.0);
let utc = epoch.unwrap_or(&dummy_epoch);
let geodetic = {
let gmst = utc.gmst();
let rot = arika::frame::Rotation::<
arika::frame::SimpleEci,
arika::frame::SimpleEcef,
>::from_era(gmst);
rot.transform(&pos_eci).to_geodetic()
};
let rho = self.atmosphere.density(&AtmosphereInput { geodetic, utc });
if rho == 0.0 {
return ExternalLoads::zeros();
}
let v_rel = self.relative_velocity_from_orbit(orbit);
let v_rel_mag_km = v_rel.magnitude();
if v_rel_mag_km < 1e-10 {
return ExternalLoads::zeros();
}
match &self.shape {
SpacecraftShape::Sphere { area, cd, .. } => {
let v_rel_m = v_rel * 1000.0; let v_rel_mag_m = v_rel_m.magnitude();
let a_drag_m = -0.5 * rho * cd * area / mass * v_rel_mag_m * v_rel_m;
ExternalLoads {
acceleration_inertial: arika::frame::Vec3::from_raw(a_drag_m / 1000.0), torque_body: arika::frame::Vec3::zeros(),
mass_rate: 0.0,
}
}
SpacecraftShape::Panels(panels) => {
let v_body = attitude
.rotation_to_body()
.transform(&arika::frame::Vec3::from_raw(v_rel))
.into_inner(); let v_body_m = v_body * 1000.0; let v_body_mag_m = v_body_m.magnitude();
let v_hat_body = v_body_m / v_body_mag_m;
let mut total_force_body = Vector3::zeros(); let mut total_torque_body = Vector3::zeros();
for panel in panels {
let cos_theta = panel.normal.dot(&(-v_hat_body)).max(0.0);
if cos_theta <= 0.0 {
continue;
}
let a_proj = panel.area * cos_theta;
let force =
-0.5 * rho * panel.cd * a_proj * v_body_mag_m * v_body_mag_m * v_hat_body;
total_force_body += force;
total_torque_body += panel.cp_offset.cross(&force);
}
let a_body = arika::frame::Vec3::from_raw(total_force_body / mass);
let a_inertial = attitude.rotation_to_eci().transform(&a_body) / 1000.0;
ExternalLoads {
acceleration_inertial: a_inertial,
torque_body: arika::frame::Vec3::from_raw(total_torque_body),
mass_rate: 0.0,
}
}
}
}
}
impl<S: HasAttitude + HasOrbit<Frame = arika::frame::SimpleEci> + HasMass> Model<S> for PanelDrag {
fn name(&self) -> &str {
"panel_drag"
}
fn eval(&self, _t: f64, state: &S, epoch: Option<&Epoch>) -> ExternalLoads {
self.loads_from_state(state.orbit(), state.attitude(), state.mass(), epoch)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SpacecraftState;
#[test]
fn at_com_zero_cp_offset() {
let p = SurfacePanel::at_com(2.0, Vector3::new(1.0, 0.0, 0.0), 2.2);
assert_eq!(p.cp_offset, Vector3::zeros());
}
#[test]
fn at_com_normalises_normal() {
let p = SurfacePanel::at_com(1.0, Vector3::new(3.0, 4.0, 0.0), 2.0);
let expected = Vector3::new(0.6, 0.8, 0.0);
assert!(
(p.normal - expected).magnitude() < 1e-15,
"Normal should be normalised, got {:?}",
p.normal
);
}
#[test]
fn at_com_already_unit() {
let n = Vector3::new(0.0, 0.0, 1.0);
let p = SurfacePanel::at_com(5.0, n, 2.2);
assert!((p.normal - n).magnitude() < 1e-15);
}
#[test]
#[should_panic]
fn at_com_zero_normal_panics() {
SurfacePanel::at_com(1.0, Vector3::zeros(), 2.0);
}
#[test]
fn at_com_preserves_area_and_cd() {
let p = SurfacePanel::at_com(3.5, Vector3::new(0.0, 1.0, 0.0), 2.1);
assert!((p.area - 3.5).abs() < 1e-15);
assert!((p.cd - 2.1).abs() < 1e-15);
}
#[test]
fn sphere_variant() {
let shape = SpacecraftShape::sphere(10.0, 2.2, 1.5);
match shape {
SpacecraftShape::Sphere { area, cd, cr } => {
assert!((area - 10.0).abs() < 1e-15);
assert!((cd - 2.2).abs() < 1e-15);
assert!((cr - 1.5).abs() < 1e-15);
}
_ => panic!("Expected Sphere variant"),
}
}
#[test]
#[should_panic(expected = "area must be positive")]
fn sphere_zero_area_panics() {
SpacecraftShape::sphere(0.0, 2.2, 1.5);
}
#[test]
#[should_panic(expected = "cd must be non-negative")]
fn sphere_negative_cd_panics() {
SpacecraftShape::sphere(10.0, -1.0, 1.5);
}
#[test]
#[should_panic(expected = "cr must be non-negative")]
fn sphere_negative_cr_panics() {
SpacecraftShape::sphere(10.0, 2.2, -0.1);
}
#[test]
fn panels_stores_panels() {
let panels = vec![
SurfacePanel::at_com(1.0, Vector3::new(1.0, 0.0, 0.0), 2.0),
SurfacePanel::at_com(2.0, Vector3::new(0.0, 1.0, 0.0), 2.2),
];
let shape = SpacecraftShape::panels(panels.clone());
match shape {
SpacecraftShape::Panels(p) => {
assert_eq!(p.len(), 2);
assert!((p[0].area - 1.0).abs() < 1e-15);
assert!((p[1].area - 2.0).abs() < 1e-15);
}
_ => panic!("Expected Panels variant"),
}
}
#[test]
fn cube_has_six_panels() {
let shape = SpacecraftShape::cube(0.5, 2.2, 1.5);
match &shape {
SpacecraftShape::Panels(panels) => {
assert_eq!(panels.len(), 6, "Cube should have 6 faces");
}
_ => panic!("Expected Panels variant"),
}
}
#[test]
fn cube_face_area() {
let half = 0.5;
let expected_area = (2.0 * half) * (2.0 * half); let shape = SpacecraftShape::cube(half, 2.2, 1.5);
if let SpacecraftShape::Panels(panels) = &shape {
for (i, p) in panels.iter().enumerate() {
assert!(
(p.area - expected_area).abs() < 1e-15,
"Panel {i} area: expected {expected_area}, got {}",
p.area
);
}
}
}
#[test]
fn cube_normals_are_unit() {
let shape = SpacecraftShape::cube(1.0, 2.0, 1.5);
if let SpacecraftShape::Panels(panels) = &shape {
for (i, p) in panels.iter().enumerate() {
assert!(
(p.normal.magnitude() - 1.0).abs() < 1e-15,
"Panel {i} normal not unit: magnitude = {}",
p.normal.magnitude()
);
}
}
}
#[test]
fn cube_normals_are_axis_aligned() {
let shape = SpacecraftShape::cube(1.0, 2.0, 1.5);
if let SpacecraftShape::Panels(panels) = &shape {
let normals: Vec<_> = panels.iter().map(|p| p.normal).collect();
let expected = [
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(-1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(0.0, -1.0, 0.0),
Vector3::new(0.0, 0.0, 1.0),
Vector3::new(0.0, 0.0, -1.0),
];
for (i, (n, e)) in normals.iter().zip(expected.iter()).enumerate() {
assert!(
(n - e).magnitude() < 1e-15,
"Panel {i}: expected normal {e:?}, got {n:?}"
);
}
}
}
#[test]
fn cube_cp_at_face_centre() {
let half = 0.75;
let shape = SpacecraftShape::cube(half, 2.0, 1.5);
if let SpacecraftShape::Panels(panels) = &shape {
for (i, p) in panels.iter().enumerate() {
let expected_cp = p.normal * half;
assert!(
(p.cp_offset - expected_cp).magnitude() < 1e-15,
"Panel {i}: expected CP {expected_cp:?}, got {:?}",
p.cp_offset
);
}
}
}
#[test]
fn cube_all_same_cd() {
let cd = 2.2;
let shape = SpacecraftShape::cube(0.5, cd, 1.5);
if let SpacecraftShape::Panels(panels) = &shape {
for (i, p) in panels.iter().enumerate() {
assert!(
(p.cd - cd).abs() < 1e-15,
"Panel {i} cd: expected {cd}, got {}",
p.cd
);
}
}
}
#[test]
fn cube_opposite_normals_cancel() {
let shape = SpacecraftShape::cube(1.0, 2.0, 1.5);
if let SpacecraftShape::Panels(panels) = &shape {
let normal_sum: Vector3<f64> = panels.iter().map(|p| p.normal).sum();
assert!(
normal_sum.magnitude() < 1e-14,
"Opposite normals should cancel: sum = {normal_sum:?}"
);
}
}
#[test]
fn panel_drag_name() {
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(10.0, 2.2, 1.5));
assert_eq!(Model::<SpacecraftState>::name(&drag), "panel_drag");
}
#[test]
fn panel_drag_for_earth_defaults() {
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(10.0, 2.2, 1.5));
assert_eq!(drag.body, Some(KnownBody::Earth));
assert!((drag.body_radius - R_EARTH).abs() < 1e-10);
assert!((drag.omega_body - OMEGA_EARTH).abs() < 1e-15);
}
#[test]
fn panel_drag_with_atmosphere_builder() {
use tobari::HarrisPriester;
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(10.0, 2.2, 1.5))
.with_atmosphere(Box::new(HarrisPriester::new()));
assert_eq!(Model::<SpacecraftState>::name(&drag), "panel_drag");
}
use crate::OrbitalState;
use crate::attitude::AttitudeState;
use nalgebra::Vector4;
fn iss_state() -> SpacecraftState {
let r = R_EARTH + 400.0;
let v = (arika::earth::MU / r).sqrt();
SpacecraftState {
orbit: OrbitalState::new(Vector3::new(r, 0.0, 0.0), Vector3::new(0.0, v, 0.0)),
attitude: AttitudeState::identity(),
mass: 500.0,
}
}
#[test]
fn sphere_nonzero_drag_at_iss() {
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(5.0, 2.0, 1.5));
let loads = drag.eval(0.0, &iss_state(), None);
assert!(
loads.acceleration_inertial.magnitude() > 0.0,
"Sphere should produce non-zero drag at ISS altitude"
);
}
#[test]
fn sphere_zero_torque() {
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(5.0, 2.0, 1.5));
let loads = drag.eval(0.0, &iss_state(), None);
assert_eq!(
loads.torque_body.into_inner(),
Vector3::zeros(),
"Sphere should produce zero torque"
);
}
#[test]
fn sphere_attitude_independent() {
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(5.0, 2.0, 1.5));
let s1 = iss_state();
let mut s2 = iss_state();
let c = std::f64::consts::FRAC_PI_4.cos();
let s = std::f64::consts::FRAC_PI_4.sin();
s2.attitude.quaternion = Vector4::new(c, 0.0, 0.0, s);
let l1 = drag.eval(0.0, &s1, None);
let l2 = drag.eval(0.0, &s2, None);
assert!(
(l1.acceleration_inertial - l2.acceleration_inertial).magnitude() < 1e-15,
"Sphere drag should not depend on attitude"
);
}
#[test]
fn sphere_opposes_velocity() {
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(5.0, 2.0, 1.5));
let loads = drag.eval(0.0, &iss_state(), None);
assert!(loads.acceleration_inertial.y() < 0.0);
}
#[test]
fn panels_facing_flow_nonzero_drag() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let loads = drag.eval(0.0, &iss_state(), None);
assert!(
loads.acceleration_inertial.magnitude() > 0.0,
"Panel facing flow should produce drag"
);
}
#[test]
fn panels_backface_zero_drag() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, 1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let loads = drag.eval(0.0, &iss_state(), None);
assert_eq!(
loads.acceleration_inertial.into_inner(),
Vector3::zeros(),
"Panel facing away from flow should produce zero drag"
);
}
#[test]
fn panels_drag_opposes_velocity() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let loads = drag.eval(0.0, &iss_state(), None);
assert!(
loads.acceleration_inertial.y() < 0.0,
"Panel drag should oppose velocity"
);
}
#[test]
fn panels_different_attitude_different_drag() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let s1 = iss_state();
let l1 = drag.eval(0.0, &s1, None);
let mut s2 = iss_state();
let c = std::f64::consts::FRAC_PI_4.cos();
let s = std::f64::consts::FRAC_PI_4.sin();
s2.attitude.quaternion = Vector4::new(c, 0.0, 0.0, s);
let l2 = drag.eval(0.0, &s2, None);
let diff = (l1.acceleration_inertial - l2.acceleration_inertial).magnitude();
assert!(
diff > 1e-15,
"Different attitudes should produce different drag: diff = {diff:.3e}"
);
}
#[test]
fn panels_rotated_to_backface_zero() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let mut state = iss_state();
state.attitude.quaternion = Vector4::new(0.0, 0.0, 0.0, 1.0);
let loads = drag.eval(0.0, &state, None);
assert!(
loads.acceleration_inertial.magnitude() < 1e-15,
"Panel rotated to backface should produce zero drag, got {:?}",
loads.acceleration_inertial
);
}
#[test]
fn panels_above_atmosphere_zero() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let state = SpacecraftState {
orbit: OrbitalState::new(
Vector3::new(R_EARTH + 3000.0, 0.0, 0.0),
Vector3::new(0.0, 5.0, 0.0),
),
attitude: AttitudeState::identity(),
mass: 500.0,
};
let loads = drag.eval(0.0, &state, None);
assert_eq!(loads.acceleration_inertial.into_inner(), Vector3::zeros());
}
#[test]
fn panels_at_com_zero_torque() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let loads = drag.eval(0.0, &iss_state(), None);
assert_eq!(
loads.torque_body.into_inner(),
Vector3::zeros(),
"Panel at CoM should produce zero torque"
);
}
#[test]
fn panels_cp_offset_produces_torque() {
let panel = SurfacePanel {
area: 10.0,
normal: Vector3::new(0.0, -1.0, 0.0),
cd: 2.2,
cr: 1.5,
cp_offset: Vector3::new(1.0, 0.0, 0.0), };
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let loads = drag.eval(0.0, &iss_state(), None);
assert!(
loads.torque_body.magnitude() > 0.0,
"Offset CP should produce non-zero torque"
);
assert!(
loads.torque_body.z().abs() > loads.torque_body.x().abs(),
"Torque should be primarily about z-axis"
);
}
#[test]
fn panels_double_offset_double_torque() {
let make_panel = |offset: f64| SurfacePanel {
area: 10.0,
normal: Vector3::new(0.0, -1.0, 0.0),
cd: 2.2,
cr: 1.5,
cp_offset: Vector3::new(offset, 0.0, 0.0),
};
let drag1 = PanelDrag::for_earth(SpacecraftShape::panels(vec![make_panel(1.0)]));
let drag2 = PanelDrag::for_earth(SpacecraftShape::panels(vec![make_panel(2.0)]));
let state = iss_state();
let t1 = drag1.eval(0.0, &state, None).torque_body;
let t2 = drag2.eval(0.0, &state, None).torque_body;
let ratio = t2.magnitude() / t1.magnitude();
assert!(
(ratio - 2.0).abs() < 1e-10,
"Double offset should give double torque, got ratio {ratio}"
);
}
#[test]
fn sphere_matches_atmospheric_drag() {
use crate::perturbations::AtmosphericDrag;
let b = 0.01;
let panel_drag = PanelDrag::for_earth(SpacecraftShape::sphere(5.0, 2.0, 1.5));
let atmo_drag = AtmosphericDrag::for_earth(Some(b));
let state = iss_state();
let panel_loads = panel_drag.eval(0.0, &state, None);
let atmo_accel = atmo_drag.acceleration(&state.orbit, None);
let diff = (panel_loads.acceleration_inertial.into_inner() - atmo_accel).magnitude();
assert!(
diff < 1e-15,
"Sphere PanelDrag should match AtmosphericDrag: diff = {diff:.3e}"
);
}
#[test]
fn single_panel_facing_flow_matches_sphere() {
let area = 10.0;
let cd = 2.2;
let panel = SurfacePanel::at_com(area, Vector3::new(0.0, -1.0, 0.0), cd);
let panel_drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let sphere_drag = PanelDrag::for_earth(SpacecraftShape::sphere(area, cd, 1.5));
let state = iss_state();
let panel_loads = panel_drag.eval(0.0, &state, None);
let sphere_loads = sphere_drag.eval(0.0, &state, None);
let diff =
(panel_loads.acceleration_inertial - sphere_loads.acceleration_inertial).magnitude();
let rel = diff / sphere_loads.acceleration_inertial.magnitude();
assert!(
rel < 1e-10,
"Single panel (cos θ=1) should match sphere: relative diff = {rel:.3e}"
);
}
#[test]
fn cube_symmetric_zero_net_torque() {
let drag = PanelDrag::for_earth(SpacecraftShape::cube(0.5, 2.2, 1.5));
let loads = drag.eval(0.0, &iss_state(), None);
assert!(
loads.torque_body.magnitude() < 1e-20,
"Cube with flow along axis should have zero torque (CP parallel to force)"
);
}
fn quat_from_axis_angle(axis: Vector3<f64>, angle: f64) -> Vector4<f64> {
let half = angle / 2.0;
let (s, c) = half.sin_cos();
let a = axis.normalize();
Vector4::new(c, a.x * s, a.y * s, a.z * s)
}
#[test]
fn cos_theta_scaling_45_degrees() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let s0 = iss_state(); let mut s45 = iss_state();
s45.attitude.quaternion =
quat_from_axis_angle(Vector3::new(1.0, 0.0, 0.0), std::f64::consts::FRAC_PI_4);
let a0 = drag.eval(0.0, &s0, None).acceleration_inertial.magnitude();
let a45 = drag.eval(0.0, &s45, None).acceleration_inertial.magnitude();
let ratio = a45 / a0;
let expected = std::f64::consts::FRAC_PI_4.cos(); assert!(
(ratio - expected).abs() < 1e-10,
"45° rotation: expected ratio {expected:.6}, got {ratio:.6}"
);
}
#[test]
fn cos_theta_scaling_60_degrees() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let s0 = iss_state();
let mut s60 = iss_state();
s60.attitude.quaternion =
quat_from_axis_angle(Vector3::new(1.0, 0.0, 0.0), std::f64::consts::FRAC_PI_3);
let a0 = drag.eval(0.0, &s0, None).acceleration_inertial.magnitude();
let a60 = drag.eval(0.0, &s60, None).acceleration_inertial.magnitude();
let ratio = a60 / a0;
assert!(
(ratio - 0.5).abs() < 1e-10,
"60° rotation: expected ratio 0.5, got {ratio:.6}"
);
}
#[test]
fn cos_theta_scaling_90_degrees_zero() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let mut s90 = iss_state();
s90.attitude.quaternion =
quat_from_axis_angle(Vector3::new(1.0, 0.0, 0.0), std::f64::consts::FRAC_PI_2);
let a = drag.eval(0.0, &s90, None).acceleration_inertial.magnitude();
assert!(a < 1e-20, "90° rotation: expected zero drag, got {a:.3e}");
}
#[test]
fn force_direction_always_anti_velocity() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let angles = [0.0, 0.3, 0.7, 1.0, -0.5];
let axes = [
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(0.0, 0.0, 1.0),
Vector3::new(1.0, 1.0, 0.0),
Vector3::new(1.0, 0.0, 1.0),
];
let base = iss_state();
let v_rel = *base.orbit.velocity()
- Vector3::new(0.0, 0.0, OMEGA_EARTH).cross(base.orbit.position());
for (axis, angle) in axes.iter().zip(angles.iter()) {
let mut state = iss_state();
state.attitude.quaternion = quat_from_axis_angle(*axis, *angle);
let loads = drag.eval(0.0, &state, None);
let a = loads.acceleration_inertial.into_inner();
if a.magnitude() < 1e-20 {
continue; }
let cross = a.cross(&v_rel);
let cross_rel = cross.magnitude() / (a.magnitude() * v_rel.magnitude());
assert!(
cross_rel < 1e-10,
"axis={axis:?}, angle={angle}: force not parallel to -v_rel, |a×v|/|a||v| = {cross_rel:.3e}"
);
assert!(
a.dot(&v_rel) < 0.0,
"axis={axis:?}, angle={angle}: force not opposing velocity"
);
}
}
#[test]
fn energy_dissipation_always_negative() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
for i in 0..20 {
let angle = (i as f64) * std::f64::consts::PI / 10.0; let mut state = iss_state();
state.attitude.quaternion = quat_from_axis_angle(Vector3::new(1.0, 1.0, 1.0), angle);
let loads = drag.eval(0.0, &state, None);
let a = loads.acceleration_inertial.into_inner();
let v_rel = *state.orbit.velocity()
- Vector3::new(0.0, 0.0, OMEGA_EARTH).cross(state.orbit.position());
let power = a.dot(&v_rel); assert!(
power <= 0.0,
"Drag should always dissipate energy: angle={angle:.2}, F·v = {power:.3e}"
);
}
}
#[test]
fn two_sided_panel_no_dead_zone() {
let panels = vec![
SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2),
SurfacePanel::at_com(10.0, Vector3::new(0.0, 1.0, 0.0), 2.2),
];
let drag = PanelDrag::for_earth(SpacecraftShape::panels(panels));
let a0 = drag
.eval(0.0, &iss_state(), None)
.acceleration_inertial
.magnitude();
assert!(a0 > 0.0);
let mut s180 = iss_state();
s180.attitude.quaternion = Vector4::new(0.0, 0.0, 0.0, 1.0);
let a180 = drag
.eval(0.0, &s180, None)
.acceleration_inertial
.magnitude();
assert!(
(a0 - a180).abs() / a0 < 1e-10,
"Two-sided panel should have same drag at 0° and 180°: a0={a0:.3e}, a180={a180:.3e}"
);
let mut s45 = iss_state();
s45.attitude.quaternion =
quat_from_axis_angle(Vector3::new(1.0, 0.0, 0.0), std::f64::consts::FRAC_PI_4);
let a45 = drag.eval(0.0, &s45, None).acceleration_inertial.magnitude();
let ratio = a45 / a0;
let expected = std::f64::consts::FRAC_PI_4.cos(); assert!(
(ratio - expected).abs() < 1e-10,
"Two-sided at 45°: expected ratio {expected:.6}, got {ratio:.6}"
);
let mut s90 = iss_state();
s90.attitude.quaternion =
quat_from_axis_angle(Vector3::new(1.0, 0.0, 0.0), std::f64::consts::FRAC_PI_2);
let a90 = drag.eval(0.0, &s90, None).acceleration_inertial.magnitude();
assert!(
a90 < 1e-20,
"Two-sided at 90° about x: both panels perpendicular → zero, got {a90:.3e}"
);
}
#[test]
fn cube_projected_area_analytic() {
let half = 0.5;
let cd = 2.2;
let drag = PanelDrag::for_earth(SpacecraftShape::cube(half, cd, 1.5));
let a0 = drag
.eval(0.0, &iss_state(), None)
.acceleration_inertial
.magnitude();
let mut s45z = iss_state();
s45z.attitude.quaternion =
quat_from_axis_angle(Vector3::new(0.0, 0.0, 1.0), std::f64::consts::FRAC_PI_4);
let a45z = drag
.eval(0.0, &s45z, None)
.acceleration_inertial
.magnitude();
let ratio = a45z / a0;
let expected = std::f64::consts::SQRT_2; assert!(
(ratio - expected).abs() < 1e-10,
"Cube at 45° about z: expected ratio {expected:.6}, got {ratio:.6}"
);
}
#[test]
fn torque_exact_cross_product() {
let panel = SurfacePanel {
area: 10.0,
normal: Vector3::new(0.0, -1.0, 0.0),
cd: 2.2,
cr: 1.5,
cp_offset: Vector3::new(1.0, 0.0, 0.0),
};
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let loads = drag.eval(0.0, &iss_state(), None);
let f_body_y = loads.acceleration_inertial.y() * iss_state().mass * 1000.0;
assert!(
loads.torque_body.x().abs() < 1e-20,
"τ_x should be 0, got {:.3e}",
loads.torque_body.x()
);
assert!(
loads.torque_body.y().abs() < 1e-20,
"τ_y should be 0, got {:.3e}",
loads.torque_body.y()
);
let rel_err = (loads.torque_body.z() - f_body_y).abs() / f_body_y.abs();
assert!(
rel_err < 1e-10,
"τ_z should equal F_y ({f_body_y:.6e}), got {:.6e}, rel_err={rel_err:.3e}",
loads.torque_body.z()
);
}
struct ConstantDensity(f64);
impl AtmosphereModel for ConstantDensity {
fn density(&self, _input: &AtmosphereInput<'_>) -> f64 {
self.0
}
}
fn quat_compose(q_second: &Vector4<f64>, q_first: &Vector4<f64>) -> Vector4<f64> {
use nalgebra::{Quaternion, UnitQuaternion};
let uq_second = UnitQuaternion::from_quaternion(Quaternion::new(
q_second[0],
q_second[1],
q_second[2],
q_second[3],
));
let uq_first = UnitQuaternion::from_quaternion(Quaternion::new(
q_first[0], q_first[1], q_first[2], q_first[3],
));
let result = uq_second * uq_first;
Vector4::new(result.w, result.i, result.j, result.k)
}
fn mock_drag(shape: SpacecraftShape, rho: f64) -> PanelDrag {
PanelDrag {
shape,
atmosphere: Box::new(ConstantDensity(rho)),
body: None,
body_radius: 100.0, omega_body: 0.0, }
}
#[test]
fn equivariance_acceleration_under_inertial_rotation() {
let panels = vec![
SurfacePanel {
area: 10.0,
normal: Vector3::new(0.0, -1.0, 0.0),
cd: 2.2,
cr: 1.5,
cp_offset: Vector3::new(1.0, 0.0, 0.0),
},
SurfacePanel::at_com(5.0, Vector3::new(1.0, 0.0, 0.0), 2.0),
];
let drag = mock_drag(SpacecraftShape::panels(panels), 1e-12);
let mut s1 = iss_state();
s1.attitude.quaternion = quat_from_axis_angle(Vector3::new(1.0, 1.0, 0.0), 0.5);
let l1 = drag.eval(0.0, &s1, None);
let q_r = quat_from_axis_angle(Vector3::new(1.0, 2.0, 3.0), 37.0_f64.to_radians());
let att_tmp = AttitudeState {
quaternion: q_r,
angular_velocity: Vector3::zeros(),
};
let r_mat = *att_tmp.orientation().to_rotation_matrix().matrix();
let s2 = SpacecraftState {
orbit: OrbitalState::new(r_mat * *s1.orbit.position(), r_mat * *s1.orbit.velocity()),
attitude: AttitudeState {
quaternion: quat_compose(&q_r, &s1.attitude.quaternion),
angular_velocity: s1.attitude.angular_velocity,
},
mass: s1.mass,
};
let l2 = drag.eval(0.0, &s2, None);
let a1_rotated = r_mat * l1.acceleration_inertial.into_inner();
let a_rel = (l2.acceleration_inertial.into_inner() - a1_rotated).magnitude()
/ l1.acceleration_inertial.magnitude();
assert!(
a_rel < 1e-10,
"Acceleration should transform as R·a: relative error = {a_rel:.3e}"
);
}
#[test]
fn equivariance_torque_under_inertial_rotation() {
let panels = vec![
SurfacePanel {
area: 10.0,
normal: Vector3::new(0.0, -1.0, 0.0),
cd: 2.2,
cr: 1.5,
cp_offset: Vector3::new(1.0, 0.0, 0.0),
},
SurfacePanel {
area: 8.0,
normal: Vector3::new(1.0, 0.0, 0.0),
cd: 2.0,
cr: 1.5,
cp_offset: Vector3::new(0.0, 0.0, 0.5),
},
];
let drag = mock_drag(SpacecraftShape::panels(panels), 1e-12);
let mut s1 = iss_state();
s1.attitude.quaternion = quat_from_axis_angle(Vector3::new(0.0, 1.0, 0.0), 0.8);
let l1 = drag.eval(0.0, &s1, None);
let rotations = [
(Vector3::new(1.0, 0.0, 0.0), 45.0_f64),
(Vector3::new(0.0, 1.0, 0.0), 120.0),
(Vector3::new(1.0, 2.0, 3.0), 37.0),
(Vector3::new(-1.0, 0.5, 0.3), 200.0),
];
for (axis, angle_deg) in &rotations {
let q_r = quat_from_axis_angle(*axis, angle_deg.to_radians());
let att_tmp = AttitudeState {
quaternion: q_r,
angular_velocity: Vector3::zeros(),
};
let r_mat = *att_tmp.orientation().to_rotation_matrix().matrix();
let s2 = SpacecraftState {
orbit: OrbitalState::new(
r_mat * *s1.orbit.position(),
r_mat * *s1.orbit.velocity(),
),
attitude: AttitudeState {
quaternion: quat_compose(&q_r, &s1.attitude.quaternion),
angular_velocity: s1.attitude.angular_velocity,
},
mass: s1.mass,
};
let l2 = drag.eval(0.0, &s2, None);
let tau_rel =
(l2.torque_body - l1.torque_body).magnitude() / l1.torque_body.magnitude();
assert!(
tau_rel < 1e-10,
"Body-frame torque should be invariant under {angle_deg}° about {axis:?}: \
relative error = {tau_rel:.3e}"
);
}
}
#[test]
fn convention_anchor_yaw_positive_backface() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(1.0, 0.0, 0.0), 2.2);
let drag = mock_drag(SpacecraftShape::panels(vec![panel]), 1e-12);
let mut state = iss_state();
state.attitude.quaternion =
quat_from_axis_angle(Vector3::new(0.0, 0.0, 1.0), std::f64::consts::FRAC_PI_2);
let loads = drag.eval(0.0, &state, None);
assert!(
loads.acceleration_inertial.magnitude() < 1e-20,
"Convention anchor: +90° yaw with n_b=(1,0,0) should be backface (zero drag), \
got {:.3e}. This indicates R_ib/R_bi swap.",
loads.acceleration_inertial.magnitude()
);
}
#[test]
fn convention_anchor_yaw_negative_full_drag() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(1.0, 0.0, 0.0), 2.2);
let drag = mock_drag(SpacecraftShape::panels(vec![panel]), 1e-12);
let mut state = iss_state();
state.attitude.quaternion =
quat_from_axis_angle(Vector3::new(0.0, 0.0, 1.0), -std::f64::consts::FRAC_PI_2);
let loads = drag.eval(0.0, &state, None);
assert!(
loads.acceleration_inertial.magnitude() > 1e-20,
"Convention anchor: -90° yaw with n_b=(1,0,0) should be full drag"
);
let panel_y = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag_y = mock_drag(SpacecraftShape::panels(vec![panel_y]), 1e-12);
let ref_loads = drag_y.eval(0.0, &iss_state(), None);
let rel = (loads.acceleration_inertial.magnitude()
- ref_loads.acceleration_inertial.magnitude())
.abs()
/ ref_loads.acceleration_inertial.magnitude();
assert!(
rel < 1e-10,
"Full-drag magnitudes should match: relative diff = {rel:.3e}"
);
}
#[test]
fn quaternion_sign_invariance() {
let panels = vec![
SurfacePanel {
area: 10.0,
normal: Vector3::new(0.0, -1.0, 0.0),
cd: 2.2,
cr: 1.5,
cp_offset: Vector3::new(1.0, 0.0, 0.0),
},
SurfacePanel::at_com(5.0, Vector3::new(1.0, 0.0, 0.0), 2.0),
];
let drag = mock_drag(SpacecraftShape::panels(panels), 1e-12);
let mut s1 = iss_state();
s1.attitude.quaternion = quat_from_axis_angle(Vector3::new(1.0, 2.0, 3.0), 0.7);
let mut s2 = s1.clone();
s2.attitude.quaternion = -s1.attitude.quaternion;
let l1 = drag.eval(0.0, &s1, None);
let l2 = drag.eval(0.0, &s2, None);
assert!(
(l1.acceleration_inertial - l2.acceleration_inertial).magnitude() < 1e-15,
"q and -q should give identical acceleration"
);
assert!(
(l1.torque_body - l2.torque_body).magnitude() < 1e-15,
"q and -q should give identical torque"
);
}
#[test]
fn density_linearity() {
let panels = vec![SurfacePanel {
area: 10.0,
normal: Vector3::new(0.0, -1.0, 0.0),
cd: 2.2,
cr: 1.5,
cp_offset: Vector3::new(1.0, 0.0, 0.0),
}];
let drag1 = mock_drag(SpacecraftShape::panels(panels.clone()), 1e-12);
let drag2 = mock_drag(SpacecraftShape::panels(panels), 2e-12);
let state = iss_state();
let l1 = drag1.eval(0.0, &state, None);
let l2 = drag2.eval(0.0, &state, None);
let a_ratio = l2.acceleration_inertial.magnitude() / l1.acceleration_inertial.magnitude();
assert!(
(a_ratio - 2.0).abs() < 1e-10,
"Acceleration should scale linearly with density: ratio = {a_ratio:.6}"
);
let tau_ratio = l2.torque_body.magnitude() / l1.torque_body.magnitude();
assert!(
(tau_ratio - 2.0).abs() < 1e-10,
"Torque should scale linearly with density: ratio = {tau_ratio:.6}"
);
}
#[test]
fn velocity_squared_scaling() {
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = mock_drag(SpacecraftShape::panels(vec![panel]), 1e-12);
let s1 = iss_state();
let mut s2 = iss_state();
*s2.orbit.velocity_mut() = *s1.orbit.velocity() * 2.0;
let a1 = drag.eval(0.0, &s1, None).acceleration_inertial.magnitude();
let a2 = drag.eval(0.0, &s2, None).acceleration_inertial.magnitude();
let ratio = a2 / a1;
assert!(
(ratio - 4.0).abs() < 1e-10,
"Acceleration should scale as |v|²: ratio = {ratio:.6} (expected 4.0)"
);
}
#[test]
fn absolute_magnitude_analytic() {
let area = 10.0; let cd = 2.2;
let mass = 500.0; let rho = 1e-12;
let panel = SurfacePanel::at_com(area, Vector3::new(0.0, -1.0, 0.0), cd);
let drag = mock_drag(SpacecraftShape::panels(vec![panel]), rho);
let state = iss_state();
let loads = drag.eval(0.0, &state, None);
let v_ms = state.orbit.velocity().magnitude() * 1000.0; let expected_a_ms2 = 0.5 * rho * cd * area * v_ms * v_ms / mass;
let expected_a_kms2 = expected_a_ms2 / 1000.0;
let actual = loads.acceleration_inertial.magnitude();
let rel_err = (actual - expected_a_kms2).abs() / expected_a_kms2;
assert!(
rel_err < 1e-10,
"Absolute acceleration: expected {expected_a_kms2:.6e}, got {actual:.6e}, \
rel_err = {rel_err:.3e}"
);
}
#[test]
fn panels_integrable_with_rk4() {
use super::super::SpacecraftDynamics;
use crate::orbital::gravity::PointMass;
use arika::earth::MU as MU_EARTH;
use nalgebra::Matrix3;
use utsuroi::{Integrator, OdeState, Rk4};
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let inertia = Matrix3::from_diagonal(&Vector3::new(100.0, 200.0, 300.0));
let dyn_sc = SpacecraftDynamics::new(MU_EARTH, PointMass, inertia).with_model(drag);
let result = Rk4.integrate(&dyn_sc, iss_state().into(), 0.0, 60.0, 1.0, |_, _| {});
assert!(
result.is_finite(),
"State should remain finite after 60s integration"
);
assert!(result.plant.orbit.position().magnitude() > 0.0);
}
#[test]
fn panels_drag_reduces_orbital_energy() {
use super::super::SpacecraftDynamics;
use crate::orbital::gravity::PointMass;
use arika::earth::MU as MU_EARTH;
use nalgebra::Matrix3;
use utsuroi::{Integrator, Rk4};
let panel = SurfacePanel::at_com(10.0, Vector3::new(0.0, -1.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let inertia = Matrix3::from_diagonal(&Vector3::new(100.0, 200.0, 300.0));
let dyn_sc = SpacecraftDynamics::new(MU_EARTH, PointMass, inertia).with_model(drag);
let s0 = iss_state();
let e0 = 0.5 * s0.orbit.velocity().magnitude_squared()
- MU_EARTH / s0.orbit.position().magnitude();
let s1 = Rk4.integrate(&dyn_sc, s0.into(), 0.0, 300.0, 1.0, |_, _| {});
let e1 = 0.5 * s1.plant.orbit.velocity().magnitude_squared()
- MU_EARTH / s1.plant.orbit.position().magnitude();
assert!(
e1 < e0,
"Drag should reduce orbital energy: e0={e0:.6e}, e1={e1:.6e}"
);
}
#[test]
fn tumbling_asymmetric_panels_varying_drag() {
use super::super::SpacecraftDynamics;
use crate::orbital::gravity::PointMass;
use arika::earth::MU as MU_EARTH;
use nalgebra::Matrix3;
use utsuroi::{Integrator, Rk4};
let panel = SurfacePanel::at_com(20.0, Vector3::new(1.0, 0.0, 0.0), 2.2);
let drag = PanelDrag::for_earth(SpacecraftShape::panels(vec![panel]));
let inertia = Matrix3::from_diagonal(&Vector3::new(100.0, 200.0, 300.0));
let dyn_sc = SpacecraftDynamics::new(MU_EARTH, PointMass, inertia).with_model(drag);
let mut state = iss_state();
state.attitude.angular_velocity = Vector3::new(0.0, 0.0, 0.05);
let mut magnitudes = Vec::new();
let _ = Rk4.integrate(&dyn_sc, state.into(), 0.0, 60.0, 1.0, |_t, s| {
let loads = dyn_sc.model_breakdown(0.0, &s.plant);
if let Some((_, el)) = loads.first() {
magnitudes.push(el.acceleration_inertial.magnitude());
}
});
let min = magnitudes.iter().cloned().fold(f64::INFINITY, f64::min);
let max = magnitudes.iter().cloned().fold(0.0_f64, f64::max);
assert!(
max > min * 1.01 || min == 0.0,
"Tumbling should cause varying drag: min={min:.3e}, max={max:.3e}"
);
}
#[test]
fn sphere_integrable_with_spacecraft_dynamics() {
use super::super::SpacecraftDynamics;
use crate::orbital::gravity::PointMass;
use arika::earth::MU as MU_EARTH;
use nalgebra::Matrix3;
use utsuroi::{Integrator, OdeState, Rk4};
let drag = PanelDrag::for_earth(SpacecraftShape::sphere(10.0, 2.2, 1.5));
let inertia = Matrix3::from_diagonal(&Vector3::new(10.0, 10.0, 10.0));
let dyn_sc = SpacecraftDynamics::new(MU_EARTH, PointMass, inertia).with_model(drag);
let result = Rk4.integrate(&dyn_sc, iss_state().into(), 0.0, 60.0, 1.0, |_, _| {});
assert!(result.is_finite());
}
}