use core::marker::PhantomData;
use astrodyn_quantities::aliases::{InertiaTensor, Position};
use astrodyn_quantities::frame::{BodyFrame, StructuralFrame, Vehicle};
use glam::{DMat3, DVec3};
use uom::si::f64::Mass;
use uom::si::mass::kilogram;
pub const INERTIA_CONSISTENCY_TOL: f64 = 1e-6;
pub const MIN_SAFE_MASS_KG: f64 = 1e-100;
pub const MAX_SAFE_MASS_KG: f64 = 1e100;
const POST_INVERSE_IDENTITY_TOL: f64 = INERTIA_CONSISTENCY_TOL;
#[inline]
fn checked_inertia_inverse(inertia: DMat3) -> DMat3 {
let det = inertia.determinant();
assert!(
det.is_finite() && det != 0.0,
"inertia tensor has a non-finite or zero determinant \
(det={det:.2e}); the inverse would be all zeros (det=±inf, \
where finite cofactors divided by ±inf round to 0) or contain \
inf/NaN entries (det=0). Supply a non-singular inertia tensor \
whose entries stay within the f64 dynamic range — \
e.g. diag(1e103, 1e103, 1e103) overflows `det = m³` to +inf \
even though every entry is a normal-range f64."
);
let inverse = inertia.inverse();
assert!(
inverse.is_finite(),
"inertia tensor is singular or ill-conditioned \
(det={det:.2e}); inverse contains inf/NaN entries. \
Supply a non-singular inertia tensor."
);
let product = inertia * inverse;
let deviation = product - DMat3::IDENTITY;
assert!(
deviation.is_finite(),
"inertia tensor's `I · I⁻¹ − I_{{3×3}}` contains a non-finite \
entry (NaN or ±inf) even though `det` and `inverse` are \
individually finite (det={det:.2e}). This indicates a \
cancellation/overflow inside the matrix product that the \
per-entry finite-cofactor check could not detect. Rescale \
the inertia tensor so the product `I · I⁻¹` stays within \
the f64 dynamic range. Deviation matrix: {deviation:?}"
);
let max_deviation = deviation
.to_cols_array()
.iter()
.map(|x: &f64| x.abs())
.fold(0.0_f64, f64::max);
assert!(
max_deviation <= POST_INVERSE_IDENTITY_TOL,
"inertia tensor produced an inverse that does not reproduce \
the identity under multiplication (max|I·I⁻¹ − I_{{3×3}}| = \
{max_deviation:.2e} > {POST_INVERSE_IDENTITY_TOL:.0e}, \
det={det:.2e}). This usually means individual cofactors \
underflowed to 0 before the cofactor/det divide — for example, \
diag(1e300, 1e-200, 1e-200) has finite det = 1e-100 and a \
finite-entry inverse, but the (0,0) cofactor `1e-200·1e-200 = \
1e-400` underflows to 0, zeroing the corresponding inverse \
entry. Rescale the inertia tensor so that no entry-pair \
product underflows the f64 dynamic range."
);
inverse
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MassProperties {
pub mass: f64,
pub inverse_mass: f64,
pub inertia: DMat3,
pub inverse_inertia: DMat3,
pub position: DVec3,
pub t_parent_this: DMat3,
pub dirty: bool,
}
impl MassProperties {
pub fn new(mass: f64) -> Self {
assert!(
(MIN_SAFE_MASS_KG..=MAX_SAFE_MASS_KG).contains(&mass),
"MassProperties::new: mass {mass} kg out of safe range \
[{MIN_SAFE_MASS_KG:.0e}, {MAX_SAFE_MASS_KG:.0e}] kg for the \
point-mass constructor. This guard applies only to `new`, \
which synthesises the placeholder inertia `I = m·I_{{3×3}}` \
from `mass` (so the inverse formula propagates `m³` through \
both numerator and denominator and must stay within the f64 \
dynamic range). The explicit-inertia paths (`with_inertia`, \
`recompute_derived`) accept a caller-supplied inertia whose \
magnitude is decoupled from `mass`, and require `mass > 0 \
&& mass.is_finite()` *and* `(1.0 / mass).is_finite()` (the \
latter rejects positive finite subnormals whose reciprocal \
overflows to `+inf`). If your scenario genuinely needs a \
mass outside this range, supply an explicit inertia via \
`MassProperties::with_inertia(mass, inertia, position)` \
instead of `new`."
);
let inertia = DMat3::IDENTITY * mass;
Self {
mass,
inverse_mass: 1.0 / mass,
inertia,
inverse_inertia: checked_inertia_inverse(inertia),
position: DVec3::ZERO,
t_parent_this: DMat3::IDENTITY,
dirty: false,
}
}
pub fn with_inertia(mass: f64, inertia: DMat3, position: DVec3) -> Self {
let inverse_mass = 1.0 / mass;
assert!(
mass.is_finite() && mass > 0.0 && inverse_mass.is_finite(),
"MassProperties::with_inertia: mass {mass} kg must be \
finite and strictly positive, *and* `1/mass` must be finite \
(positive subnormals below `1.0 / f64::MAX ≈ 5.6e-309` \
satisfy `is_finite() && > 0.0` yet round `1/mass` to `+inf`). \
Inertia magnitude is checked separately and may live at \
any non-singular scale."
);
let inverse_inertia = checked_inertia_inverse(inertia);
Self {
mass,
inverse_mass,
inertia,
inverse_inertia,
position,
t_parent_this: DMat3::IDENTITY,
dirty: false,
}
}
pub fn with_t_parent_this(mut self, t_parent_this: DMat3) -> Self {
self.t_parent_this = t_parent_this;
self
}
pub fn recompute_derived(&mut self) {
if !self.dirty {
return;
}
self.dirty = false;
let inverse_mass = 1.0 / self.mass;
assert!(
self.mass.is_finite() && self.mass > 0.0 && inverse_mass.is_finite(),
"MassProperties::recompute_derived: mass {} kg must be \
finite and strictly positive, *and* `1/mass` must be finite \
(positive subnormals below `1.0 / f64::MAX ≈ 5.6e-309` \
satisfy `is_finite() && > 0.0` yet round `1/mass` to `+inf`); \
the inertia tensor is checked separately.",
self.mass,
);
self.inverse_mass = inverse_mass;
self.inverse_inertia = checked_inertia_inverse(self.inertia);
}
pub fn validate_consistency(&self, tol: f64) {
let product = self.inertia * self.inverse_inertia;
assert!(
(product - DMat3::IDENTITY).abs_diff_eq(DMat3::ZERO, tol),
"MassProperties: inertia and inverse_inertia are inconsistent \
(I * I^-1 != identity to {tol:.0e}). In JEOD, inverse_inertia \
is always recomputed from inertia. Use MassProperties::with_inertia() \
when constructing, or call MassProperties::recompute_derived() after \
mutating mass/inertia."
);
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MassPropertiesTyped<V: Vehicle> {
pub mass: Mass,
pub inverse_mass: f64,
pub inertia: InertiaTensor<BodyFrame<V>>,
pub inverse_inertia: DMat3,
pub center_of_mass: Position<StructuralFrame<V>>,
pub t_parent_this: DMat3,
pub dirty: bool,
_v: PhantomData<V>,
}
impl<V: Vehicle> MassPropertiesTyped<V> {
pub fn new(mass: Mass) -> Self {
let m = mass.get::<kilogram>();
assert!(
(MIN_SAFE_MASS_KG..=MAX_SAFE_MASS_KG).contains(&m),
"MassPropertiesTyped::new: mass {m} kg out of safe range \
[{MIN_SAFE_MASS_KG:.0e}, {MAX_SAFE_MASS_KG:.0e}] kg for the \
point-mass constructor. This guard applies only to `new`, \
which synthesises the placeholder inertia `I = m·I_{{3×3}}` \
from `mass` (so the inverse formula propagates `m³` through \
both numerator and denominator and must stay within the f64 \
dynamic range). The explicit-inertia paths (`with_inertia`, \
`recompute_derived`) accept a caller-supplied inertia whose \
magnitude is decoupled from `mass`, and require `mass > 0 \
&& mass.is_finite()` *and* `(1.0 / mass).is_finite()` (the \
latter rejects positive finite subnormals whose reciprocal \
overflows to `+inf`). If your scenario genuinely needs a \
mass outside this range, supply an explicit inertia via \
`MassPropertiesTyped::with_inertia(mass, inertia, com)` \
instead of `new`."
);
let inertia_dmat = DMat3::IDENTITY * m;
Self {
mass,
inverse_mass: 1.0 / m,
inertia: InertiaTensor::<BodyFrame<V>>::from_dmat3_unchecked(inertia_dmat),
inverse_inertia: checked_inertia_inverse(inertia_dmat),
center_of_mass: Position::<StructuralFrame<V>>::zero(),
t_parent_this: DMat3::IDENTITY,
dirty: false,
_v: PhantomData,
}
}
pub fn with_inertia(
mass: Mass,
inertia: InertiaTensor<BodyFrame<V>>,
center_of_mass: Position<StructuralFrame<V>>,
) -> Self {
let m = mass.get::<kilogram>();
let inverse_mass = 1.0 / m;
assert!(
m.is_finite() && m > 0.0 && inverse_mass.is_finite(),
"MassPropertiesTyped::with_inertia: mass {m} kg must be \
finite and strictly positive, *and* `1/mass` must be finite \
(positive subnormals below `1.0 / f64::MAX ≈ 5.6e-309` \
satisfy `is_finite() && > 0.0` yet round `1/mass` to `+inf`). \
Inertia magnitude is checked separately and may live at \
any non-singular scale."
);
let inverse_inertia = checked_inertia_inverse(inertia.as_dmat3());
Self {
mass,
inverse_mass,
inertia,
inverse_inertia,
center_of_mass,
t_parent_this: DMat3::IDENTITY,
dirty: false,
_v: PhantomData,
}
}
pub fn with_t_parent_this(mut self, t_parent_this: DMat3) -> Self {
self.t_parent_this = t_parent_this;
self
}
pub fn recompute_derived(&mut self) {
if !self.dirty {
return;
}
self.dirty = false;
let m = self.mass.get::<kilogram>();
let inverse_mass = 1.0 / m;
assert!(
m.is_finite() && m > 0.0 && inverse_mass.is_finite(),
"MassPropertiesTyped::recompute_derived: mass {m} kg must \
be finite and strictly positive, *and* `1/mass` must be \
finite (positive subnormals below `1.0 / f64::MAX ≈ \
5.6e-309` satisfy `is_finite() && > 0.0` yet round `1/mass` \
to `+inf`); the inertia tensor is checked separately."
);
self.inverse_mass = inverse_mass;
self.inverse_inertia = checked_inertia_inverse(self.inertia.as_dmat3());
}
pub fn validate_consistency(&self, tol: f64) {
let product = self.inertia.as_dmat3() * self.inverse_inertia;
assert!(
(product - DMat3::IDENTITY).abs_diff_eq(DMat3::ZERO, tol),
"MassPropertiesTyped: inertia and inverse_inertia inconsistent \
(I·I⁻¹ != identity to {tol:.0e})"
);
}
#[inline]
pub fn to_untyped(&self) -> MassProperties {
MassProperties {
mass: self.mass.get::<kilogram>(),
inverse_mass: self.inverse_mass,
inertia: self.inertia.as_dmat3(),
inverse_inertia: self.inverse_inertia,
position: self.center_of_mass.raw_si(),
t_parent_this: self.t_parent_this,
dirty: self.dirty,
}
}
#[inline]
pub fn from_untyped_unchecked(s: &MassProperties) -> Self {
Self {
mass: Mass::new::<kilogram>(s.mass),
inverse_mass: s.inverse_mass,
inertia: InertiaTensor::<BodyFrame<V>>::from_dmat3_unchecked(s.inertia),
inverse_inertia: s.inverse_inertia,
center_of_mass: Position::<StructuralFrame<V>>::from_raw_si(s.position),
t_parent_this: s.t_parent_this,
dirty: s.dirty,
_v: PhantomData,
}
}
}
#[cfg(test)]
#[allow(
clippy::float_cmp,
reason = "mass-properties tests assert bit-exact recovery of literal scalars and tensor components"
)]
mod tests {
use super::*;
#[test]
fn point_mass_inertia() {
let mp = MassProperties::new(10.0);
assert_eq!(mp.mass, 10.0);
assert_eq!(mp.inverse_mass, 0.1);
assert_eq!(mp.inertia, DMat3::IDENTITY * 10.0);
assert_eq!(mp.inverse_inertia, (DMat3::IDENTITY * 10.0).inverse());
assert_eq!(mp.position, DVec3::ZERO);
}
#[test]
fn inertia_times_inverse_is_identity() {
let mp = MassProperties::new(42.0);
let product = mp.inertia * mp.inverse_inertia;
let diff = product - DMat3::IDENTITY;
assert!(diff.x_axis.length() < 1e-12);
assert!(diff.y_axis.length() < 1e-12);
assert!(diff.z_axis.length() < 1e-12);
}
#[test]
fn validate_consistency_passes_for_consistent() {
let mp = MassProperties::with_inertia(
10.0,
DMat3::from_diagonal(DVec3::new(100.0, 200.0, 300.0)),
DVec3::ZERO,
);
mp.validate_consistency(1e-6); }
#[test]
#[should_panic(expected = "inconsistent")]
fn validate_consistency_fails_for_wrong_inverse() {
let mut mp = MassProperties::with_inertia(
10.0,
DMat3::from_diagonal(DVec3::new(100.0, 200.0, 300.0)),
DVec3::ZERO,
);
mp.inverse_inertia = DMat3::IDENTITY;
mp.validate_consistency(1e-6);
}
#[test]
fn recompute_derived_after_mass_change() {
let mut mp = MassProperties::new(10.0);
assert_eq!(mp.inverse_mass, 0.1);
mp.mass = 8.0;
mp.dirty = true;
assert_eq!(mp.inverse_mass, 0.1);
mp.recompute_derived();
assert!((mp.inverse_mass - 0.125).abs() < 1e-15);
assert!((mp.mass * mp.inverse_mass - 1.0).abs() < 1e-15);
assert!(!mp.dirty);
}
#[test]
fn recompute_derived_skips_when_clean() {
let mut mp = MassProperties::new(10.0);
assert!(!mp.dirty);
mp.recompute_derived();
assert_eq!(mp.inverse_mass, 0.1);
}
#[test]
fn recompute_derived_after_inertia_change() {
let mut mp = MassProperties::with_inertia(
10.0,
DMat3::from_diagonal(DVec3::new(100.0, 200.0, 300.0)),
DVec3::ZERO,
);
mp.inertia = DMat3::from_diagonal(DVec3::new(50.0, 100.0, 150.0));
mp.dirty = true;
mp.recompute_derived();
mp.validate_consistency(1e-6);
assert!((mp.inverse_mass - 0.1).abs() < 1e-15);
}
#[test]
fn typed_point_mass_round_trips_to_untyped() {
use astrodyn_quantities::frame::TestVehicle;
let typed = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(10.0));
assert_eq!(typed.mass.get::<kilogram>(), 10.0);
assert_eq!(typed.inverse_mass, 0.1);
assert_eq!(typed.inertia.as_dmat3(), DMat3::IDENTITY * 10.0);
assert_eq!(typed.inverse_inertia, (DMat3::IDENTITY * 10.0).inverse());
assert_eq!(typed.center_of_mass.raw_si(), DVec3::ZERO);
}
#[test]
fn typed_with_inertia_matches_untyped() {
use astrodyn_quantities::frame::TestVehicle;
let m = 5.0;
let i = DMat3::from_diagonal(DVec3::new(50.0, 60.0, 70.0));
let pos = DVec3::new(0.1, 0.2, 0.3);
let typed = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(m),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(i),
Position::<StructuralFrame<TestVehicle>>::from_raw_si(pos),
);
let untyped = MassProperties::with_inertia(m, i, pos);
assert_eq!(typed.mass.get::<kilogram>(), untyped.mass);
assert_eq!(typed.inverse_mass, untyped.inverse_mass);
assert_eq!(typed.inertia.as_dmat3(), untyped.inertia);
assert_eq!(typed.inverse_inertia, untyped.inverse_inertia);
assert_eq!(typed.center_of_mass.raw_si(), untyped.position);
assert_eq!(typed.t_parent_this, untyped.t_parent_this);
assert_eq!(typed.dirty, untyped.dirty);
}
#[test]
#[should_panic(expected = "out of safe range")]
fn untyped_new_panics_on_zero_mass() {
let _ = MassProperties::new(0.0);
}
#[test]
#[should_panic(expected = "out of safe range")]
fn untyped_new_panics_on_negative_mass() {
let _ = MassProperties::new(-1.0);
}
#[test]
#[should_panic(expected = "out of safe range")]
fn untyped_new_panics_on_nan_mass() {
let _ = MassProperties::new(f64::NAN);
}
#[test]
#[should_panic(expected = "out of safe range")]
fn untyped_new_panics_on_infinite_mass() {
let _ = MassProperties::new(f64::INFINITY);
}
#[test]
#[should_panic(expected = "out of safe range")]
fn untyped_new_panics_on_mass_below_safe_floor_cubic_underflow() {
let _ = MassProperties::new(1e-150);
}
#[test]
#[should_panic(expected = "out of safe range")]
fn untyped_new_panics_on_huge_mass() {
let _ = MassProperties::new(1e150);
}
#[test]
fn untyped_new_accepts_safe_extremes() {
let lo = MassProperties::new(MIN_SAFE_MASS_KG);
let hi = MassProperties::new(MAX_SAFE_MASS_KG);
assert!(lo.inverse_mass.is_finite());
assert!(hi.inverse_mass.is_finite());
for v in [
lo.inverse_inertia.x_axis,
lo.inverse_inertia.y_axis,
lo.inverse_inertia.z_axis,
hi.inverse_inertia.x_axis,
hi.inverse_inertia.y_axis,
hi.inverse_inertia.z_axis,
] {
assert!(v.x.is_finite() && v.y.is_finite() && v.z.is_finite());
}
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_with_inertia_panics_on_zero_mass() {
let _ = MassProperties::with_inertia(0.0, DMat3::IDENTITY, DVec3::ZERO);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_with_inertia_panics_on_negative_mass() {
let _ = MassProperties::with_inertia(-1.0, DMat3::IDENTITY, DVec3::ZERO);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_with_inertia_panics_on_nan_mass() {
let _ = MassProperties::with_inertia(f64::NAN, DMat3::IDENTITY, DVec3::ZERO);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_with_inertia_panics_on_infinite_mass() {
let _ = MassProperties::with_inertia(f64::INFINITY, DMat3::IDENTITY, DVec3::ZERO);
}
#[test]
fn untyped_with_inertia_accepts_tiny_mass_with_sane_inertia() {
let mp = MassProperties::with_inertia(
1e-150,
DMat3::from_diagonal(DVec3::new(1e-50, 1e-50, 1e-50)),
DVec3::ZERO,
);
assert!(mp.inverse_mass.is_finite());
assert!(mp.inverse_inertia.is_finite());
mp.validate_consistency(INERTIA_CONSISTENCY_TOL);
}
#[test]
fn typed_with_inertia_accepts_tiny_mass_with_sane_inertia() {
use astrodyn_quantities::frame::TestVehicle;
let mp = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(1e-150),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(DMat3::from_diagonal(
DVec3::new(1e-50, 1e-50, 1e-50),
)),
Position::<StructuralFrame<TestVehicle>>::zero(),
);
assert!(mp.inverse_mass.is_finite());
assert!(mp.inverse_inertia.is_finite());
mp.validate_consistency(INERTIA_CONSISTENCY_TOL);
}
#[test]
fn untyped_with_inertia_accepts_huge_mass_with_sane_inertia() {
let mp = MassProperties::with_inertia(1e200, DMat3::IDENTITY, DVec3::ZERO);
assert!(mp.inverse_mass.is_finite());
assert!(mp.inverse_inertia.is_finite());
}
#[test]
fn safe_extremes_round_trip_through_with_inertia_and_recompute() {
for &m in &[MIN_SAFE_MASS_KG, 1.0_f64, MAX_SAFE_MASS_KG] {
let inertia = DMat3::IDENTITY * m;
let via_with_inertia = MassProperties::with_inertia(m, inertia, DVec3::ZERO);
assert!(via_with_inertia.inverse_inertia.is_finite());
let mut mp = MassProperties::new(m);
mp.dirty = true;
mp.recompute_derived();
assert!(mp.inverse_inertia.is_finite());
}
}
#[test]
#[should_panic(expected = "non-finite or zero determinant")]
fn singular_inertia_rejected_by_with_inertia() {
let _ = MassProperties::with_inertia(1.0, DMat3::ZERO, DVec3::ZERO);
}
#[test]
#[should_panic(expected = "non-finite or zero determinant")]
fn det_overflow_inertia_rejected_by_with_inertia() {
let _ = MassProperties::with_inertia(
1.0,
DMat3::from_diagonal(DVec3::new(1e103, 1e103, 1e103)),
DVec3::ZERO,
);
}
#[test]
#[should_panic(expected = "out of safe range")]
fn typed_new_panics_on_zero_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(0.0));
}
#[test]
#[should_panic(expected = "out of safe range")]
fn typed_new_panics_on_nan_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(f64::NAN));
}
#[test]
#[should_panic(expected = "out of safe range")]
fn typed_new_panics_on_negative_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(-1.0));
}
#[test]
#[should_panic(expected = "out of safe range")]
fn typed_new_panics_on_infinite_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(f64::INFINITY));
}
#[test]
#[should_panic(expected = "out of safe range")]
fn typed_new_panics_on_mass_below_safe_floor_cubic_underflow() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(1e-150));
}
#[test]
#[should_panic(expected = "out of safe range")]
fn typed_new_panics_on_huge_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(1e150));
}
#[test]
fn typed_new_accepts_safe_extremes() {
use astrodyn_quantities::frame::TestVehicle;
let lo = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(MIN_SAFE_MASS_KG));
let hi = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(MAX_SAFE_MASS_KG));
assert!(lo.inverse_mass.is_finite());
assert!(hi.inverse_mass.is_finite());
assert!(lo.inverse_inertia.is_finite());
assert!(hi.inverse_inertia.is_finite());
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_with_inertia_panics_on_zero_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(0.0),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(DMat3::IDENTITY),
Position::<StructuralFrame<TestVehicle>>::zero(),
);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_with_inertia_panics_on_negative_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(-1.0),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(DMat3::IDENTITY),
Position::<StructuralFrame<TestVehicle>>::zero(),
);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_with_inertia_panics_on_nan_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(f64::NAN),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(DMat3::IDENTITY),
Position::<StructuralFrame<TestVehicle>>::zero(),
);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_with_inertia_panics_on_infinite_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(f64::INFINITY),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(DMat3::IDENTITY),
Position::<StructuralFrame<TestVehicle>>::zero(),
);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_recompute_derived_panics_on_zero_mass() {
let mut mp = MassProperties::new(10.0);
mp.mass = 0.0;
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_recompute_derived_panics_on_zero_mass() {
use astrodyn_quantities::frame::TestVehicle;
let mut mp = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(10.0));
mp.mass = Mass::new::<kilogram>(0.0);
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_recompute_derived_panics_on_negative_mass() {
let mut mp = MassProperties::new(10.0);
mp.mass = -1.0;
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_recompute_derived_panics_on_nan_mass() {
let mut mp = MassProperties::new(10.0);
mp.mass = f64::NAN;
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_recompute_derived_panics_on_infinite_mass() {
let mut mp = MassProperties::new(10.0);
mp.mass = f64::INFINITY;
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_recompute_derived_panics_on_negative_mass() {
use astrodyn_quantities::frame::TestVehicle;
let mut mp = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(10.0));
mp.mass = Mass::new::<kilogram>(-1.0);
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_recompute_derived_panics_on_nan_mass() {
use astrodyn_quantities::frame::TestVehicle;
let mut mp = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(10.0));
mp.mass = Mass::new::<kilogram>(f64::NAN);
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_recompute_derived_panics_on_infinite_mass() {
use astrodyn_quantities::frame::TestVehicle;
let mut mp = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(10.0));
mp.mass = Mass::new::<kilogram>(f64::INFINITY);
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_with_inertia_panics_on_positive_subnormal_mass() {
let _ = MassProperties::with_inertia(1e-310, DMat3::IDENTITY, DVec3::ZERO);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_with_inertia_panics_on_positive_subnormal_mass() {
use astrodyn_quantities::frame::TestVehicle;
let _ = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(1e-310),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(DMat3::IDENTITY),
Position::<StructuralFrame<TestVehicle>>::zero(),
);
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn untyped_recompute_derived_panics_on_positive_subnormal_mass() {
let mut mp = MassProperties::new(10.0);
mp.mass = 1e-310;
mp.dirty = true;
mp.recompute_derived();
}
#[test]
#[should_panic(expected = "finite and strictly positive")]
fn typed_recompute_derived_panics_on_positive_subnormal_mass() {
use astrodyn_quantities::frame::TestVehicle;
let mut mp = MassPropertiesTyped::<TestVehicle>::new(Mass::new::<kilogram>(10.0));
mp.mass = Mass::new::<kilogram>(1e-310);
mp.dirty = true;
mp.recompute_derived();
}
#[test]
fn untyped_recompute_derived_accepts_huge_mass_with_sane_inertia() {
let mut mp = MassProperties::with_inertia(
1.0,
DMat3::from_diagonal(DVec3::new(100.0, 200.0, 300.0)),
DVec3::ZERO,
);
mp.mass = 1e200;
mp.dirty = true;
mp.recompute_derived();
assert!(mp.inverse_mass.is_finite());
assert!(mp.inverse_inertia.is_finite());
}
#[test]
#[should_panic(expected = "does not reproduce")]
fn cofactor_underflow_rejected_by_with_inertia() {
let _ = MassProperties::with_inertia(
1.0,
DMat3::from_diagonal(DVec3::new(1e300, 1e-200, 1e-200)),
DVec3::ZERO,
);
}
#[test]
fn typed_validate_consistency_passes() {
use astrodyn_quantities::frame::TestVehicle;
let typed = MassPropertiesTyped::<TestVehicle>::with_inertia(
Mass::new::<kilogram>(10.0),
InertiaTensor::<BodyFrame<TestVehicle>>::from_dmat3_unchecked(DMat3::from_diagonal(
DVec3::new(100.0, 200.0, 300.0),
)),
Position::<StructuralFrame<TestVehicle>>::zero(),
);
typed.validate_consistency(1e-6);
}
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_dmat3_full_rank() -> impl Strategy<Value = DMat3> {
(
(1.0_f64..1.0e6_f64),
(1.0_f64..1.0e6_f64),
(1.0_f64..1.0e6_f64),
(-1.0_f64..1.0_f64),
(-1.0_f64..1.0_f64),
(-1.0_f64..1.0_f64),
)
.prop_map(|(ix, iy, iz, ax, ay, az)| {
let diag = DMat3::from_diagonal(DVec3::new(ix, iy, iz));
let axis = DVec3::new(ax, ay, az);
let rot = if axis.length_squared() > 1.0e-6 {
let angle = 0.1; glam::DMat3::from_axis_angle(axis.normalize(), angle)
} else {
DMat3::IDENTITY
};
rot.transpose() * diag * rot
})
}
fn arb_arbitrary_dmat3() -> impl Strategy<Value = DMat3> {
(
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
arb_finite_bounded(),
)
.prop_map(|(a, b, c, d, e, f, g, h, i)| {
DMat3::from_cols(
DVec3::new(a, b, c),
DVec3::new(d, e, f),
DVec3::new(g, h, i),
)
})
}
fn arb_mass_properties() -> impl Strategy<Value = MassProperties> {
(
(1.0e-3_f64..1.0e6_f64),
arb_dmat3_full_rank(),
arb_dvec3(),
arb_arbitrary_dmat3(),
)
.prop_map(|(mass, inertia, position, t_parent_this)| MassProperties {
mass,
inverse_mass: 1.0 / mass,
inertia,
inverse_inertia: inertia.inverse(),
position,
t_parent_this,
dirty: false,
})
}
proptest! {
#[test]
fn round_trip_mass_properties_untyped_typed_untyped(orig in arb_mass_properties()) {
let typed = MassPropertiesTyped::<TestVehicle>::from_untyped_unchecked(&orig);
prop_assert_eq!(typed.to_untyped(), orig);
}
#[test]
fn round_trip_mass_properties_typed_untyped_typed(orig in arb_mass_properties()) {
let typed = MassPropertiesTyped::<TestVehicle>::from_untyped_unchecked(&orig);
let lifted = MassPropertiesTyped::<TestVehicle>::from_untyped_unchecked(&typed.to_untyped());
prop_assert_eq!(lifted.to_untyped(), typed.to_untyped());
}
}
}