1use crate::{Error, Result};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum NoseShape {
19 Conical,
21 Ogive,
23 Elliptical,
25 Parabolic,
27}
28
29impl std::str::FromStr for NoseShape {
30 type Err = Error;
31
32 fn from_str(s: &str) -> Result<Self> {
33 match s {
34 "conical" | "cone" => Ok(Self::Conical),
35 "ogive" => Ok(Self::Ogive),
36 "elliptical" | "ellipse" => Ok(Self::Elliptical),
37 "parabolic" => Ok(Self::Parabolic),
38 _ => Err(Error::invalid(
39 "nose shape",
40 "conical, ogive, elliptical or parabolic",
41 )),
42 }
43 }
44}
45
46impl NoseShape {
47 fn cp_fraction(self) -> f64 {
50 match self {
51 Self::Conical => 2.0 / 3.0,
52 Self::Ogive => 0.466,
53 Self::Elliptical => 1.0 / 3.0,
54 Self::Parabolic => 0.5,
55 }
56 }
57
58 fn lateral_area(self, length_m: f64, diameter_m: f64) -> f64 {
61 let r = diameter_m / 2.0;
62 match self {
63 Self::Conical => std::f64::consts::PI * r * (r * r + length_m * length_m).sqrt(),
64 _ => std::f64::consts::PI * diameter_m * length_m,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy)]
71pub struct NoseCone {
72 pub shape: NoseShape,
74 pub length_m: f64,
76 pub diameter_m: f64,
78}
79
80#[derive(Debug, Clone, Copy)]
82pub struct BodyTube {
83 pub length_m: f64,
85 pub diameter_m: f64,
87}
88
89#[derive(Debug, Clone, Copy)]
92pub struct TrapezoidalFins {
93 pub count: u32,
95 pub root_chord_m: f64,
97 pub tip_chord_m: f64,
99 pub span_m: f64,
101 pub leading_sweep_m: f64,
103 pub thickness_m: f64,
105}
106
107#[derive(Debug, Clone, Copy)]
111pub struct BoatTail {
112 pub length_m: f64,
114 pub fore_diameter_m: f64,
116 pub aft_diameter_m: f64,
118}
119
120#[derive(Debug, Clone, Copy)]
123pub struct RocketGeometry {
124 pub nose: NoseCone,
126 pub tube: BodyTube,
128 pub fins: TrapezoidalFins,
130 pub boat_tail: Option<BoatTail>,
132 pub roughness_m: f64,
134}
135
136const ELLIPSOID_TABLE: &[(f64, f64)] = &[
140 (1.2, 0.110),
141 (1.25, 0.128),
142 (1.3, 0.140),
143 (1.4, 0.148),
144 (1.6, 0.152),
145 (2.0, 0.159),
146 (2.4, 0.162),
147];
148const PARABOLIC_TABLE: &[(f64, f64)] = &[
149 (0.95, 0.0),
150 (0.975, 0.016),
151 (1.0, 0.041),
152 (1.05, 0.092),
153 (1.1, 0.109),
154 (1.2, 0.119),
155 (1.4, 0.113),
156 (1.7, 0.108),
157];
158
159impl RocketGeometry {
160 pub fn new(nose: NoseCone, tube: BodyTube, fins: TrapezoidalFins) -> Self {
162 Self {
163 nose,
164 tube,
165 fins,
166 boat_tail: None,
167 roughness_m: 20e-6,
168 }
169 }
170
171 pub fn with_roughness(mut self, roughness_m: f64) -> Self {
173 self.roughness_m = roughness_m;
174 self
175 }
176
177 pub fn with_boat_tail(mut self, boat_tail: BoatTail) -> Result<Self> {
180 if boat_tail.length_m <= 0.0 || !boat_tail.length_m.is_finite() {
181 return Err(Error::invalid("boat tail", "length must be positive"));
182 }
183 if (boat_tail.fore_diameter_m - self.tube.diameter_m).abs() > 1e-12 {
184 return Err(Error::invalid(
185 "boat tail",
186 "forward diameter must equal the body tube diameter",
187 ));
188 }
189 if boat_tail.aft_diameter_m <= 0.0 || boat_tail.aft_diameter_m >= boat_tail.fore_diameter_m
190 {
191 return Err(Error::invalid(
192 "boat tail",
193 "aft diameter must be positive and smaller than the forward diameter",
194 ));
195 }
196 self.boat_tail = Some(boat_tail);
197 Ok(self)
198 }
199
200 pub fn total_length_m(&self) -> f64 {
202 self.nose.length_m + self.tube.length_m + self.boat_tail.map(|b| b.length_m).unwrap_or(0.0)
203 }
204
205 pub fn max_diameter_m(&self) -> f64 {
207 self.nose.diameter_m.max(self.tube.diameter_m)
208 }
209
210 pub fn ref_area_m2(&self) -> f64 {
212 std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
213 }
214
215 fn fin_area(&self) -> f64 {
216 self.fins.span_m * (self.fins.root_chord_m + self.fins.tip_chord_m) / 2.0
217 }
218
219 fn mac(&self) -> f64 {
221 let (cr, ct) = (self.fins.root_chord_m, self.fins.tip_chord_m);
222 (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct))
223 }
224
225 fn fin_cp_fraction(&self, mach: f64) -> f64 {
230 if mach <= 0.5 {
231 return 0.25;
232 }
233 let ar = 2.0 * self.fins.span_m.powi(2) / self.fin_area();
234 let supersonic = |m: f64| {
235 let beta = (m * m - 1.0).sqrt();
236 (ar * beta - 0.67) / (2.0 * ar * beta - 1.0)
237 };
238 if mach >= 2.0 {
239 return supersonic(mach);
240 }
241 let hi = supersonic(2.0);
242 0.25 + (hi - 0.25) * (mach - 0.5) / 1.5
243 }
244
245 fn fin_cp(&self, mach: f64) -> f64 {
247 let (cr, ct, s, m) = (
248 self.fins.root_chord_m,
249 self.fins.tip_chord_m,
250 self.fins.span_m,
251 self.fins.leading_sweep_m,
252 );
253 let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
254 let x_mac_le = m * y_mac / s;
255 x_mac_le + self.fin_cp_fraction(mach) * self.mac()
256 }
257
258 fn leading_edge_cos(&self) -> f64 {
260 let (s, m) = (self.fins.span_m, self.fins.leading_sweep_m);
261 s / (s * s + m * m).sqrt()
262 }
263
264 fn fin_pressure_cd(&self, mach: f64) -> f64 {
268 let span = self.fins.span_m;
269 let thickness = self.fins.thickness_m;
270 if span <= 0.0 || thickness <= 0.0 {
271 return 0.0;
272 }
273 let leading_edge = if mach < 0.9 {
274 (1.0 - mach * mach).powf(-0.417) - 1.0
275 } else if mach < 1.0 {
276 1.0 - 1.785 * (mach - 0.9)
277 } else {
278 let m2 = mach * mach;
279 1.214 - 0.502 / m2 + 0.1095 / (m2 * m2)
280 };
281 let cos_le = self.leading_edge_cos();
282 let cd = leading_edge * cos_le * cos_le + Self::base_cd(mach) / 2.0;
283 cd * span * thickness / self.ref_area_m2() * self.fins.count as f64
286 }
287
288 fn nose_pressure_cd(&self, mach: f64) -> f64 {
294 let sinphi = match self.nose.shape {
299 NoseShape::Conical | NoseShape::Ogive => self.nose_sinphi(),
300 NoseShape::Elliptical => return self.table_pressure_cd(mach, ELLIPSOID_TABLE),
301 NoseShape::Parabolic => return self.table_pressure_cd(mach, PARABOLIC_TABLE),
302 };
303 const GAMMA: f64 = 1.4;
304 let cd_mach0 = 0.8 * sinphi * sinphi;
305 let y1 = 2.1 * sinphi * sinphi + 0.6019 * sinphi;
306 let d0 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * sinphi);
307 let d1 = -1.1341 * sinphi;
308 if mach >= 1.3 {
309 return 2.1 * sinphi * sinphi + 0.5 * sinphi / (mach * mach - 1.0).sqrt();
310 }
311 if mach >= 1.0 {
312 return Self::hermite(mach, 1.0, 1.3, sinphi, y1, d0, d1);
313 }
314 let eps = 0.01;
317 let at_eps = Self::hermite(1.0 + eps, 1.0, 1.3, sinphi, y1, d0, d1);
318 let min_deriv = (at_eps - sinphi) / eps;
319 let gap = sinphi - cd_mach0;
320 if gap <= 0.01 || min_deriv <= 0.01 {
321 return cd_mach0;
322 }
323 let b = min_deriv / gap;
324 gap * mach.powf(b) + cd_mach0
325 }
326
327 fn nose_sinphi(&self) -> f64 {
330 let l = self.nose.length_m;
331 let r = self.nose.diameter_m / 2.0;
332 let r_tip = self.nose_radius(0.99 * l);
333 (r - r_tip) / (r - r_tip).hypot(0.01 * l)
334 }
335
336 fn stagnation_cd(mach: f64) -> f64 {
338 let m2 = mach * mach;
339 if mach <= 1.0 {
340 0.85 * (1.0 + m2 / 4.0 + m2 * m2 / 40.0)
341 } else {
342 0.85 * (1.84 - 0.76 / m2 + 0.166 / (m2 * m2) + 0.035 / (m2 * m2 * m2))
343 }
344 }
345
346 fn table_interp(table: &[(f64, f64)], mach: f64) -> f64 {
348 if mach <= table[0].0 {
349 return table[0].1;
350 }
351 for pair in table.windows(2) {
352 let (lo, hi) = (pair[0], pair[1]);
353 if mach <= hi.0 {
354 let f = (mach - lo.0) / (hi.0 - lo.0);
355 return lo.1 + f * (hi.1 - lo.1);
356 }
357 }
358 table[table.len() - 1].1
359 }
360
361 fn table_pressure_cd(&self, mach: f64, table: &[(f64, f64)]) -> f64 {
365 let fineness = self.nose.length_m / self.nose.diameter_m;
366 let log4 = (fineness + 1.0).ln() / 4.0_f64.ln();
367 let value_at = |m: f64| {
368 let stag = Self::stagnation_cd(m);
369 stag * (Self::table_interp(table, m) / stag).powf(log4)
370 };
371 let min = table[0].0;
372 if mach >= min {
373 return value_at(mach);
374 }
375 let cd_mach0 = 0.8 * self.nose_sinphi().powi(2);
376 let min_value = value_at(min);
377 if min_value < 0.001 {
378 return cd_mach0;
379 }
380 let min_deriv = (value_at(min + 0.01) - min_value) / 0.01;
381 let gap = min_value - cd_mach0;
382 if gap <= 0.01 || min_deriv <= 0.01 {
383 return cd_mach0;
384 }
385 let b = min * min_deriv / gap;
386 let a = gap / min.powf(b);
387 a * mach.powf(b) + cd_mach0
388 }
389
390 fn midchord_cos(&self) -> f64 {
391 let (cr, ct, s, m) = (
392 self.fins.root_chord_m,
393 self.fins.tip_chord_m,
394 self.fins.span_m,
395 self.fins.leading_sweep_m,
396 );
397 let gamma = ((m + ct / 2.0 - cr / 2.0) / s).atan();
398 gamma.cos()
399 }
400
401 fn hermite(x: f64, x0: f64, x1: f64, y0: f64, y1: f64, d0: f64, d1: f64) -> f64 {
404 let h = x1 - x0;
405 let t = (x - x0) / h;
406 let t2 = t * t;
407 let t3 = t2 * t;
408 (2.0 * t3 - 3.0 * t2 + 1.0) * y0
409 + (t3 - 2.0 * t2 + t) * h * d0
410 + (-2.0 * t3 + 3.0 * t2) * y1
411 + (t3 - t2) * h * d1
412 }
413
414 const CNA_SUBSONIC: f64 = 0.9;
416 const CNA_SUPERSONIC: f64 = 1.5;
417
418 fn fin_cna1(&self, mach: f64) -> f64 {
424 let span = self.fins.span_m;
425 let area = self.fin_area();
426 let cos = self.midchord_cos();
427 if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
428 return 0.0;
429 }
430 let ref_area = self.ref_area_m2();
431 let subsonic = |m: f64| {
432 let ratio = (span * span) / (area * cos);
433 2.0 * std::f64::consts::PI * span * span
434 / (1.0 + (1.0 + (1.0 - m * m) * ratio * ratio).sqrt())
435 / ref_area
436 };
437 let supersonic = |m: f64| 2.0 * area / (m * m - 1.0).sqrt() / ref_area;
438 if mach <= Self::CNA_SUBSONIC {
439 return subsonic(mach);
440 }
441 if mach >= Self::CNA_SUPERSONIC {
442 return supersonic(mach);
443 }
444 let sq = (1.0
445 + (1.0 - Self::CNA_SUBSONIC * Self::CNA_SUBSONIC)
446 * (span * span / (area * cos)).powi(2))
447 .sqrt();
448 let sub_v = 2.0 * std::f64::consts::PI * span * span / ref_area / (1.0 + sq);
449 let sub_d = 2.0 * Self::CNA_SUBSONIC * std::f64::consts::PI * span.powi(6)
450 / ((area * cos).powi(2) * ref_area * sq * (1.0 + sq).powi(2));
451 let super_v = supersonic(Self::CNA_SUPERSONIC);
452 let beta_b = (Self::CNA_SUPERSONIC * Self::CNA_SUPERSONIC - 1.0).powf(1.5);
453 let super_d = -area / ref_area * 2.0 * Self::CNA_SUPERSONIC / beta_b;
454 Self::hermite(
455 mach,
456 Self::CNA_SUBSONIC,
457 Self::CNA_SUPERSONIC,
458 sub_v,
459 super_v,
460 sub_d,
461 super_d,
462 )
463 }
464
465 fn interference_tau(&self) -> f64 {
467 let r = self.max_diameter_m() / 2.0;
468 r / (self.fins.span_m + r)
469 }
470
471 fn fin_count_factor(&self) -> f64 {
472 match self.fins.count {
473 5 => 0.948,
474 6 => 0.913,
475 7 => 0.854,
476 8 => 0.810,
477 n if n >= 9 => 0.75,
478 _ => 1.0,
479 }
480 }
481
482 fn fin_set_cna(&self, mach: f64) -> f64 {
490 self.fin_cna1(mach)
491 * (1.0 + self.interference_tau())
492 * self.fin_count_factor()
493 * (self.fins.count as f64 / 2.0)
494 }
495
496 fn fin_cp_abs(&self, mach: f64) -> f64 {
498 self.nose.length_m + self.tube.length_m - self.fins.root_chord_m + self.fin_cp(mach)
499 }
500
501 fn nose_radius(&self, x: f64) -> f64 {
503 let l = self.nose.length_m;
504 let r = self.nose.diameter_m / 2.0;
505 let t = (x / l).clamp(0.0, 1.0);
506 match self.nose.shape {
507 NoseShape::Conical => r * t,
508 NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
509 NoseShape::Parabolic => r * t * (2.0 - t),
510 NoseShape::Ogive => {
511 let rho = (r * r + l * l) / (2.0 * r);
512 (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
513 }
514 }
515 }
516
517 fn nose_volume_properties(&self) -> (f64, f64) {
520 const N: usize = 1024;
521 let l = self.nose.length_m;
522 let dx = l / N as f64;
523 let mut volume = 0.0; let mut moment = 0.0; for i in 0..N {
526 let x = (i as f64 + 0.5) * dx;
527 let r2 = self.nose_radius(x).powi(2);
528 volume += r2 * dx;
529 moment += x * r2 * dx;
530 }
531 let cg = moment / volume;
532 let mut inertia = 0.0; for i in 0..N {
534 let x = (i as f64 + 0.5) * dx;
535 let r2 = self.nose_radius(x).powi(2);
536 inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
537 }
538 (cg, inertia / volume)
539 }
540
541 fn fin_planform_cg(&self) -> f64 {
543 let (cr, ct, m) = (
544 self.fins.root_chord_m,
545 self.fins.tip_chord_m,
546 self.fins.leading_sweep_m,
547 );
548 (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct))
549 }
550
551 fn fin_radial_cg(&self) -> f64 {
553 let (cr, ct, s) = (
554 self.fins.root_chord_m,
555 self.fins.tip_chord_m,
556 self.fins.span_m,
557 );
558 s * (cr + 2.0 * ct) / (3.0 * (cr + ct))
559 }
560
561 pub fn mass_properties(
569 &self,
570 nose_kg: f64,
571 tube_kg: f64,
572 fins_kg: f64,
573 casing: Option<(f64, f64)>,
574 ) -> MassProperties {
575 self.mass_properties_with_boat(nose_kg, tube_kg, fins_kg, None, casing)
576 }
577
578 pub fn mass_properties_with_boat(
581 &self,
582 nose_kg: f64,
583 tube_kg: f64,
584 fins_kg: f64,
585 boat_kg: Option<f64>,
586 casing: Option<(f64, f64)>,
587 ) -> MassProperties {
588 assert!(
589 nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
590 "component masses must be non-negative"
591 );
592 let (nose_cg, nose_inertia) = self.nose_volume_properties();
593 let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
594 let fin_cg = self.nose.length_m + self.tube.length_m - self.fins.root_chord_m
595 + self.fin_planform_cg();
596 let mut parts: Vec<(f64, f64, f64)> = vec![
597 (nose_kg, nose_cg, nose_kg * nose_inertia),
598 (
599 tube_kg,
600 tube_cg,
601 tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
602 ),
603 (fins_kg, fin_cg, 0.0),
604 ];
605 if let (Some(b), Some(kg)) = (self.boat_tail, boat_kg) {
606 let rf = b.fore_diameter_m / 2.0;
607 let ra = b.aft_diameter_m / 2.0;
608 let cg = self.nose.length_m
611 + self.tube.length_m
612 + b.length_m * (rf + 2.0 * ra) / (3.0 * (rf + ra));
613 let mean_r = (rf + ra) / 2.0;
614 parts.push((
615 kg,
616 cg,
617 kg * (b.length_m.powi(2) / 12.0 + mean_r * mean_r / 2.0),
618 ));
619 }
620 if let Some((kg, cg)) = casing {
621 parts.push((kg, cg, 0.0));
622 }
623 let total: f64 = parts.iter().map(|p| p.0).sum();
624 assert!(total > 0.0, "component masses must sum to a positive mass");
625 let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
626 let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
627 inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
628 MassProperties {
629 dry_mass_kg: total,
630 dry_cg_m: cg,
631 pitch_inertia_kg_m2: inertia,
632 }
633 }
634
635 fn boat_cna(&self) -> f64 {
638 match self.boat_tail {
639 Some(b) => {
640 let ratio = b.aft_diameter_m / b.fore_diameter_m;
641 2.0 * (ratio * ratio - 1.0)
642 }
643 None => 0.0,
644 }
645 }
646
647 fn boat_cp_abs(&self) -> f64 {
650 match self.boat_tail {
651 Some(b) => {
652 let rf = b.fore_diameter_m / 2.0;
653 let ra = b.aft_diameter_m / 2.0;
654 self.nose.length_m
655 + self.tube.length_m
656 + b.length_m * (rf + 3.0 * ra) / (4.0 * (rf + ra))
657 }
658 None => 0.0,
659 }
660 }
661
662 fn boat_pressure_cd(&self, mach: f64) -> f64 {
666 let Some(b) = self.boat_tail else {
667 return 0.0;
668 };
669 let fineness = b.length_m / (b.fore_diameter_m - b.aft_diameter_m);
670 if fineness >= 3.0 {
671 return 0.0;
672 }
673 let annulus =
674 std::f64::consts::FRAC_PI_4 * (b.fore_diameter_m.powi(2) - b.aft_diameter_m.powi(2));
675 let cd = Self::base_cd(mach) * annulus / self.ref_area_m2();
676 if fineness <= 1.0 {
677 cd
678 } else {
679 cd * (3.0 - fineness) / 2.0
680 }
681 }
682
683 fn base_area_fraction(&self) -> f64 {
685 match self.boat_tail {
686 Some(b) => (b.aft_diameter_m / self.max_diameter_m()).powi(2),
687 None => 1.0,
688 }
689 }
690
691 fn total_cp(&self, mach: f64) -> f64 {
693 let nose_cna = 2.0;
694 let fin_cna = self.fin_set_cna(mach);
695 let boat_cna = self.boat_cna();
696 let total = nose_cna + fin_cna + boat_cna;
697 if total <= 0.0 {
698 return f64::NAN;
699 }
700 (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
701 + fin_cna * self.fin_cp_abs(mach)
702 + boat_cna * self.boat_cp_abs())
703 / total
704 }
705
706 #[cfg(test)]
707 fn nose_cp(&self) -> f64 {
708 self.nose.length_m * self.nose.shape.cp_fraction()
709 }
710
711 fn base_cd(mach: f64) -> f64 {
712 if mach <= 1.0 {
713 0.12 + 0.13 * mach * mach
714 } else {
715 0.25 / mach
716 }
717 }
718
719 fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
720 let length = self.total_length_m();
721 let velocity = mach * sound_mps;
722 let reynolds = velocity * length / 1.5e-5;
723 let rough_ratio = self.roughness_m / length;
724 let cf_turbulent = if reynolds < 1.0e4 {
725 1.48e-2
726 } else {
727 1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
728 };
729 let cf_rough = 0.032 * rough_ratio.powf(0.2);
730 let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
733 let r = self.max_diameter_m() / 2.0;
734 let fineness = length / r;
735 let correction = 1.0 + 1.0 / (2.0 * fineness);
736 let boat_wet = match self.boat_tail {
737 Some(b) => {
738 let rf = b.fore_diameter_m / 2.0;
739 let ra = b.aft_diameter_m / 2.0;
740 let slant = (b.length_m.powi(2) + (rf - ra).powi(2)).sqrt();
741 std::f64::consts::PI * (rf + ra) * slant
742 }
743 None => 0.0,
744 };
745 let body_wet = self
746 .nose
747 .shape
748 .lateral_area(self.nose.length_m, self.nose.diameter_m)
749 + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m
750 + boat_wet;
751 let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
752 let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
753 cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
754 }
755
756 pub fn analyze(&self) -> AeroAnalysis {
760 self.validate();
761 let machs: [f64; 12] = [0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0, 1.1, 1.3, 1.5, 2.0, 3.0];
762 let mut cd_vs_mach = Vec::new();
763 let mut cna_vs_mach = Vec::new();
764 let mut cp_vs_mach = Vec::new();
765 let mut induced_k_vs_mach = Vec::new();
766 for &m in &machs {
767 cd_vs_mach.push((
768 m,
769 Self::base_cd(m) * self.base_area_fraction()
770 + self.nose_pressure_cd(m)
771 + self.fin_pressure_cd(m)
772 + self.boat_pressure_cd(m)
773 + self.friction_cd(m, 340.0),
774 ));
775 let fin_cna = self.fin_set_cna(m);
776 let total_cna = 2.0 + fin_cna + self.boat_cna();
777 cna_vs_mach.push((m, total_cna));
778 cp_vs_mach.push((m, self.total_cp(m)));
779 induced_k_vs_mach.push((m, total_cna));
782 }
783 AeroAnalysis {
784 cd_vs_mach,
785 cna_vs_mach,
786 cp_vs_mach,
787 induced_k_vs_mach,
788 ref_area_m2: self.ref_area_m2(),
789 total_length_m: self.total_length_m(),
790 max_diameter_m: self.max_diameter_m(),
791 }
792 }
793
794 fn validate(&self) {
795 assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
796 assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
797 assert!(self.fins.count > 0);
798 assert!(self.fins.span_m > 0.0);
799 assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
800 assert!(self.fins.thickness_m > 0.0);
801 }
802}
803
804pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
810 let mut nose = None;
811 let mut tube = None;
812 let mut fins = None;
813 let mut boat_tail = None;
814 for item in spec.split(',') {
815 let (key, val) = item
816 .split_once('=')
817 .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
818 let parts: Vec<&str> = val.split(':').collect();
819 let number = |i: usize| -> Result<f64> {
820 parts
821 .get(i)
822 .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
823 .parse()
824 .map_err(|_| Error::invalid("geometry", "non-numeric field"))
825 };
826 match key {
827 "nose" => {
828 if parts.len() != 3 {
829 return Err(Error::invalid("nose", "expected shape:length:diameter"));
830 }
831 let shape = parts[0].parse::<NoseShape>()?;
832 nose = Some(NoseCone {
833 shape,
834 length_m: number(1)?,
835 diameter_m: number(2)?,
836 });
837 }
838 "tube" => {
839 if parts.len() != 2 {
840 return Err(Error::invalid("tube", "expected length:diameter"));
841 }
842 tube = Some(BodyTube {
843 length_m: number(0)?,
844 diameter_m: number(1)?,
845 });
846 }
847 "fins" => {
848 if !(5..=6).contains(&parts.len()) {
849 return Err(Error::invalid(
850 "fins",
851 "expected count:root:tip:span:sweep[:thickness]",
852 ));
853 }
854 let count: u32 = parts[0]
855 .parse()
856 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
857 fins = Some(TrapezoidalFins {
858 count,
859 root_chord_m: number(1)?,
860 tip_chord_m: number(2)?,
861 span_m: number(3)?,
862 leading_sweep_m: number(4)?,
863 thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
864 });
865 }
866 "boat" => {
867 if parts.len() != 3 {
868 return Err(Error::invalid(
869 "boat",
870 "expected boat=length:fore_diameter:aft_diameter",
871 ));
872 }
873 boat_tail = Some(BoatTail {
874 length_m: number(0)?,
875 fore_diameter_m: number(1)?,
876 aft_diameter_m: number(2)?,
877 });
878 }
879 other => {
880 return Err(Error::invalid(
881 "geometry key",
882 match other {
883 "nose" | "tube" | "fins" => unreachable!(),
884 _ => "must be nose, tube, fins or boat",
885 },
886 ))
887 }
888 }
889 }
890 let geometry = RocketGeometry::new(
891 nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
892 tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
893 fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
894 );
895 match boat_tail {
896 Some(boat) => geometry.with_boat_tail(boat),
897 None => Ok(geometry),
898 }
899}
900
901#[derive(Debug, Clone, Copy)]
904pub struct MassProperties {
905 pub dry_mass_kg: f64,
907 pub dry_cg_m: f64,
909 pub pitch_inertia_kg_m2: f64,
911}
912
913#[derive(Debug, Clone)]
915pub struct AeroAnalysis {
916 pub cd_vs_mach: Vec<(f64, f64)>,
918 pub cna_vs_mach: Vec<(f64, f64)>,
920 pub cp_vs_mach: Vec<(f64, f64)>,
922 pub induced_k_vs_mach: Vec<(f64, f64)>,
924 pub ref_area_m2: f64,
926 pub total_length_m: f64,
928 pub max_diameter_m: f64,
930}
931
932#[cfg(feature = "ballistics")]
933impl AeroAnalysis {
934 pub fn airframe(
936 &self,
937 cg_dry_m: f64,
938 cg_prop_m: f64,
939 pitch_inertia_kg_m2: f64,
940 recovery_cd_area_m2: f64,
941 ) -> crate::Airframe {
942 crate::Airframe {
943 diameter_m: self.max_diameter_m,
944 cd_vs_mach: self.cd_vs_mach.clone(),
945 cna_vs_mach: self.cna_vs_mach.clone(),
946 cp_vs_mach: self.cp_vs_mach.clone(),
947 cg_dry_m,
948 cg_prop_m,
949 pitch_inertia_kg_m2,
950 induced_k_vs_mach: self.induced_k_vs_mach.clone(),
951 recovery_cd_area_m2,
952 }
953 }
954
955 pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
957 crate::ballistics::BallisticForces::new(
958 self.max_diameter_m,
959 self.cd_vs_mach.clone(),
960 crate::ballistics::Atmosphere::default(),
961 wind_mps,
962 )?
963 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
964 }
965
966 pub fn weathercock_params(
968 &self,
969 cg_dry_m: f64,
970 cg_prop_m: f64,
971 pitch_inertia_kg_m2: f64,
972 ) -> Result<crate::WeathercockParams> {
973 crate::WeathercockParams::new(
974 self.cna_vs_mach.clone(),
975 self.cp_vs_mach.clone(),
976 cg_dry_m,
977 cg_prop_m,
978 self.max_diameter_m,
979 pitch_inertia_kg_m2,
980 0.2,
981 )
982 }
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 fn example() -> RocketGeometry {
990 RocketGeometry::new(
991 NoseCone {
992 shape: NoseShape::Conical,
993 length_m: 0.1,
994 diameter_m: 0.024,
995 },
996 BodyTube {
997 length_m: 0.4,
998 diameter_m: 0.024,
999 },
1000 TrapezoidalFins {
1001 count: 3,
1002 root_chord_m: 0.05,
1003 tip_chord_m: 0.03,
1004 span_m: 0.04,
1005 leading_sweep_m: 0.02,
1006 thickness_m: 0.002,
1007 },
1008 )
1009 }
1010
1011 #[test]
1012 fn nose_cp_fractions_match_barrowman() {
1013 assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
1014 assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
1015 assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
1016 assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
1017 }
1018
1019 #[test]
1020 fn conical_pressure_drag_uses_half_angle() {
1021 let geo = example(); let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
1024 assert!((geo.nose_pressure_cd(0.0) - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
1025 }
1026
1027 #[test]
1028 fn nose_wave_drag_matches_openrocket_formula() {
1029 let geo = example(); let r = 0.012_f64;
1031 let sinphi = r / (r * r + 0.1 * 0.1).sqrt();
1032 let m = 1.5_f64;
1034 let expected = 2.1 * sinphi * sinphi + 0.5 * sinphi / (m * m - 1.0).sqrt();
1035 assert!(
1036 (geo.nose_pressure_cd(m) - expected).abs() < 1e-12,
1037 "{} vs {expected}",
1038 geo.nose_pressure_cd(m)
1039 );
1040 let below = geo.nose_pressure_cd(0.999);
1042 let at = geo.nose_pressure_cd(1.0);
1043 assert!((at - sinphi).abs() < 0.02, "at {at}");
1044 assert!(below < at && below > 0.8 * sinphi * sinphi);
1045 }
1046
1047 #[test]
1048 fn fin_cp_matches_the_trapezoid_formula() {
1049 let geo = example();
1050 let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
1052 let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
1053 let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
1054 assert!(
1055 (geo.fin_cp(0.0) - expected).abs() < 1e-12,
1056 "{}",
1057 geo.fin_cp(0.0)
1058 );
1059 }
1060
1061 #[test]
1062 fn boat_tail_physics() {
1063 let base = example();
1064 let with_boat = base
1065 .with_boat_tail(BoatTail {
1066 length_m: 0.06,
1067 fore_diameter_m: 0.024,
1068 aft_diameter_m: 0.016,
1069 })
1070 .unwrap();
1071 let delta = base.fin_set_cna(0.0) + 2.0 - (with_boat.fin_set_cna(0.0) + 2.0);
1073 let expected = 2.0 * (1.0 - (0.016_f64 / 0.024).powi(2));
1074 assert!((with_boat.boat_cna() + expected).abs() < 1e-12);
1075 assert!((delta - 0.0).abs() < 1e-12 || delta > 0.0); let cna_base = base.analyze().cna_vs_mach[0].1;
1077 let cna_boat = with_boat.analyze().cna_vs_mach[0].1;
1078 assert!(
1079 (cna_base - cna_boat - expected).abs() < 1e-9,
1080 "{cna_base} vs {cna_boat}"
1081 );
1082 let cp = with_boat.boat_cp_abs();
1084 let want = 0.1 + 0.4 + 0.06 * (0.012 + 3.0 * 0.008) / (4.0 * (0.012 + 0.008));
1085 assert!((cp - want).abs() < 1e-12, "{cp} vs {want}");
1086 assert_eq!(with_boat.boat_pressure_cd(0.5), 0.0);
1088 let stubby = base
1089 .with_boat_tail(BoatTail {
1090 length_m: 0.012,
1091 fore_diameter_m: 0.024,
1092 aft_diameter_m: 0.016,
1093 })
1094 .unwrap();
1095 assert!(stubby.boat_pressure_cd(0.5) > 0.0);
1096 assert!((with_boat.base_area_fraction() - (0.016_f64 / 0.024).powi(2)).abs() < 1e-12);
1098 assert!((with_boat.total_length_m() - 0.56).abs() < 1e-12);
1100 assert!((with_boat.fin_cp_abs(0.0) - base.fin_cp_abs(0.0)).abs() < 1e-12);
1101 assert!(base
1103 .with_boat_tail(BoatTail {
1104 length_m: 0.06,
1105 fore_diameter_m: 0.03,
1106 aft_diameter_m: 0.016,
1107 })
1108 .is_err());
1109 assert!(base
1110 .with_boat_tail(BoatTail {
1111 length_m: 0.06,
1112 fore_diameter_m: 0.024,
1113 aft_diameter_m: 0.03,
1114 })
1115 .is_err());
1116 let parsed = parse_geometry(
1118 "nose=conical:0.1:0.024,tube=0.4:0.024,fins=3:0.05:0.03:0.04:0.02:0.002,boat=0.06:0.024:0.016",
1119 )
1120 .unwrap();
1121 assert!(parsed.boat_tail.is_some());
1122 assert!(parse_geometry(
1123 "nose=conical:0.1:0.024,tube=0.4:0.024,fins=3:0.05:0.03:0.04:0.02:0.002,boat=0.06:0.024:0.03",
1124 )
1125 .is_err());
1126 let props = with_boat.mass_properties_with_boat(0.012, 0.020, 0.006, Some(0.004), None);
1128 assert!((props.dry_mass_kg - 0.042).abs() < 1e-12);
1129 assert!(props.pitch_inertia_kg_m2 > 0.0);
1130 }
1131
1132 #[test]
1133 fn elliptical_and_parabolic_noses_use_the_nasa_tables() {
1134 let elliptical = RocketGeometry::new(
1135 NoseCone {
1136 shape: NoseShape::Elliptical,
1137 length_m: 0.072,
1138 diameter_m: 0.024,
1139 },
1140 example().tube,
1141 example().fins,
1142 );
1143 assert!(
1145 (elliptical.nose_pressure_cd(1.4) - 0.148).abs() < 0.005,
1146 "{}",
1147 elliptical.nose_pressure_cd(1.4)
1148 );
1149 assert!(elliptical.nose_pressure_cd(0.5) < 0.02);
1151 let parabolic = RocketGeometry::new(
1152 NoseCone {
1153 shape: NoseShape::Parabolic,
1154 length_m: 0.072,
1155 diameter_m: 0.024,
1156 },
1157 example().tube,
1158 example().fins,
1159 );
1160 assert!(parabolic.nose_pressure_cd(0.8) < 1e-4);
1162 assert!(
1163 (parabolic.nose_pressure_cd(1.05) - 0.092).abs() < 0.005,
1164 "{}",
1165 parabolic.nose_pressure_cd(1.05)
1166 );
1167 let lo = RocketGeometry::stagnation_cd(0.999);
1169 let hi = RocketGeometry::stagnation_cd(1.001);
1170 assert!((lo - hi).abs() < 0.01, "{lo} vs {hi}");
1171 }
1172
1173 #[test]
1174 fn fin_cp_moves_aft_supersonic() {
1175 let geo = example();
1176 assert_eq!(geo.fin_cp_fraction(0.0), 0.25);
1177 assert_eq!(geo.fin_cp_fraction(0.5), 0.25);
1178 let ar = 2.0 * 0.04_f64.powi(2) / geo.fin_area();
1180 let beta = (4.0_f64 - 1.0).sqrt();
1181 let expected = (ar * beta - 0.67) / (2.0 * ar * beta - 1.0);
1182 assert!((geo.fin_cp_fraction(2.0) - expected).abs() < 1e-12);
1183 assert!(geo.fin_cp_fraction(1.0) > 0.25 && geo.fin_cp_fraction(1.0) < expected);
1184 }
1185
1186 #[test]
1187 fn conical_nose_volume_properties_match_the_solid_cone() {
1188 let geo = example(); let (cg, inertia) = geo.nose_volume_properties();
1190 assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
1191 let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
1192 assert!(
1193 (inertia - expected).abs() / expected < 1e-4,
1194 "inertia {inertia} expected {expected}"
1195 );
1196 }
1197
1198 #[test]
1199 fn fin_centroid_matches_polygon_limits() {
1200 let geo = example();
1201 let rectangular = RocketGeometry::new(
1203 geo.nose,
1204 geo.tube,
1205 TrapezoidalFins {
1206 count: 3,
1207 root_chord_m: 0.05,
1208 tip_chord_m: 0.05,
1209 span_m: 0.04,
1210 leading_sweep_m: 0.0,
1211 thickness_m: 0.002,
1212 },
1213 );
1214 assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
1215 assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
1216 let triangular = RocketGeometry::new(
1218 geo.nose,
1219 geo.tube,
1220 TrapezoidalFins {
1221 count: 3,
1222 root_chord_m: 0.06,
1223 tip_chord_m: 0.0,
1224 span_m: 0.03,
1225 leading_sweep_m: 0.03,
1226 thickness_m: 0.002,
1227 },
1228 );
1229 assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
1230 assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
1231 }
1232
1233 #[test]
1234 fn mass_properties_are_consistent() {
1235 let geo = example();
1236 let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1237 assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
1238 let (nose_cg, _) = geo.nose_volume_properties();
1240 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1241 let fin_cg = geo.total_length_m() - geo.fins.root_chord_m + geo.fin_planform_cg();
1242 let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
1243 assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
1244 assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
1245 assert!(props.pitch_inertia_kg_m2 > 0.0);
1246 let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
1248 assert!(with_casing.dry_cg_m > props.dry_cg_m);
1249 assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
1250 assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
1251 let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
1253 assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
1254 }
1255
1256 #[test]
1257 fn induced_k_tracks_cna() {
1258 let geo = example();
1260 let analysis = geo.analyze();
1261 for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
1262 assert_eq!(k, cna, "mach {m}");
1263 }
1264 }
1265
1266 #[test]
1267 fn mass_properties_single_component_is_exact() {
1268 let geo = example();
1269 let props = geo.mass_properties(0.0, 0.05, 0.0, None);
1270 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1272 assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
1273 let expected =
1274 0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
1275 assert!(
1276 (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
1277 "{} vs {expected}",
1278 props.pitch_inertia_kg_m2
1279 );
1280 }
1281
1282 #[test]
1283 fn fin_set_cna_matches_diederich() {
1284 let geo = example();
1285 let area = geo.fin_area();
1288 let cos = geo.midchord_cos();
1289 let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
1290 let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
1291 / (1.0 + (1.0 + ratio * ratio).sqrt())
1292 / geo.ref_area_m2();
1293 let expected = 1.5 * one * (1.0 + geo.interference_tau());
1294 let actual = geo.fin_set_cna(0.0);
1295 assert!(
1296 (actual - expected).abs() / expected < 1e-9,
1297 "actual {actual} expected {expected}"
1298 );
1299 let d = geo.max_diameter_m();
1301 let classic = 4.0 * 3.0 * (geo.fins.span_m / d).powi(2)
1302 / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
1303 * (1.0 + geo.interference_tau());
1304 assert!(
1305 (actual - classic).abs() / classic < 0.02,
1306 "actual {actual} classic {classic}"
1307 );
1308 }
1309
1310 #[test]
1311 fn total_cp_is_the_moment_sum() {
1312 let geo = example();
1313 let fin_cna = geo.fin_set_cna(0.0);
1314 let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs(0.0)) / (2.0 + fin_cna);
1315 assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
1316 assert!(geo.total_cp(0.0) > geo.nose_cp());
1318 }
1319
1320 #[test]
1321 fn base_cd_matches_openrocket() {
1322 assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
1323 assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
1324 assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
1325 }
1326
1327 #[test]
1328 fn analysis_produces_finite_curves_and_roughness_costs_drag() {
1329 let geo = example();
1330 let analysis = geo.analyze();
1331 for &(m, cd) in &analysis.cd_vs_mach {
1332 assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
1333 }
1334 for &(m, cp) in &analysis.cp_vs_mach {
1335 assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
1336 }
1337 let smooth = geo.with_roughness(1e-6).analyze();
1338 let rough = geo.with_roughness(500e-6).analyze();
1339 assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
1340 }
1341}