1use crate::{Error, Result};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum NoseShape {
20 Conical,
22 Ogive,
24 Elliptical,
26 Parabolic,
28}
29
30impl std::str::FromStr for NoseShape {
31 type Err = Error;
32
33 fn from_str(s: &str) -> Result<Self> {
34 match s {
35 "conical" | "cone" => Ok(Self::Conical),
36 "ogive" => Ok(Self::Ogive),
37 "elliptical" | "ellipse" => Ok(Self::Elliptical),
38 "parabolic" => Ok(Self::Parabolic),
39 _ => Err(Error::invalid(
40 "nose shape",
41 "conical, ogive, elliptical or parabolic",
42 )),
43 }
44 }
45}
46
47impl NoseShape {
48 fn cp_fraction(self) -> f64 {
51 match self {
52 Self::Conical => 2.0 / 3.0,
53 Self::Ogive => 0.466,
54 Self::Elliptical => 1.0 / 3.0,
55 Self::Parabolic => 0.5,
56 }
57 }
58
59 fn lateral_area(self, length_m: f64, diameter_m: f64) -> f64 {
62 let r = diameter_m / 2.0;
63 match self {
64 Self::Conical => std::f64::consts::PI * r * (r * r + length_m * length_m).sqrt(),
65 _ => std::f64::consts::PI * diameter_m * length_m,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy)]
72pub struct NoseCone {
73 pub shape: NoseShape,
75 pub length_m: f64,
77 pub diameter_m: f64,
79}
80
81#[derive(Debug, Clone, Copy)]
83pub struct BodyTube {
84 pub length_m: f64,
86 pub diameter_m: f64,
88}
89
90#[derive(Debug, Clone, Copy)]
93pub struct TrapezoidalFins {
94 pub count: u32,
96 pub root_chord_m: f64,
98 pub tip_chord_m: f64,
100 pub span_m: f64,
102 pub leading_sweep_m: f64,
104 pub thickness_m: f64,
106}
107
108#[derive(Debug, Clone, Copy)]
112pub struct BoatTail {
113 pub length_m: f64,
115 pub fore_diameter_m: f64,
117 pub aft_diameter_m: f64,
119}
120
121#[derive(Debug, Clone, Copy)]
125pub struct EllipticalFins {
126 pub count: u32,
128 pub root_chord_m: f64,
130 pub span_m: f64,
132 pub thickness_m: f64,
134}
135
136#[derive(Debug, Clone, Copy)]
140pub struct FreeformFins {
141 pub count: u32,
143 pub points: [(f64, f64); 16],
145 pub point_count: usize,
147 pub thickness_m: f64,
149}
150
151#[derive(Debug, Clone, Copy)]
154#[allow(clippy::large_enum_variant)]
155pub enum FinSet {
156 Trapezoidal(TrapezoidalFins),
158 Elliptical(EllipticalFins),
160 Freeform(FreeformFins),
162}
163
164impl From<TrapezoidalFins> for FinSet {
165 fn from(fins: TrapezoidalFins) -> Self {
166 Self::Trapezoidal(fins)
167 }
168}
169
170impl From<EllipticalFins> for FinSet {
171 fn from(fins: EllipticalFins) -> Self {
172 Self::Elliptical(fins)
173 }
174}
175
176impl From<FreeformFins> for FinSet {
177 fn from(fins: FreeformFins) -> Self {
178 Self::Freeform(fins)
179 }
180}
181
182#[derive(Debug, Clone, Copy)]
184struct Planform {
185 count: u32,
186 root_chord_m: f64,
187 span_m: f64,
188 thickness_m: f64,
189 area_m2: f64,
190 mac_m: f64,
191 mac_lead_m: f64,
192 cos_midchord: f64,
193 cos_leading_edge: f64,
194 cg_x_m: f64,
195 cg_y_m: f64,
196}
197
198#[derive(Debug, Clone, Copy)]
201pub struct RocketGeometry {
202 pub nose: NoseCone,
204 pub tube: BodyTube,
206 pub fins: FinSet,
208 pub boat_tail: Option<BoatTail>,
210 pub roughness_m: f64,
212}
213
214const ELLIPSOID_TABLE: &[(f64, f64)] = &[
218 (1.2, 0.110),
219 (1.25, 0.128),
220 (1.3, 0.140),
221 (1.4, 0.148),
222 (1.6, 0.152),
223 (2.0, 0.159),
224 (2.4, 0.162),
225];
226const PARABOLIC_TABLE: &[(f64, f64)] = &[
227 (0.95, 0.0),
228 (0.975, 0.016),
229 (1.0, 0.041),
230 (1.05, 0.092),
231 (1.1, 0.109),
232 (1.2, 0.119),
233 (1.4, 0.113),
234 (1.7, 0.108),
235];
236
237impl RocketGeometry {
238 pub fn new(nose: NoseCone, tube: BodyTube, fins: impl Into<FinSet>) -> Self {
240 Self {
241 nose,
242 tube,
243 fins: fins.into(),
244 boat_tail: None,
245 roughness_m: 20e-6,
246 }
247 }
248
249 pub fn with_roughness(mut self, roughness_m: f64) -> Self {
251 self.roughness_m = roughness_m;
252 self
253 }
254
255 pub fn with_boat_tail(mut self, boat_tail: BoatTail) -> Result<Self> {
258 if boat_tail.length_m <= 0.0 || !boat_tail.length_m.is_finite() {
259 return Err(Error::invalid("boat tail", "length must be positive"));
260 }
261 if (boat_tail.fore_diameter_m - self.tube.diameter_m).abs() > 1e-12 {
262 return Err(Error::invalid(
263 "boat tail",
264 "forward diameter must equal the body tube diameter",
265 ));
266 }
267 if boat_tail.aft_diameter_m <= 0.0 || boat_tail.aft_diameter_m >= boat_tail.fore_diameter_m
268 {
269 return Err(Error::invalid(
270 "boat tail",
271 "aft diameter must be positive and smaller than the forward diameter",
272 ));
273 }
274 self.boat_tail = Some(boat_tail);
275 Ok(self)
276 }
277
278 pub fn total_length_m(&self) -> f64 {
280 self.nose.length_m + self.tube.length_m + self.boat_tail.map(|b| b.length_m).unwrap_or(0.0)
281 }
282
283 pub fn max_diameter_m(&self) -> f64 {
285 self.nose.diameter_m.max(self.tube.diameter_m)
286 }
287
288 pub fn ref_area_m2(&self) -> f64 {
290 std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
291 }
292
293 fn planform(&self) -> Planform {
297 match self.fins {
298 FinSet::Trapezoidal(f) => {
299 let (cr, ct, s, m) = (f.root_chord_m, f.tip_chord_m, f.span_m, f.leading_sweep_m);
300 let area = s * (cr + ct) / 2.0;
301 let mac = (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct));
302 let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
303 let gamma_mid = ((m + ct / 2.0 - cr / 2.0) / s).atan();
304 Planform {
305 count: f.count,
306 root_chord_m: cr,
307 span_m: s,
308 thickness_m: f.thickness_m,
309 area_m2: area,
310 mac_m: mac,
311 mac_lead_m: m * y_mac / s,
312 cos_midchord: gamma_mid.cos(),
313 cos_leading_edge: s / (s * s + m * m).sqrt(),
314 cg_x_m: (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct)),
315 cg_y_m: y_mac,
316 }
317 }
318 FinSet::Elliptical(f) => {
319 const DIVISIONS: usize = 48;
320 let (cr, s) = (f.root_chord_m, f.span_m);
321 let mut strips = [(0.0, 0.0); DIVISIONS];
322 for (i, strip) in strips.iter_mut().enumerate() {
323 let t = (i as f64 * s / (DIVISIONS - 1) as f64 / s).min(1.0);
324 let arc = (1.0 - t * t).max(0.0).sqrt();
325 let lead = cr / 2.0 * (1.0 - arc);
326 *strip = (lead, lead + cr * arc);
327 }
328 let (area, mac, mac_lead, cos_mid, cos_le) = Self::strips_planform(s, &strips);
329 Planform {
330 count: f.count,
331 root_chord_m: cr,
332 span_m: s,
333 thickness_m: f.thickness_m,
334 area_m2: area,
335 mac_m: mac,
336 mac_lead_m: mac_lead,
337 cos_midchord: cos_mid,
338 cos_leading_edge: cos_le,
339 cg_x_m: cr / 2.0,
340 cg_y_m: 4.0 * s / (3.0 * std::f64::consts::PI),
341 }
342 }
343 FinSet::Freeform(f) => {
344 const DIVISIONS: usize = 48;
345 let points = &f.points[..f.point_count];
346 let span = points.iter().map(|p| p.1).fold(0.0_f64, f64::max);
347 let dy = span / (DIVISIONS - 1).max(1) as f64;
348 let mut strips = [(f64::INFINITY, f64::NEG_INFINITY); DIVISIONS];
349 for i in 0..points.len() {
350 let ((x1, y1), (x2, y2)) = (points[i], points[(i + 1) % points.len()]);
351 if (y2 - y1).abs() < 1e-12 {
352 let strip = ((y1 / dy).round() as usize).min(DIVISIONS - 1);
353 strips[strip].0 = strips[strip].0.min(x1.min(x2));
354 strips[strip].1 = strips[strip].1.max(x1.max(x2));
355 continue;
356 }
357 let (lo, hi) = if y1 < y2 { (y1, y2) } else { (y2, y1) };
358 let first = ((lo / dy).ceil() as i32).clamp(0, DIVISIONS as i32 - 1) as usize;
359 let last = ((hi / dy).floor() as i32).clamp(0, DIVISIONS as i32 - 1) as usize;
360 for (i, strip) in strips.iter_mut().enumerate().take(last + 1).skip(first) {
361 let y = i as f64 * dy;
362 let x = x1 + (y - y1) * (x2 - x1) / (y2 - y1);
363 strip.0 = strip.0.min(x);
364 strip.1 = strip.1.max(x);
365 }
366 }
367 let root_chord = (strips[0].1 - strips[0].0).max(0.0);
368 let strips: Vec<(f64, f64)> = strips
369 .iter()
370 .map(|&(lo, hi)| if hi >= lo { (lo, hi) } else { (0.0, 0.0) })
371 .collect();
372 let (area, mac, mac_lead, cos_mid, cos_le) = Self::strips_planform(span, &strips);
373 let (mut cross_sum, mut cx_sum, mut cy_sum) = (0.0, 0.0, 0.0);
375 for i in 0..points.len() {
376 let ((x1, y1), (x2, y2)) = (points[i], points[(i + 1) % points.len()]);
377 let cross = x1 * y2 - x2 * y1;
378 cross_sum += cross;
379 cx_sum += (x1 + x2) * cross;
380 cy_sum += (y1 + y2) * cross;
381 }
382 let signed = cross_sum / 2.0;
383 Planform {
384 count: f.count,
385 root_chord_m: root_chord,
386 span_m: span,
387 thickness_m: f.thickness_m,
388 area_m2: area,
389 mac_m: mac,
390 mac_lead_m: mac_lead,
391 cos_midchord: cos_mid,
392 cos_leading_edge: cos_le,
393 cg_x_m: cx_sum / (6.0 * signed),
394 cg_y_m: cy_sum / (6.0 * signed),
395 }
396 }
397 }
398 }
399
400 fn strips_planform(span: f64, strips: &[(f64, f64)]) -> (f64, f64, f64, f64, f64) {
404 let dy = span / (strips.len() - 1) as f64;
405 let mut area = 0.0;
406 let mut mac_num = 0.0;
407 let mut lead_num = 0.0;
408 let mut cos_mid = 0.0;
409 let mut cos_le = 0.0;
410 let mut previous: Option<(f64, f64)> = None;
411 for &(lead, trail) in strips {
412 let chord = (trail - lead).max(0.0);
413 let mid = (lead + trail) / 2.0;
414 area += chord * dy;
415 mac_num += chord * chord * dy;
416 lead_num += lead * chord * dy;
417 if let Some((prev_mid, prev_lead)) = previous {
418 let hypot_mid = dy.hypot(mid - prev_mid);
419 if hypot_mid > 0.0 {
420 cos_mid += dy / hypot_mid;
421 }
422 let hypot_lead = dy.hypot(lead - prev_lead);
423 if hypot_lead > 0.0 {
424 cos_le += dy / hypot_lead;
425 }
426 }
427 previous = Some((mid, lead));
428 }
429 let n = (strips.len() - 1) as f64;
430 (
431 area,
432 mac_num / area,
433 lead_num / area,
434 cos_mid / n,
435 cos_le / n,
436 )
437 }
438
439 #[cfg(test)]
440 fn fin_area(&self) -> f64 {
441 self.planform().area_m2
442 }
443
444 fn fin_cp_fraction(&self, mach: f64) -> f64 {
449 if mach <= 0.5 {
450 return 0.25;
451 }
452 let planform = self.planform();
453 let ar = 2.0 * planform.span_m.powi(2) / planform.area_m2;
454 let supersonic = |m: f64| {
455 let beta = (m * m - 1.0).sqrt();
456 (ar * beta - 0.67) / (2.0 * ar * beta - 1.0)
457 };
458 if mach >= 2.0 {
459 return supersonic(mach);
460 }
461 let hi = supersonic(2.0);
462 0.25 + (hi - 0.25) * (mach - 0.5) / 1.5
463 }
464
465 fn fin_cp(&self, mach: f64) -> f64 {
467 let planform = self.planform();
468 planform.mac_lead_m + self.fin_cp_fraction(mach) * planform.mac_m
469 }
470
471 fn fin_pressure_cd(&self, mach: f64) -> f64 {
475 let planform = self.planform();
476 if planform.span_m <= 0.0 || planform.thickness_m <= 0.0 {
477 return 0.0;
478 }
479 let leading_edge = if mach < 0.9 {
480 (1.0 - mach * mach).powf(-0.417) - 1.0
481 } else if mach < 1.0 {
482 1.0 - 1.785 * (mach - 0.9)
483 } else {
484 let m2 = mach * mach;
485 1.214 - 0.502 / m2 + 0.1095 / (m2 * m2)
486 };
487 let cos_le = planform.cos_leading_edge;
488 let cd = leading_edge * cos_le * cos_le + Self::base_cd(mach) / 2.0;
489 cd * planform.span_m * planform.thickness_m / self.ref_area_m2() * planform.count as f64
492 }
493
494 fn nose_pressure_cd(&self, mach: f64) -> f64 {
500 let sinphi = match self.nose.shape {
505 NoseShape::Conical | NoseShape::Ogive => self.nose_sinphi(),
506 NoseShape::Elliptical => return self.table_pressure_cd(mach, ELLIPSOID_TABLE),
507 NoseShape::Parabolic => return self.table_pressure_cd(mach, PARABOLIC_TABLE),
508 };
509 const GAMMA: f64 = 1.4;
510 let cd_mach0 = 0.8 * sinphi * sinphi;
511 let y1 = 2.1 * sinphi * sinphi + 0.6019 * sinphi;
512 let d0 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * sinphi);
513 let d1 = -1.1341 * sinphi;
514 if mach >= 1.3 {
515 return 2.1 * sinphi * sinphi + 0.5 * sinphi / (mach * mach - 1.0).sqrt();
516 }
517 if mach >= 1.0 {
518 return Self::hermite(mach, 1.0, 1.3, sinphi, y1, d0, d1);
519 }
520 let eps = 0.01;
523 let at_eps = Self::hermite(1.0 + eps, 1.0, 1.3, sinphi, y1, d0, d1);
524 let min_deriv = (at_eps - sinphi) / eps;
525 let gap = sinphi - cd_mach0;
526 if gap <= 0.01 || min_deriv <= 0.01 {
527 return cd_mach0;
528 }
529 let b = min_deriv / gap;
530 gap * mach.powf(b) + cd_mach0
531 }
532
533 fn nose_sinphi(&self) -> f64 {
536 let l = self.nose.length_m;
537 let r = self.nose.diameter_m / 2.0;
538 let r_tip = self.nose_radius(0.99 * l);
539 (r - r_tip) / (r - r_tip).hypot(0.01 * l)
540 }
541
542 fn stagnation_cd(mach: f64) -> f64 {
544 let m2 = mach * mach;
545 if mach <= 1.0 {
546 0.85 * (1.0 + m2 / 4.0 + m2 * m2 / 40.0)
547 } else {
548 0.85 * (1.84 - 0.76 / m2 + 0.166 / (m2 * m2) + 0.035 / (m2 * m2 * m2))
549 }
550 }
551
552 fn table_interp(table: &[(f64, f64)], mach: f64) -> f64 {
554 if mach <= table[0].0 {
555 return table[0].1;
556 }
557 for pair in table.windows(2) {
558 let (lo, hi) = (pair[0], pair[1]);
559 if mach <= hi.0 {
560 let f = (mach - lo.0) / (hi.0 - lo.0);
561 return lo.1 + f * (hi.1 - lo.1);
562 }
563 }
564 table[table.len() - 1].1
565 }
566
567 fn table_pressure_cd(&self, mach: f64, table: &[(f64, f64)]) -> f64 {
571 let fineness = self.nose.length_m / self.nose.diameter_m;
572 let log4 = (fineness + 1.0).ln() / 4.0_f64.ln();
573 let value_at = |m: f64| {
574 let stag = Self::stagnation_cd(m);
575 stag * (Self::table_interp(table, m) / stag).powf(log4)
576 };
577 let min = table[0].0;
578 if mach >= min {
579 return value_at(mach);
580 }
581 let cd_mach0 = 0.8 * self.nose_sinphi().powi(2);
582 let min_value = value_at(min);
583 if min_value < 0.001 {
584 return cd_mach0;
585 }
586 let min_deriv = (value_at(min + 0.01) - min_value) / 0.01;
587 let gap = min_value - cd_mach0;
588 if gap <= 0.01 || min_deriv <= 0.01 {
589 return cd_mach0;
590 }
591 let b = min * min_deriv / gap;
592 let a = gap / min.powf(b);
593 a * mach.powf(b) + cd_mach0
594 }
595
596 #[cfg(test)]
597 fn midchord_cos(&self) -> f64 {
598 self.planform().cos_midchord
599 }
600
601 fn hermite(x: f64, x0: f64, x1: f64, y0: f64, y1: f64, d0: f64, d1: f64) -> f64 {
604 let h = x1 - x0;
605 let t = (x - x0) / h;
606 let t2 = t * t;
607 let t3 = t2 * t;
608 (2.0 * t3 - 3.0 * t2 + 1.0) * y0
609 + (t3 - 2.0 * t2 + t) * h * d0
610 + (-2.0 * t3 + 3.0 * t2) * y1
611 + (t3 - t2) * h * d1
612 }
613
614 const CNA_SUBSONIC: f64 = 0.9;
616 const CNA_SUPERSONIC: f64 = 1.5;
617
618 fn fin_cna1(&self, mach: f64) -> f64 {
624 let planform = self.planform();
625 let span = planform.span_m;
626 let area = planform.area_m2;
627 let cos = planform.cos_midchord;
628 if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
629 return 0.0;
630 }
631 let ref_area = self.ref_area_m2();
632 let subsonic = |m: f64| {
633 let ratio = (span * span) / (area * cos);
634 2.0 * std::f64::consts::PI * span * span
635 / (1.0 + (1.0 + (1.0 - m * m) * ratio * ratio).sqrt())
636 / ref_area
637 };
638 let supersonic = |m: f64| 2.0 * area / (m * m - 1.0).sqrt() / ref_area;
639 if mach <= Self::CNA_SUBSONIC {
640 return subsonic(mach);
641 }
642 if mach >= Self::CNA_SUPERSONIC {
643 return supersonic(mach);
644 }
645 let sq = (1.0
646 + (1.0 - Self::CNA_SUBSONIC * Self::CNA_SUBSONIC)
647 * (span * span / (area * cos)).powi(2))
648 .sqrt();
649 let sub_v = 2.0 * std::f64::consts::PI * span * span / ref_area / (1.0 + sq);
650 let sub_d = 2.0 * Self::CNA_SUBSONIC * std::f64::consts::PI * span.powi(6)
651 / ((area * cos).powi(2) * ref_area * sq * (1.0 + sq).powi(2));
652 let super_v = supersonic(Self::CNA_SUPERSONIC);
653 let beta_b = (Self::CNA_SUPERSONIC * Self::CNA_SUPERSONIC - 1.0).powf(1.5);
654 let super_d = -area / ref_area * 2.0 * Self::CNA_SUPERSONIC / beta_b;
655 Self::hermite(
656 mach,
657 Self::CNA_SUBSONIC,
658 Self::CNA_SUPERSONIC,
659 sub_v,
660 super_v,
661 sub_d,
662 super_d,
663 )
664 }
665
666 fn interference_tau(&self) -> f64 {
668 let r = self.max_diameter_m() / 2.0;
669 r / (self.planform().span_m + r)
670 }
671
672 fn fin_count_factor(&self) -> f64 {
673 match self.planform().count {
674 5 => 0.948,
675 6 => 0.913,
676 7 => 0.854,
677 8 => 0.810,
678 n if n >= 9 => 0.75,
679 _ => 1.0,
680 }
681 }
682
683 fn fin_set_cna(&self, mach: f64) -> f64 {
691 self.fin_cna1(mach)
692 * (1.0 + self.interference_tau())
693 * self.fin_count_factor()
694 * (self.planform().count as f64 / 2.0)
695 }
696
697 fn fin_cp_abs(&self, mach: f64) -> f64 {
699 self.nose.length_m + self.tube.length_m - self.planform().root_chord_m + self.fin_cp(mach)
700 }
701
702 fn nose_radius(&self, x: f64) -> f64 {
704 let l = self.nose.length_m;
705 let r = self.nose.diameter_m / 2.0;
706 let t = (x / l).clamp(0.0, 1.0);
707 match self.nose.shape {
708 NoseShape::Conical => r * t,
709 NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
710 NoseShape::Parabolic => r * t * (2.0 - t),
711 NoseShape::Ogive => {
712 let rho = (r * r + l * l) / (2.0 * r);
713 (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
714 }
715 }
716 }
717
718 fn nose_volume_properties(&self) -> (f64, f64) {
721 const N: usize = 1024;
722 let l = self.nose.length_m;
723 let dx = l / N as f64;
724 let mut volume = 0.0; let mut moment = 0.0; for i in 0..N {
727 let x = (i as f64 + 0.5) * dx;
728 let r2 = self.nose_radius(x).powi(2);
729 volume += r2 * dx;
730 moment += x * r2 * dx;
731 }
732 let cg = moment / volume;
733 let mut inertia = 0.0; for i in 0..N {
735 let x = (i as f64 + 0.5) * dx;
736 let r2 = self.nose_radius(x).powi(2);
737 inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
738 }
739 (cg, inertia / volume)
740 }
741
742 fn fin_planform_cg(&self) -> f64 {
744 self.planform().cg_x_m
745 }
746
747 fn fin_radial_cg(&self) -> f64 {
749 self.planform().cg_y_m
750 }
751
752 pub fn mass_properties(
760 &self,
761 nose_kg: f64,
762 tube_kg: f64,
763 fins_kg: f64,
764 casing: Option<(f64, f64)>,
765 ) -> MassProperties {
766 self.mass_properties_with_boat(nose_kg, tube_kg, fins_kg, None, casing)
767 }
768
769 pub fn mass_properties_with_boat(
772 &self,
773 nose_kg: f64,
774 tube_kg: f64,
775 fins_kg: f64,
776 boat_kg: Option<f64>,
777 casing: Option<(f64, f64)>,
778 ) -> MassProperties {
779 assert!(
780 nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
781 "component masses must be non-negative"
782 );
783 let (nose_cg, nose_inertia) = self.nose_volume_properties();
784 let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
785 let fin_cg = self.nose.length_m + self.tube.length_m - self.planform().root_chord_m
786 + self.fin_planform_cg();
787 let mut parts: Vec<(f64, f64, f64)> = vec![
788 (nose_kg, nose_cg, nose_kg * nose_inertia),
789 (
790 tube_kg,
791 tube_cg,
792 tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
793 ),
794 (fins_kg, fin_cg, 0.0),
795 ];
796 if let (Some(b), Some(kg)) = (self.boat_tail, boat_kg) {
797 let rf = b.fore_diameter_m / 2.0;
798 let ra = b.aft_diameter_m / 2.0;
799 let cg = self.nose.length_m
802 + self.tube.length_m
803 + b.length_m * (rf + 2.0 * ra) / (3.0 * (rf + ra));
804 let mean_r = (rf + ra) / 2.0;
805 parts.push((
806 kg,
807 cg,
808 kg * (b.length_m.powi(2) / 12.0 + mean_r * mean_r / 2.0),
809 ));
810 }
811 if let Some((kg, cg)) = casing {
812 parts.push((kg, cg, 0.0));
813 }
814 let total: f64 = parts.iter().map(|p| p.0).sum();
815 assert!(total > 0.0, "component masses must sum to a positive mass");
816 let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
817 let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
818 inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
819 MassProperties {
820 dry_mass_kg: total,
821 dry_cg_m: cg,
822 pitch_inertia_kg_m2: inertia,
823 }
824 }
825
826 fn boat_cna(&self) -> f64 {
829 match self.boat_tail {
830 Some(b) => {
831 let ratio = b.aft_diameter_m / b.fore_diameter_m;
832 2.0 * (ratio * ratio - 1.0)
833 }
834 None => 0.0,
835 }
836 }
837
838 fn boat_cp_abs(&self) -> f64 {
841 match self.boat_tail {
842 Some(b) => {
843 let rf = b.fore_diameter_m / 2.0;
844 let ra = b.aft_diameter_m / 2.0;
845 self.nose.length_m
846 + self.tube.length_m
847 + b.length_m * (rf + 3.0 * ra) / (4.0 * (rf + ra))
848 }
849 None => 0.0,
850 }
851 }
852
853 fn boat_pressure_cd(&self, mach: f64) -> f64 {
857 let Some(b) = self.boat_tail else {
858 return 0.0;
859 };
860 let fineness = b.length_m / (b.fore_diameter_m - b.aft_diameter_m);
861 if fineness >= 3.0 {
862 return 0.0;
863 }
864 let annulus =
865 std::f64::consts::FRAC_PI_4 * (b.fore_diameter_m.powi(2) - b.aft_diameter_m.powi(2));
866 let cd = Self::base_cd(mach) * annulus / self.ref_area_m2();
867 if fineness <= 1.0 {
868 cd
869 } else {
870 cd * (3.0 - fineness) / 2.0
871 }
872 }
873
874 fn base_area_fraction(&self) -> f64 {
876 match self.boat_tail {
877 Some(b) => (b.aft_diameter_m / self.max_diameter_m()).powi(2),
878 None => 1.0,
879 }
880 }
881
882 fn total_cp(&self, mach: f64) -> f64 {
884 let nose_cna = 2.0;
885 let fin_cna = self.fin_set_cna(mach);
886 let boat_cna = self.boat_cna();
887 let total = nose_cna + fin_cna + boat_cna;
888 if total <= 0.0 {
889 return f64::NAN;
890 }
891 (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
892 + fin_cna * self.fin_cp_abs(mach)
893 + boat_cna * self.boat_cp_abs())
894 / total
895 }
896
897 #[cfg(test)]
898 fn nose_cp(&self) -> f64 {
899 self.nose.length_m * self.nose.shape.cp_fraction()
900 }
901
902 fn base_cd(mach: f64) -> f64 {
903 if mach <= 1.0 {
904 0.12 + 0.13 * mach * mach
905 } else {
906 0.25 / mach
907 }
908 }
909
910 fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
911 let length = self.total_length_m();
912 let velocity = mach * sound_mps;
913 let reynolds = velocity * length / 1.5e-5;
914 let rough_ratio = self.roughness_m / length;
915 let cf_turbulent = if reynolds < 1.0e4 {
916 1.48e-2
917 } else {
918 1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
919 };
920 let blend = |low: f64, high: f64| {
924 if mach < 0.9 {
925 low
926 } else if mach > 1.1 {
927 high
928 } else {
929 high * (mach - 0.9) / 0.2 + low * (1.1 - mach) / 0.2
930 }
931 };
932 let cf_smooth = cf_turbulent
933 * blend(
934 1.0 - 0.1 * mach * mach,
935 (1.0 + 0.15 * mach * mach).powf(-0.58),
936 );
937 let cf_rough = 0.032
938 * rough_ratio.powf(0.2)
939 * blend(1.0 - 0.1 * mach * mach, 1.0 / (1.0 + 0.18 * mach * mach));
940 let cf = cf_smooth.max(cf_rough);
942 let r = self.max_diameter_m() / 2.0;
943 let fineness = length / r;
944 let correction = 1.0 + 1.0 / (2.0 * fineness);
945 let boat_wet = match self.boat_tail {
946 Some(b) => {
947 let rf = b.fore_diameter_m / 2.0;
948 let ra = b.aft_diameter_m / 2.0;
949 let slant = (b.length_m.powi(2) + (rf - ra).powi(2)).sqrt();
950 std::f64::consts::PI * (rf + ra) * slant
951 }
952 None => 0.0,
953 };
954 let body_wet = self
955 .nose
956 .shape
957 .lateral_area(self.nose.length_m, self.nose.diameter_m)
958 + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m
959 + boat_wet;
960 let planform = self.planform();
961 let fin_wet = 2.0 * planform.count as f64 * planform.area_m2;
962 let thickness_factor = 1.0 + 2.0 * planform.thickness_m / planform.mac_m;
963 cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
964 }
965
966 pub fn analyze(&self) -> AeroAnalysis {
970 self.validate();
971 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];
972 let mut cd_vs_mach = Vec::new();
973 let mut cna_vs_mach = Vec::new();
974 let mut cp_vs_mach = Vec::new();
975 let mut induced_k_vs_mach = Vec::new();
976 for &m in &machs {
977 cd_vs_mach.push((
978 m,
979 Self::base_cd(m) * self.base_area_fraction()
980 + self.nose_pressure_cd(m)
981 + self.fin_pressure_cd(m)
982 + self.boat_pressure_cd(m)
983 + self.friction_cd(m, 340.0),
984 ));
985 let fin_cna = self.fin_set_cna(m);
986 let total_cna = 2.0 + fin_cna + self.boat_cna();
987 cna_vs_mach.push((m, total_cna));
988 cp_vs_mach.push((m, self.total_cp(m)));
989 induced_k_vs_mach.push((m, total_cna));
992 }
993 AeroAnalysis {
994 cd_vs_mach,
995 cna_vs_mach,
996 cp_vs_mach,
997 induced_k_vs_mach,
998 ref_area_m2: self.ref_area_m2(),
999 total_length_m: self.total_length_m(),
1000 max_diameter_m: self.max_diameter_m(),
1001 }
1002 }
1003
1004 fn validate(&self) {
1005 assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
1006 assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
1007 let planform = self.planform();
1008 assert!(planform.count > 0);
1009 assert!(planform.span_m > 0.0);
1010 assert!(planform.root_chord_m > 0.0 && planform.thickness_m > 0.0);
1011 }
1012}
1013
1014pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
1023 let mut nose = None;
1024 let mut tube = None;
1025 let mut fins: Option<FinSet> = None;
1026 let mut boat_tail = None;
1027 for item in spec.split(',') {
1028 let (key, val) = item
1029 .split_once('=')
1030 .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
1031 let parts: Vec<&str> = val.split(':').collect();
1032 let number = |i: usize| -> Result<f64> {
1033 parts
1034 .get(i)
1035 .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
1036 .parse()
1037 .map_err(|_| Error::invalid("geometry", "non-numeric field"))
1038 };
1039 match key {
1040 "nose" => {
1041 if parts.len() != 3 {
1042 return Err(Error::invalid("nose", "expected shape:length:diameter"));
1043 }
1044 let shape = parts[0].parse::<NoseShape>()?;
1045 nose = Some(NoseCone {
1046 shape,
1047 length_m: number(1)?,
1048 diameter_m: number(2)?,
1049 });
1050 }
1051 "tube" => {
1052 if parts.len() != 2 {
1053 return Err(Error::invalid("tube", "expected length:diameter"));
1054 }
1055 tube = Some(BodyTube {
1056 length_m: number(0)?,
1057 diameter_m: number(1)?,
1058 });
1059 }
1060 "fins" => {
1061 if !(5..=6).contains(&parts.len()) {
1062 return Err(Error::invalid(
1063 "fins",
1064 "expected count:root:tip:span:sweep[:thickness]",
1065 ));
1066 }
1067 let count: u32 = parts[0]
1068 .parse()
1069 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
1070 fins = Some(
1071 TrapezoidalFins {
1072 count,
1073 root_chord_m: number(1)?,
1074 tip_chord_m: number(2)?,
1075 span_m: number(3)?,
1076 leading_sweep_m: number(4)?,
1077 thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
1078 }
1079 .into(),
1080 );
1081 }
1082 "efins" => {
1083 if !(3..=4).contains(&parts.len()) {
1084 return Err(Error::invalid(
1085 "efins",
1086 "expected count:root:span[:thickness]",
1087 ));
1088 }
1089 let count: u32 = parts[0]
1090 .parse()
1091 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
1092 fins = Some(
1093 EllipticalFins {
1094 count,
1095 root_chord_m: number(1)?,
1096 span_m: number(2)?,
1097 thickness_m: if parts.len() == 4 { number(3)? } else { 0.002 },
1098 }
1099 .into(),
1100 );
1101 }
1102 "ffins" => {
1103 if !(2..=3).contains(&parts.len()) {
1104 return Err(Error::invalid(
1105 "ffins",
1106 "expected count:x1y1+x2y2+...[:thickness] (x and y in metres)",
1107 ));
1108 }
1109 let count: u32 = parts[0]
1110 .parse()
1111 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
1112 let mut points = [(0.0, 0.0); 16];
1113 let mut point_count = 0usize;
1114 for pair in parts[1].split('+') {
1115 if point_count >= 16 {
1116 return Err(Error::invalid("ffins", "at most 16 vertices"));
1117 }
1118 let (xs, ys) = pair.split_once('x').ok_or_else(|| {
1119 Error::invalid(
1120 "ffins",
1121 "vertices as xVAL yVAL pairs, e.g. 0x0+0.05x0+0.02x0.05",
1122 )
1123 })?;
1124 let x: f64 = xs
1125 .parse()
1126 .map_err(|_| Error::invalid("ffins", "non-numeric vertex x"))?;
1127 let y: f64 = ys
1128 .parse()
1129 .map_err(|_| Error::invalid("ffins", "non-numeric vertex y"))?;
1130 if !x.is_finite() || !y.is_finite() || y < 0.0 {
1131 return Err(Error::invalid(
1132 "ffins",
1133 "vertices must be finite with y >= 0",
1134 ));
1135 }
1136 points[point_count] = (x, y);
1137 point_count += 1;
1138 }
1139 if point_count < 3 || points[0].1 != 0.0 || points[point_count - 1].1 != 0.0 {
1140 return Err(Error::invalid(
1141 "ffins",
1142 "need at least 3 vertices, first and last on the body (y = 0)",
1143 ));
1144 }
1145 fins = Some(
1146 FreeformFins {
1147 count,
1148 points,
1149 point_count,
1150 thickness_m: if parts.len() == 3 { number(2)? } else { 0.002 },
1151 }
1152 .into(),
1153 );
1154 }
1155 "boat" => {
1156 if parts.len() != 3 {
1157 return Err(Error::invalid(
1158 "boat",
1159 "expected boat=length:fore_diameter:aft_diameter",
1160 ));
1161 }
1162 boat_tail = Some(BoatTail {
1163 length_m: number(0)?,
1164 fore_diameter_m: number(1)?,
1165 aft_diameter_m: number(2)?,
1166 });
1167 }
1168 other => {
1169 return Err(Error::invalid(
1170 "geometry key",
1171 match other {
1172 "nose" | "tube" | "fins" => unreachable!(),
1173 _ => "must be nose, tube, fins, efins or boat",
1174 },
1175 ))
1176 }
1177 }
1178 }
1179 let geometry = RocketGeometry::new(
1180 nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
1181 tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
1182 fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
1183 );
1184 match boat_tail {
1185 Some(boat) => geometry.with_boat_tail(boat),
1186 None => Ok(geometry),
1187 }
1188}
1189
1190#[derive(Debug, Clone, Copy)]
1193pub struct MassProperties {
1194 pub dry_mass_kg: f64,
1196 pub dry_cg_m: f64,
1198 pub pitch_inertia_kg_m2: f64,
1200}
1201
1202#[derive(Debug, Clone)]
1204pub struct AeroAnalysis {
1205 pub cd_vs_mach: Vec<(f64, f64)>,
1207 pub cna_vs_mach: Vec<(f64, f64)>,
1209 pub cp_vs_mach: Vec<(f64, f64)>,
1211 pub induced_k_vs_mach: Vec<(f64, f64)>,
1213 pub ref_area_m2: f64,
1215 pub total_length_m: f64,
1217 pub max_diameter_m: f64,
1219}
1220
1221#[cfg(feature = "ballistics")]
1222impl AeroAnalysis {
1223 pub fn airframe(
1225 &self,
1226 cg_dry_m: f64,
1227 cg_prop_m: f64,
1228 pitch_inertia_kg_m2: f64,
1229 recovery_cd_area_m2: f64,
1230 ) -> crate::Airframe {
1231 crate::Airframe {
1232 diameter_m: self.max_diameter_m,
1233 cd_vs_mach: self.cd_vs_mach.clone(),
1234 cna_vs_mach: self.cna_vs_mach.clone(),
1235 cp_vs_mach: self.cp_vs_mach.clone(),
1236 cg_dry_m,
1237 cg_prop_m,
1238 pitch_inertia_kg_m2,
1239 induced_k_vs_mach: self.induced_k_vs_mach.clone(),
1240 recovery_cd_area_m2,
1241 }
1242 }
1243
1244 pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
1246 crate::ballistics::BallisticForces::new(
1247 self.max_diameter_m,
1248 self.cd_vs_mach.clone(),
1249 crate::ballistics::Atmosphere::default(),
1250 wind_mps,
1251 )?
1252 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
1253 }
1254
1255 pub fn weathercock_params(
1257 &self,
1258 cg_dry_m: f64,
1259 cg_prop_m: f64,
1260 pitch_inertia_kg_m2: f64,
1261 ) -> Result<crate::WeathercockParams> {
1262 crate::WeathercockParams::new(
1263 self.cna_vs_mach.clone(),
1264 self.cp_vs_mach.clone(),
1265 cg_dry_m,
1266 cg_prop_m,
1267 self.max_diameter_m,
1268 pitch_inertia_kg_m2,
1269 0.2,
1270 )
1271 }
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276 use super::*;
1277
1278 fn example() -> RocketGeometry {
1279 RocketGeometry::new(
1280 NoseCone {
1281 shape: NoseShape::Conical,
1282 length_m: 0.1,
1283 diameter_m: 0.024,
1284 },
1285 BodyTube {
1286 length_m: 0.4,
1287 diameter_m: 0.024,
1288 },
1289 TrapezoidalFins {
1290 count: 3,
1291 root_chord_m: 0.05,
1292 tip_chord_m: 0.03,
1293 span_m: 0.04,
1294 leading_sweep_m: 0.02,
1295 thickness_m: 0.002,
1296 },
1297 )
1298 }
1299
1300 #[test]
1301 fn nose_cp_fractions_match_barrowman() {
1302 assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
1303 assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
1304 assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
1305 assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
1306 }
1307
1308 #[test]
1309 fn conical_pressure_drag_uses_half_angle() {
1310 let geo = example(); let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
1313 assert!((geo.nose_pressure_cd(0.0) - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
1314 }
1315
1316 #[test]
1317 fn nose_wave_drag_matches_openrocket_formula() {
1318 let geo = example(); let r = 0.012_f64;
1320 let sinphi = r / (r * r + 0.1 * 0.1).sqrt();
1321 let m = 1.5_f64;
1323 let expected = 2.1 * sinphi * sinphi + 0.5 * sinphi / (m * m - 1.0).sqrt();
1324 assert!(
1325 (geo.nose_pressure_cd(m) - expected).abs() < 1e-12,
1326 "{} vs {expected}",
1327 geo.nose_pressure_cd(m)
1328 );
1329 let below = geo.nose_pressure_cd(0.999);
1331 let at = geo.nose_pressure_cd(1.0);
1332 assert!((at - sinphi).abs() < 0.02, "at {at}");
1333 assert!(below < at && below > 0.8 * sinphi * sinphi);
1334 }
1335
1336 #[test]
1337 fn fin_cp_matches_the_trapezoid_formula() {
1338 let geo = example();
1339 let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
1341 let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
1342 let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
1343 assert!(
1344 (geo.fin_cp(0.0) - expected).abs() < 1e-12,
1345 "{}",
1346 geo.fin_cp(0.0)
1347 );
1348 }
1349
1350 #[test]
1351 fn boat_tail_physics() {
1352 let base = example();
1353 let with_boat = base
1354 .with_boat_tail(BoatTail {
1355 length_m: 0.06,
1356 fore_diameter_m: 0.024,
1357 aft_diameter_m: 0.016,
1358 })
1359 .unwrap();
1360 let delta = base.fin_set_cna(0.0) + 2.0 - (with_boat.fin_set_cna(0.0) + 2.0);
1362 let expected = 2.0 * (1.0 - (0.016_f64 / 0.024).powi(2));
1363 assert!((with_boat.boat_cna() + expected).abs() < 1e-12);
1364 assert!((delta - 0.0).abs() < 1e-12 || delta > 0.0); let cna_base = base.analyze().cna_vs_mach[0].1;
1366 let cna_boat = with_boat.analyze().cna_vs_mach[0].1;
1367 assert!(
1368 (cna_base - cna_boat - expected).abs() < 1e-9,
1369 "{cna_base} vs {cna_boat}"
1370 );
1371 let cp = with_boat.boat_cp_abs();
1373 let want = 0.1 + 0.4 + 0.06 * (0.012 + 3.0 * 0.008) / (4.0 * (0.012 + 0.008));
1374 assert!((cp - want).abs() < 1e-12, "{cp} vs {want}");
1375 assert_eq!(with_boat.boat_pressure_cd(0.5), 0.0);
1377 let stubby = base
1378 .with_boat_tail(BoatTail {
1379 length_m: 0.012,
1380 fore_diameter_m: 0.024,
1381 aft_diameter_m: 0.016,
1382 })
1383 .unwrap();
1384 assert!(stubby.boat_pressure_cd(0.5) > 0.0);
1385 assert!((with_boat.base_area_fraction() - (0.016_f64 / 0.024).powi(2)).abs() < 1e-12);
1387 assert!((with_boat.total_length_m() - 0.56).abs() < 1e-12);
1389 assert!((with_boat.fin_cp_abs(0.0) - base.fin_cp_abs(0.0)).abs() < 1e-12);
1390 assert!(base
1392 .with_boat_tail(BoatTail {
1393 length_m: 0.06,
1394 fore_diameter_m: 0.03,
1395 aft_diameter_m: 0.016,
1396 })
1397 .is_err());
1398 assert!(base
1399 .with_boat_tail(BoatTail {
1400 length_m: 0.06,
1401 fore_diameter_m: 0.024,
1402 aft_diameter_m: 0.03,
1403 })
1404 .is_err());
1405 let parsed = parse_geometry(
1407 "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",
1408 )
1409 .unwrap();
1410 assert!(parsed.boat_tail.is_some());
1411 assert!(parse_geometry(
1412 "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",
1413 )
1414 .is_err());
1415 let props = with_boat.mass_properties_with_boat(0.012, 0.020, 0.006, Some(0.004), None);
1417 assert!((props.dry_mass_kg - 0.042).abs() < 1e-12);
1418 assert!(props.pitch_inertia_kg_m2 > 0.0);
1419 }
1420
1421 #[test]
1422 fn elliptical_and_parabolic_noses_use_the_nasa_tables() {
1423 let elliptical = RocketGeometry::new(
1424 NoseCone {
1425 shape: NoseShape::Elliptical,
1426 length_m: 0.072,
1427 diameter_m: 0.024,
1428 },
1429 example().tube,
1430 example().fins,
1431 );
1432 assert!(
1434 (elliptical.nose_pressure_cd(1.4) - 0.148).abs() < 0.005,
1435 "{}",
1436 elliptical.nose_pressure_cd(1.4)
1437 );
1438 assert!(elliptical.nose_pressure_cd(0.5) < 0.02);
1440 let parabolic = RocketGeometry::new(
1441 NoseCone {
1442 shape: NoseShape::Parabolic,
1443 length_m: 0.072,
1444 diameter_m: 0.024,
1445 },
1446 example().tube,
1447 example().fins,
1448 );
1449 assert!(parabolic.nose_pressure_cd(0.8) < 1e-4);
1451 assert!(
1452 (parabolic.nose_pressure_cd(1.05) - 0.092).abs() < 0.005,
1453 "{}",
1454 parabolic.nose_pressure_cd(1.05)
1455 );
1456 let lo = RocketGeometry::stagnation_cd(0.999);
1458 let hi = RocketGeometry::stagnation_cd(1.001);
1459 assert!((lo - hi).abs() < 0.01, "{lo} vs {hi}");
1460 }
1461
1462 #[test]
1463 fn fin_cp_moves_aft_supersonic() {
1464 let geo = example();
1465 assert_eq!(geo.fin_cp_fraction(0.0), 0.25);
1466 assert_eq!(geo.fin_cp_fraction(0.5), 0.25);
1467 let ar = 2.0 * 0.04_f64.powi(2) / geo.fin_area();
1469 let beta = (4.0_f64 - 1.0).sqrt();
1470 let expected = (ar * beta - 0.67) / (2.0 * ar * beta - 1.0);
1471 assert!((geo.fin_cp_fraction(2.0) - expected).abs() < 1e-12);
1472 assert!(geo.fin_cp_fraction(1.0) > 0.25 && geo.fin_cp_fraction(1.0) < expected);
1473 }
1474
1475 #[test]
1476 fn conical_nose_volume_properties_match_the_solid_cone() {
1477 let geo = example(); let (cg, inertia) = geo.nose_volume_properties();
1479 assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
1480 let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
1481 assert!(
1482 (inertia - expected).abs() / expected < 1e-4,
1483 "inertia {inertia} expected {expected}"
1484 );
1485 }
1486
1487 #[test]
1488 fn fin_centroid_matches_polygon_limits() {
1489 let geo = example();
1490 let rectangular = RocketGeometry::new(
1492 geo.nose,
1493 geo.tube,
1494 TrapezoidalFins {
1495 count: 3,
1496 root_chord_m: 0.05,
1497 tip_chord_m: 0.05,
1498 span_m: 0.04,
1499 leading_sweep_m: 0.0,
1500 thickness_m: 0.002,
1501 },
1502 );
1503 assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
1504 assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
1505 let triangular = RocketGeometry::new(
1507 geo.nose,
1508 geo.tube,
1509 TrapezoidalFins {
1510 count: 3,
1511 root_chord_m: 0.06,
1512 tip_chord_m: 0.0,
1513 span_m: 0.03,
1514 leading_sweep_m: 0.03,
1515 thickness_m: 0.002,
1516 },
1517 );
1518 assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
1519 assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
1520 }
1521
1522 #[test]
1523 fn mass_properties_are_consistent() {
1524 let geo = example();
1525 let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1526 assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
1527 let (nose_cg, _) = geo.nose_volume_properties();
1529 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1530 let fin_cg = geo.total_length_m() - geo.planform().root_chord_m + geo.fin_planform_cg();
1531 let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
1532 assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
1533 assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
1534 assert!(props.pitch_inertia_kg_m2 > 0.0);
1535 let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
1537 assert!(with_casing.dry_cg_m > props.dry_cg_m);
1538 assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
1539 assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
1540 let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
1542 assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
1543 }
1544
1545 #[test]
1546 fn induced_k_tracks_cna() {
1547 let geo = example();
1549 let analysis = geo.analyze();
1550 for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
1551 assert_eq!(k, cna, "mach {m}");
1552 }
1553 }
1554
1555 #[test]
1556 fn mass_properties_single_component_is_exact() {
1557 let geo = example();
1558 let props = geo.mass_properties(0.0, 0.05, 0.0, None);
1559 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1561 assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
1562 let expected =
1563 0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
1564 assert!(
1565 (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
1566 "{} vs {expected}",
1567 props.pitch_inertia_kg_m2
1568 );
1569 }
1570
1571 #[test]
1572 fn freeform_trapezoid_matches_the_closed_form() {
1573 let trap = example();
1576 let mut points = [(0.0, 0.0); 16];
1577 points[0] = (0.0, 0.0);
1578 points[1] = (0.02, 0.04);
1579 points[2] = (0.05, 0.04);
1580 points[3] = (0.05, 0.0);
1581 let free = RocketGeometry::new(
1582 trap.nose,
1583 trap.tube,
1584 FreeformFins {
1585 count: 3,
1586 points,
1587 point_count: 4,
1588 thickness_m: 0.002,
1589 },
1590 );
1591 let (a, b) = (trap.planform(), free.planform());
1592 for (name, x, y) in [
1593 ("area", a.area_m2, b.area_m2),
1594 ("mac", a.mac_m, b.mac_m),
1595 ("mac_lead", a.mac_lead_m, b.mac_lead_m),
1596 ("cos_midchord", a.cos_midchord, b.cos_midchord),
1597 ("cos_leading_edge", a.cos_leading_edge, b.cos_leading_edge),
1598 ("cg_x", a.cg_x_m, b.cg_x_m),
1599 ("cg_y", a.cg_y_m, b.cg_y_m),
1600 ] {
1601 assert!(
1604 (x - y).abs() / x.max(1e-9) < 0.03,
1605 "{name}: closed {x} vs freeform {y}"
1606 );
1607 }
1608 let (ta, fa) = (trap.analyze(), free.analyze());
1610 assert!((ta.cna_vs_mach[0].1 - fa.cna_vs_mach[0].1).abs() / ta.cna_vs_mach[0].1 < 0.03);
1611 assert!((ta.cp_vs_mach[0].1 - fa.cp_vs_mach[0].1).abs() / ta.cp_vs_mach[0].1 < 0.03);
1612 let parsed = parse_geometry(
1614 "nose=conical:0.1:0.024,tube=0.4:0.024,ffins=3:0x0+0.02x0.04+0.05x0.04+0.05x0:0.002",
1615 )
1616 .unwrap();
1617 assert!(matches!(parsed.fins, FinSet::Freeform(_)));
1618 assert!(
1619 parse_geometry("nose=conical:0.1:0.024,tube=0.4:0.024,ffins=3:0x0+0.05x0").is_err()
1620 );
1621 assert!(parse_geometry(
1622 "nose=conical:0.1:0.024,tube=0.4:0.024,ffins=3:0x0.01+0.05x0+0.02x0.05"
1623 )
1624 .is_err());
1625 }
1626
1627 #[test]
1628 fn elliptical_fins_match_the_half_ellipse_geometry() {
1629 let geo = RocketGeometry::new(
1630 example().nose,
1631 example().tube,
1632 EllipticalFins {
1633 count: 3,
1634 root_chord_m: 0.05,
1635 span_m: 0.05,
1636 thickness_m: 0.0032,
1637 },
1638 );
1639 let p = geo.planform();
1640 let area = std::f64::consts::PI * 0.05 * 0.05 / 4.0;
1643 assert!(
1646 (p.area_m2 - area).abs() / area < 0.02,
1647 "{} vs {area}",
1648 p.area_m2
1649 );
1650 assert!(p.cos_midchord > 0.99, "cos_midchord {}", p.cos_midchord);
1651 assert!((p.cg_x_m - 0.025).abs() < 1e-12);
1652 assert!((p.cg_y_m - 4.0 * 0.05 / (3.0 * std::f64::consts::PI)).abs() < 1e-12);
1653 let mac = 8.0 * 0.05 / (3.0 * std::f64::consts::PI);
1655 assert!((p.mac_m - mac).abs() / mac < 0.02, "{} vs {mac}", p.mac_m);
1656 let analysis = geo.analyze();
1658 assert!(analysis.cna_vs_mach[0].1 > 2.0);
1659 assert!(analysis.cp_vs_mach[0].1 > 0.0 && analysis.cp_vs_mach[0].1 < geo.total_length_m());
1660 let parsed =
1662 parse_geometry("nose=conical:0.1:0.024,tube=0.4:0.024,efins=3:0.05:0.05:0.002")
1663 .unwrap();
1664 assert!(matches!(parsed.fins, FinSet::Elliptical(_)));
1665 let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1667 assert!(props.pitch_inertia_kg_m2 > 0.0);
1668 }
1669
1670 #[test]
1671 fn fin_set_cna_matches_diederich() {
1672 let geo = example();
1673 let area = geo.fin_area();
1676 let cos = geo.midchord_cos();
1677 let span = geo.planform().span_m;
1678 let ratio = span * span / (area * cos);
1679 let one = 2.0 * std::f64::consts::PI * span * span
1680 / (1.0 + (1.0 + ratio * ratio).sqrt())
1681 / geo.ref_area_m2();
1682 let expected = 1.5 * one * (1.0 + geo.interference_tau());
1683 let actual = geo.fin_set_cna(0.0);
1684 assert!(
1685 (actual - expected).abs() / expected < 1e-9,
1686 "actual {actual} expected {expected}"
1687 );
1688 let d = geo.max_diameter_m();
1690 let classic = 4.0 * 3.0 * (geo.planform().span_m / d).powi(2)
1691 / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
1692 * (1.0 + geo.interference_tau());
1693 assert!(
1694 (actual - classic).abs() / classic < 0.02,
1695 "actual {actual} classic {classic}"
1696 );
1697 }
1698
1699 #[test]
1700 fn total_cp_is_the_moment_sum() {
1701 let geo = example();
1702 let fin_cna = geo.fin_set_cna(0.0);
1703 let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs(0.0)) / (2.0 + fin_cna);
1704 assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
1705 assert!(geo.total_cp(0.0) > geo.nose_cp());
1707 }
1708
1709 #[test]
1710 fn base_cd_matches_openrocket() {
1711 assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
1712 assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
1713 assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
1714 }
1715
1716 #[test]
1717 fn analysis_produces_finite_curves_and_roughness_costs_drag() {
1718 let geo = example();
1719 let analysis = geo.analyze();
1720 for &(m, cd) in &analysis.cd_vs_mach {
1721 assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
1722 }
1723 for &(m, cp) in &analysis.cp_vs_mach {
1724 assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
1725 }
1726 let smooth = geo.with_roughness(1e-6).analyze();
1727 let rough = geo.with_roughness(500e-6).analyze();
1728 assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
1729 }
1730}