Skip to main content

hpr_motor/
mass.rs

1//! Axisymmetric mass elements: a mass on the motor axis with its own axial and transverse moments
2//! of inertia, and how elements combine.
3//!
4//! **Motor axis coordinate.** Positions are meters along the motor's axis, measured **from the
5//! nozzle exit plane toward the forward closure**, so `+z` points toward the rocket's nose like the
6//! body frame's `+z` (`docs/physics/frames.md`). Every element is symmetric about that axis, so its
7//! inertia tensor about its own center of mass is `diag(I_t, I_t, I_a)`.
8//!
9//! **Combining** uses the parallel-axis theorem (any dynamics text, e.g. Meriam and Kraige,
10//! *Engineering Mechanics: Dynamics*, the appendix on mass moments of inertia). For elements with
11//! masses `m_k`, centers `z_k`, and inertias `I_a,k`, `I_t,k` about their own centers,
12//!
13//! ```text
14//! m = Σ m_k,   z = Σ m_k z_k / m,   I_a = Σ I_a,k,   I_t = Σ (I_t,k + m_k (z_k − z)²)
15//! ```
16//!
17//! See `docs/physics/motor.md`.
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::MotorError;
22
23/// A mass on the motor axis, with its moments of inertia about its own center of mass.
24#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct MassElement {
27    /// Mass, kg.
28    pub mass_kg: f64,
29    /// Center of mass along the motor axis, m from the nozzle exit toward the forward end.
30    pub cg_m: f64,
31    /// Moment of inertia about the motor axis, through the element's center of mass, kg·m².
32    pub axial_inertia_kg_m2: f64,
33    /// Moment of inertia about a transverse axis through the element's center of mass, kg·m².
34    pub transverse_inertia_kg_m2: f64,
35}
36
37impl MassElement {
38    /// A massless element at the origin.
39    pub const ZERO: Self = Self {
40        mass_kg: 0.0,
41        cg_m: 0.0,
42        axial_inertia_kg_m2: 0.0,
43        transverse_inertia_kg_m2: 0.0,
44    };
45
46    /// Checks that the mass and inertias are finite and non-negative and the position finite;
47    /// `what` names the mass, position, axial and transverse inertia in an error.
48    pub(crate) fn validate(&self, what: [&'static str; 4]) -> Result<(), MotorError> {
49        let checks = [
50            (self.mass_kg, what[0], true),
51            (self.cg_m, what[1], false),
52            (self.axial_inertia_kg_m2, what[2], true),
53            (self.transverse_inertia_kg_m2, what[3], true),
54        ];
55        for (value, name, non_negative) in checks {
56            if !value.is_finite() || (non_negative && value < 0.0) {
57                return Err(MotorError::Domain { what: name, value });
58            }
59        }
60        Ok(())
61    }
62
63    /// A thin-walled tube of radius `radius_m` and length `length_m` centered at `cg_m`:
64    /// `I_a = m r²`, `I_t = m (r²/2 + L²/12)`.
65    pub fn thin_tube(mass_kg: f64, cg_m: f64, radius_m: f64, length_m: f64) -> Self {
66        let r2 = radius_m * radius_m;
67        Self {
68            mass_kg,
69            cg_m,
70            axial_inertia_kg_m2: mass_kg * r2,
71            transverse_inertia_kg_m2: mass_kg * (0.5 * r2 + length_m * length_m / 12.0),
72        }
73    }
74
75    /// A hollow cylinder of outer radius `outer_radius_m`, inner radius `inner_radius_m` and
76    /// length `length_m`, centered at `cg_m`: `I_a = ½ m (R² + r²)`,
77    /// `I_t = m ((R² + r²)/4 + L²/12)`. An inner radius of zero gives a solid cylinder.
78    pub fn hollow_cylinder(
79        mass_kg: f64,
80        cg_m: f64,
81        outer_radius_m: f64,
82        inner_radius_m: f64,
83        length_m: f64,
84    ) -> Self {
85        let radii = outer_radius_m * outer_radius_m + inner_radius_m * inner_radius_m;
86        Self {
87            mass_kg,
88            cg_m,
89            axial_inertia_kg_m2: 0.5 * mass_kg * radii,
90            transverse_inertia_kg_m2: mass_kg * (0.25 * radii + length_m * length_m / 12.0),
91        }
92    }
93
94    /// The elements combined into one: total mass, mass-weighted center, and inertias moved to
95    /// that center with the parallel-axis theorem. With zero total mass the center is the plain
96    /// average of the element positions (or 0 with no elements) and the inertias are summed; a NaN
97    /// mass gives a NaN center.
98    pub fn combine<'a>(elements: impl IntoIterator<Item = &'a MassElement> + Clone) -> Self {
99        let mut mass = 0.0;
100        let mut moment = 0.0;
101        let mut axial = 0.0;
102        let mut count = 0.0;
103        let mut position_sum = 0.0;
104        for element in elements.clone() {
105            mass += element.mass_kg;
106            moment += element.mass_kg * element.cg_m;
107            axial += element.axial_inertia_kg_m2;
108            count += 1.0;
109            position_sum += element.cg_m;
110        }
111        let cg = if mass.is_nan() {
112            f64::NAN
113        } else if mass > 0.0 {
114            moment / mass
115        } else if count > 0.0 {
116            position_sum / count
117        } else {
118            0.0
119        };
120        let transverse = elements
121            .into_iter()
122            .map(|element| {
123                let arm = element.cg_m - cg;
124                element.transverse_inertia_kg_m2 + element.mass_kg * arm * arm
125            })
126            .sum();
127        Self {
128            mass_kg: mass,
129            cg_m: cg,
130            axial_inertia_kg_m2: axial,
131            transverse_inertia_kg_m2: transverse,
132        }
133    }
134
135    /// The transverse moment of inertia about an axis through `z_m` instead of the center of mass,
136    /// kg·m² (the parallel-axis theorem).
137    pub fn transverse_inertia_about(&self, z_m: f64) -> f64 {
138        let arm = self.cg_m - z_m;
139        self.transverse_inertia_kg_m2 + self.mass_kg * arm * arm
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn combining_matches_the_parallel_axis_theorem() {
149        let a = MassElement::hollow_cylinder(2.0, 0.1, 0.02, 0.01, 0.2);
150        let b = MassElement::thin_tube(1.0, 0.4, 0.03, 0.5);
151        let c = MassElement::combine([&a, &b]);
152        assert_eq!(c.mass_kg, 3.0);
153        assert!((c.cg_m - 0.2).abs() < 1e-15);
154        assert_eq!(
155            c.axial_inertia_kg_m2,
156            a.axial_inertia_kg_m2 + b.axial_inertia_kg_m2
157        );
158        let expected = a.transverse_inertia_kg_m2
159            + 2.0 * 0.1 * 0.1
160            + b.transverse_inertia_kg_m2
161            + 1.0 * 0.2 * 0.2;
162        assert!((c.transverse_inertia_kg_m2 - expected).abs() < 1e-15);
163        // Splitting a cylinder into two halves and recombining gives the whole cylinder back.
164        let whole = MassElement::hollow_cylinder(4.0, 0.5, 0.05, 0.02, 1.0);
165        let lower = MassElement::hollow_cylinder(2.0, 0.25, 0.05, 0.02, 0.5);
166        let upper = MassElement::hollow_cylinder(2.0, 0.75, 0.05, 0.02, 0.5);
167        let joined = MassElement::combine([&lower, &upper]);
168        assert!((joined.cg_m - whole.cg_m).abs() < 1e-15);
169        assert!((joined.axial_inertia_kg_m2 - whole.axial_inertia_kg_m2).abs() < 1e-15);
170        assert!((joined.transverse_inertia_kg_m2 - whole.transverse_inertia_kg_m2).abs() < 1e-15);
171        assert_eq!(
172            joined.transverse_inertia_about(0.0),
173            joined.transverse_inertia_kg_m2 + 4.0 * 0.25
174        );
175    }
176
177    #[test]
178    fn a_thin_tube_is_the_limit_of_a_hollow_cylinder() {
179        let tube = MassElement::thin_tube(1.0, 0.0, 0.05, 0.3);
180        let shell = MassElement::hollow_cylinder(1.0, 0.0, 0.05, 0.05, 0.3);
181        assert!((tube.axial_inertia_kg_m2 - shell.axial_inertia_kg_m2).abs() < 1e-15);
182        assert!((tube.transverse_inertia_kg_m2 - shell.transverse_inertia_kg_m2).abs() < 1e-15);
183    }
184
185    #[test]
186    fn massless_combinations_stay_finite() {
187        let none: [&MassElement; 0] = [];
188        assert_eq!(MassElement::combine(none), MassElement::ZERO);
189        let empty = MassElement {
190            cg_m: 0.3,
191            ..MassElement::ZERO
192        };
193        let other = MassElement {
194            cg_m: 0.5,
195            ..MassElement::ZERO
196        };
197        let c = MassElement::combine([&empty, &other]);
198        assert!((c.cg_m - 0.4).abs() < 1e-15);
199        assert_eq!(c.transverse_inertia_kg_m2, 0.0);
200    }
201}