1use std::f64::consts::PI;
34
35use serde::{Deserialize, Serialize};
36
37use crate::error::MotorError;
38use crate::mass::MassElement;
39
40#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct BatesGrains {
44 pub count: u32,
46 pub density_kg_m3: f64,
48 pub outer_radius_m: f64,
50 pub initial_inner_radius_m: f64,
52 pub initial_height_m: f64,
54 pub separation_m: f64,
56 pub center_m: f64,
58 pub inhibited_ends: bool,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
65pub struct GrainShape {
66 pub web_burned_m: f64,
68 pub inner_radius_m: f64,
70 pub height_m: f64,
72}
73
74impl BatesGrains {
75 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 pub fn initial_mass_kg(&self) -> f64 {
129 f64::from(self.count) * self.density_kg_m3 * self.volume_m3(0.0)
130 }
131
132 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 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 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 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 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 fn solve_web(&self, target: f64, web: f64) -> f64 {
218 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 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 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 BatesGrains {
347 initial_inner_radius_m: 0.0,
348 ..good
349 },
350 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 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}