Skip to main content

hpr_motor/
grains.rs

1//! BATES grains: a stack of identical hollow cylindrical grains that burn on their bores and,
2//! unless inhibited, on both ends.
3//!
4//! The geometry follows RocketPy's `SolidMotor` (MIT; `rocketpy/motors/solid_motor.py`, v1.13.0),
5//! which integrates the regression as an ODE in time. Here it is solved exactly in the burned
6//! mass instead. Every burning surface recedes by the same web depth `x`, so one grain's volume is
7//!
8//! ```text
9//! V(x) = π (R² − (r₀ + x)²) (h₀ − 2x)    ends burning,  0 ≤ x ≤ min(R − r₀, h₀/2)
10//! V(x) = π (R² − (r₀ + x)²) h₀           ends inhibited, 0 ≤ x ≤ R − r₀
11//! ```
12//!
13//! and `dV/dx = −A_b`, the burning area `2π (r h + R² − r²)` (or `2π r h`), which is RocketPy's
14//! `ṙ = −V̇/A_b`, `ḣ = −2ṙ` written without time. Given the propellant mass left, `V(x) = m/(N ρ)`
15//! is solved for `x` by safeguarded Newton iteration; `V` falls monotonically, so the root is
16//! unique.
17//!
18//! Every grain needs a bore (`r₀ > 0`): a solid end burner shortens without widening, which this
19//! regression doesn't describe (nor does RocketPy's). Facing ends burn even with no gap between
20//! grains, as in RocketPy.
21//!
22//! The grains' centers stay put (both ends burn equally), spaced `h₀ + s` apart. About the
23//! stack's center, with `m_g = m/N` per grain and the current `r` and `h`:
24//!
25//! ```text
26//! I_a = ½ m (R² + r²)
27//! I_t = N m_g ((R² + r²)/4 + h²/12) + m_g (h₀ + s)² N (N² − 1)/12
28//! ```
29//!
30//! The last term is `Σ m_g d_k²` over grain offsets `d_k = (k − (N−1)/2)(h₀ + s)`
31//! (`solid_motor.py:724-740, 784-789`). See `docs/physics/motor.md`.
32
33use std::f64::consts::PI;
34
35use serde::{Deserialize, Serialize};
36
37use crate::error::MotorError;
38use crate::mass::MassElement;
39
40/// A stack of identical BATES grains.
41#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct BatesGrains {
44    /// Number of grains `N`, at least 1.
45    pub count: u32,
46    /// Propellant density `ρ`, kg/m³.
47    pub density_kg_m3: f64,
48    /// Grain outer radius `R`, m.
49    pub outer_radius_m: f64,
50    /// Initial bore radius `r₀`, m, positive.
51    pub initial_inner_radius_m: f64,
52    /// Initial grain height (length) `h₀`, m.
53    pub initial_height_m: f64,
54    /// Gap between adjacent grains `s`, m.
55    pub separation_m: f64,
56    /// Center of the grain stack along the motor axis, m from the nozzle exit.
57    pub center_m: f64,
58    /// Whether the grain ends are inhibited, so only the bores burn (RocketPy's
59    /// `only_radial_burn`).
60    pub inhibited_ends: bool,
61}
62
63/// The shape of one grain at some point in the burn.
64#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
65pub struct GrainShape {
66    /// Web burned so far `x`, m.
67    pub web_burned_m: f64,
68    /// Bore radius `r₀ + x`, m.
69    pub inner_radius_m: f64,
70    /// Grain height, m.
71    pub height_m: f64,
72}
73
74impl BatesGrains {
75    /// Checks the geometry.
76    ///
77    /// # Errors
78    ///
79    /// [`MotorError::Domain`] for a value that is not finite or out of range (including no bore),
80    /// and [`MotorError::Inconsistent`] for no grains, a bore at least as wide as the grain, or a
81    /// propellant mass that isn't a positive finite number (geometry so small or dense that it
82    /// underflows or overflows).
83    pub fn validate(&self) -> Result<(), MotorError> {
84        if self.count == 0 {
85            return Err(MotorError::Inconsistent(
86                "a grain stack needs at least one grain".into(),
87            ));
88        }
89        let positive = [
90            (self.density_kg_m3, "grain density (kg/m³)"),
91            (self.outer_radius_m, "grain outer radius (m)"),
92            (self.initial_inner_radius_m, "grain bore radius (m)"),
93            (self.initial_height_m, "grain height (m)"),
94        ];
95        for (value, what) in positive {
96            if !(value.is_finite() && value > 0.0) {
97                return Err(MotorError::Domain { what, value });
98            }
99        }
100        if !(self.separation_m.is_finite() && self.separation_m >= 0.0) {
101            return Err(MotorError::Domain {
102                what: "grain separation (m)",
103                value: self.separation_m,
104            });
105        }
106        if !self.center_m.is_finite() {
107            return Err(MotorError::Domain {
108                what: "grain stack center (m)",
109                value: self.center_m,
110            });
111        }
112        if self.initial_inner_radius_m >= self.outer_radius_m {
113            return Err(MotorError::Inconsistent(format!(
114                "grain bore radius {} m is not inside the outer radius {} m",
115                self.initial_inner_radius_m, self.outer_radius_m
116            )));
117        }
118        let mass = self.initial_mass_kg();
119        if !(mass.is_finite() && mass > 0.0) {
120            return Err(MotorError::Inconsistent(format!(
121                "the grains' propellant mass {mass} kg is not a positive finite number"
122            )));
123        }
124        Ok(())
125    }
126
127    /// The total propellant mass before ignition, `N ρ π (R² − r₀²) h₀`, kg.
128    pub fn initial_mass_kg(&self) -> f64 {
129        f64::from(self.count) * self.density_kg_m3 * self.volume_m3(0.0)
130    }
131
132    /// The largest web the grains can burn, m: where the bore reaches the outer radius or, with
133    /// burning ends, the height reaches zero.
134    pub fn web_m(&self) -> f64 {
135        let radial = self.outer_radius_m - self.initial_inner_radius_m;
136        if self.inhibited_ends {
137            radial
138        } else {
139            radial.min(0.5 * self.initial_height_m)
140        }
141    }
142
143    /// One grain's shape when `mass_kg` of propellant is left in the stack. Masses at or above the
144    /// initial mass give the initial shape, and masses at or below zero the burned-out shape.
145    pub fn shape(&self, mass_kg: f64) -> GrainShape {
146        let target = mass_kg / (f64::from(self.count) * self.density_kg_m3);
147        let web = self.web_m();
148        if target.is_nan() || !(web.is_finite() && web > 0.0) {
149            return GrainShape {
150                web_burned_m: f64::NAN,
151                inner_radius_m: f64::NAN,
152                height_m: f64::NAN,
153            };
154        }
155        let x = if target >= self.volume_m3(0.0) {
156            0.0
157        } else if target <= 0.0 {
158            web
159        } else {
160            self.solve_web(target, web)
161        };
162        self.shape_at_web(x)
163    }
164
165    /// The grains, with `mass_kg` of propellant left, as one mass element about the stack center.
166    pub fn mass_element(&self, mass_kg: f64) -> MassElement {
167        let mass = if mass_kg.is_nan() {
168            f64::NAN
169        } else {
170            mass_kg.max(0.0)
171        };
172        let shape = self.shape(mass);
173        let n = f64::from(self.count);
174        let radii = self.outer_radius_m.powi(2) + shape.inner_radius_m.powi(2);
175        let grain = mass / n;
176        let pitch = self.initial_height_m + self.separation_m;
177        MassElement {
178            mass_kg: mass,
179            cg_m: self.center_m,
180            axial_inertia_kg_m2: 0.5 * mass * radii,
181            transverse_inertia_kg_m2: n * grain * (0.25 * radii + shape.height_m.powi(2) / 12.0)
182                + grain * pitch * pitch * n * (n * n - 1.0) / 12.0,
183        }
184    }
185
186    /// One grain's volume after burning a web `x`, m³.
187    fn volume_m3(&self, x: f64) -> f64 {
188        let shape = self.shape_at_web(x);
189        PI * (self.outer_radius_m.powi(2) - shape.inner_radius_m.powi(2)) * shape.height_m
190    }
191
192    /// The burning area `A_b = −dV/dx` of one grain at web `x`, m².
193    fn burning_area_m2(&self, x: f64) -> f64 {
194        let shape = self.shape_at_web(x);
195        let bore = 2.0 * PI * shape.inner_radius_m * shape.height_m;
196        if self.inhibited_ends {
197            bore
198        } else {
199            bore + 2.0 * PI * (self.outer_radius_m.powi(2) - shape.inner_radius_m.powi(2))
200        }
201    }
202
203    fn shape_at_web(&self, x: f64) -> GrainShape {
204        let height = if self.inhibited_ends {
205            self.initial_height_m
206        } else {
207            (self.initial_height_m - 2.0 * x).max(0.0)
208        };
209        GrainShape {
210            web_burned_m: x,
211            inner_radius_m: (self.initial_inner_radius_m + x).min(self.outer_radius_m),
212            height_m: height,
213        }
214    }
215
216    /// Solves `V(x) = target` on `[0, web]`, where `V(0) > target > 0 = V(web)`.
217    fn solve_web(&self, target: f64, web: f64) -> f64 {
218        // Newton's method from the first step at x = 0, kept inside a bracket [lo, hi] with
219        // V(lo) > target > V(hi): a step that leaves the bracket bisects it instead. Newton
220        // converges in a handful of steps, and stops once a step is within rounding of zero.
221        let tolerance = 4.0 * f64::EPSILON * web;
222        let (mut lo, mut hi) = (0.0, web);
223        let mut x = ((self.volume_m3(0.0) - target) / self.burning_area_m2(0.0)).clamp(0.0, web);
224        for _ in 0..100 {
225            let residual = self.volume_m3(x) - target;
226            if residual == 0.0 {
227                return x;
228            }
229            if residual > 0.0 {
230                lo = x;
231            } else {
232                hi = x;
233            }
234            let area = self.burning_area_m2(x);
235            let newton = x + residual / area;
236            if area > 0.0 && (newton - x).abs() <= tolerance {
237                return newton.clamp(lo, hi);
238            }
239            x = if area > 0.0 && newton > lo && newton < hi {
240                newton
241            } else {
242                0.5 * (lo + hi)
243            };
244            if hi - lo <= tolerance {
245                return x;
246            }
247        }
248        x
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use proptest::prelude::*;
255
256    use super::*;
257
258    fn grains(inhibited_ends: bool) -> BatesGrains {
259        BatesGrains {
260            count: 3,
261            density_kg_m3: 1815.0,
262            outer_radius_m: 0.0165,
263            initial_inner_radius_m: 0.006,
264            initial_height_m: 0.09,
265            separation_m: 0.005,
266            center_m: 0.2,
267            inhibited_ends,
268        }
269    }
270
271    #[test]
272    fn initial_mass_and_inertia_match_the_closed_forms() {
273        let g = grains(false);
274        let volume = PI * (0.0165f64.powi(2) - 0.006f64.powi(2)) * 0.09;
275        assert!((g.initial_mass_kg() - 3.0 * 1815.0 * volume).abs() < 1e-15);
276        let m = g.initial_mass_kg();
277        let e = g.mass_element(m);
278        let radii = 0.0165f64.powi(2) + 0.006f64.powi(2);
279        assert!((e.axial_inertia_kg_m2 - 0.5 * m * radii).abs() < 1e-18);
280        // Three grains 0.095 m apart: offsets −0.095, 0, 0.095.
281        let each = m / 3.0 * (radii / 4.0 + 0.09f64.powi(2) / 12.0);
282        let spread = m / 3.0 * 2.0 * 0.095f64.powi(2);
283        assert!((e.transverse_inertia_kg_m2 - (3.0 * each + spread)).abs() < 1e-15);
284        assert_eq!(e.cg_m, 0.2);
285    }
286
287    #[test]
288    fn shape_inverts_the_volume() {
289        for inhibited in [false, true] {
290            let g = grains(inhibited);
291            for x in [0.0, 1e-4, 0.003, 0.007, 0.01, g.web_m()] {
292                let mass = 3.0 * 1815.0 * g.volume_m3(x);
293                let shape = g.shape(mass);
294                assert!(
295                    (shape.web_burned_m - x).abs() < 1e-12,
296                    "{inhibited} {x} {shape:?}"
297                );
298            }
299            assert_eq!(g.shape(g.initial_mass_kg() * 2.0).web_burned_m, 0.0);
300            assert_eq!(g.shape(-1.0).web_burned_m, g.web_m());
301        }
302        // Short grains burn out axially: the height reaches zero before the bore reaches R.
303        let short = BatesGrains {
304            initial_height_m: 0.01,
305            ..grains(false)
306        };
307        assert_eq!(short.web_m(), 0.005);
308        assert_eq!(short.shape(0.0).height_m, 0.0);
309    }
310
311    #[test]
312    fn rejects_bad_geometry() {
313        let good = grains(false);
314        let bad = [
315            BatesGrains { count: 0, ..good },
316            BatesGrains {
317                density_kg_m3: 0.0,
318                ..good
319            },
320            BatesGrains {
321                outer_radius_m: f64::NAN,
322                ..good
323            },
324            BatesGrains {
325                initial_inner_radius_m: 0.0165,
326                ..good
327            },
328            BatesGrains {
329                initial_inner_radius_m: -0.001,
330                ..good
331            },
332            BatesGrains {
333                separation_m: -0.001,
334                ..good
335            },
336            BatesGrains {
337                center_m: f64::INFINITY,
338                ..good
339            },
340            BatesGrains {
341                initial_inner_radius_m: 0.0,
342                inhibited_ends: true,
343                ..good
344            },
345            // An end burner: no bore, ends burning.
346            BatesGrains {
347                initial_inner_radius_m: 0.0,
348                ..good
349            },
350            // Geometry whose mass underflows or overflows.
351            BatesGrains {
352                outer_radius_m: 1e-200,
353                initial_inner_radius_m: 1e-201,
354                ..good
355            },
356            BatesGrains {
357                density_kg_m3: 1e308,
358                ..good
359            },
360        ];
361        for g in bad {
362            assert!(g.validate().is_err(), "{g:?}");
363        }
364        assert!(good.validate().is_ok());
365        // Unvalidated geometry gives NaN rather than panicking.
366        for g in [
367            BatesGrains {
368                outer_radius_m: f64::NAN,
369                inhibited_ends: true,
370                ..good
371            },
372            BatesGrains {
373                outer_radius_m: f64::NEG_INFINITY,
374                ..good
375            },
376            BatesGrains {
377                initial_inner_radius_m: 0.02,
378                ..good
379            },
380        ] {
381            assert!(g.shape(0.1).web_burned_m.is_nan(), "{g:?}");
382            let _ = g.mass_element(0.1);
383        }
384        assert!(good.shape(f64::NAN).height_m.is_nan());
385        assert!(good.mass_element(f64::NAN).mass_kg.is_nan());
386    }
387
388    proptest! {
389        #[test]
390        fn burning_area_is_minus_the_volume_derivative(
391            fraction in 0.01..0.99f64, inhibited in any::<bool>()
392        ) {
393            let g = grains(inhibited);
394            let x = fraction * g.web_m();
395            let h = 1e-7;
396            let derivative = (g.volume_m3(x + h) - g.volume_m3(x - h)) / (2.0 * h);
397            prop_assert!((derivative + g.burning_area_m2(x)).abs() < 1e-6 * g.burning_area_m2(x));
398        }
399
400        #[test]
401        fn mass_element_mass_round_trips(fraction in 0.0..1.0f64, inhibited in any::<bool>()) {
402            let g = grains(inhibited);
403            let mass = fraction * g.initial_mass_kg();
404            let shape = g.shape(mass);
405            let back = 3.0 * 1815.0 * g.volume_m3(shape.web_burned_m);
406            prop_assert!((back - mass).abs() <= 1e-12 * g.initial_mass_kg());
407        }
408    }
409}