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)]
124pub struct EllipticalFins {
125 pub count: u32,
127 pub root_chord_m: f64,
129 pub span_m: f64,
131 pub thickness_m: f64,
133}
134
135#[derive(Debug, Clone, Copy)]
137pub enum FinSet {
138 Trapezoidal(TrapezoidalFins),
140 Elliptical(EllipticalFins),
142}
143
144impl From<TrapezoidalFins> for FinSet {
145 fn from(fins: TrapezoidalFins) -> Self {
146 Self::Trapezoidal(fins)
147 }
148}
149
150impl From<EllipticalFins> for FinSet {
151 fn from(fins: EllipticalFins) -> Self {
152 Self::Elliptical(fins)
153 }
154}
155
156#[derive(Debug, Clone, Copy)]
158struct Planform {
159 count: u32,
160 root_chord_m: f64,
161 span_m: f64,
162 thickness_m: f64,
163 area_m2: f64,
164 mac_m: f64,
165 mac_lead_m: f64,
166 cos_midchord: f64,
167 cos_leading_edge: f64,
168 cg_x_m: f64,
169 cg_y_m: f64,
170}
171
172#[derive(Debug, Clone, Copy)]
175pub struct RocketGeometry {
176 pub nose: NoseCone,
178 pub tube: BodyTube,
180 pub fins: FinSet,
182 pub boat_tail: Option<BoatTail>,
184 pub roughness_m: f64,
186}
187
188const ELLIPSOID_TABLE: &[(f64, f64)] = &[
192 (1.2, 0.110),
193 (1.25, 0.128),
194 (1.3, 0.140),
195 (1.4, 0.148),
196 (1.6, 0.152),
197 (2.0, 0.159),
198 (2.4, 0.162),
199];
200const PARABOLIC_TABLE: &[(f64, f64)] = &[
201 (0.95, 0.0),
202 (0.975, 0.016),
203 (1.0, 0.041),
204 (1.05, 0.092),
205 (1.1, 0.109),
206 (1.2, 0.119),
207 (1.4, 0.113),
208 (1.7, 0.108),
209];
210
211impl RocketGeometry {
212 pub fn new(nose: NoseCone, tube: BodyTube, fins: impl Into<FinSet>) -> Self {
214 Self {
215 nose,
216 tube,
217 fins: fins.into(),
218 boat_tail: None,
219 roughness_m: 20e-6,
220 }
221 }
222
223 pub fn with_roughness(mut self, roughness_m: f64) -> Self {
225 self.roughness_m = roughness_m;
226 self
227 }
228
229 pub fn with_boat_tail(mut self, boat_tail: BoatTail) -> Result<Self> {
232 if boat_tail.length_m <= 0.0 || !boat_tail.length_m.is_finite() {
233 return Err(Error::invalid("boat tail", "length must be positive"));
234 }
235 if (boat_tail.fore_diameter_m - self.tube.diameter_m).abs() > 1e-12 {
236 return Err(Error::invalid(
237 "boat tail",
238 "forward diameter must equal the body tube diameter",
239 ));
240 }
241 if boat_tail.aft_diameter_m <= 0.0 || boat_tail.aft_diameter_m >= boat_tail.fore_diameter_m
242 {
243 return Err(Error::invalid(
244 "boat tail",
245 "aft diameter must be positive and smaller than the forward diameter",
246 ));
247 }
248 self.boat_tail = Some(boat_tail);
249 Ok(self)
250 }
251
252 pub fn total_length_m(&self) -> f64 {
254 self.nose.length_m + self.tube.length_m + self.boat_tail.map(|b| b.length_m).unwrap_or(0.0)
255 }
256
257 pub fn max_diameter_m(&self) -> f64 {
259 self.nose.diameter_m.max(self.tube.diameter_m)
260 }
261
262 pub fn ref_area_m2(&self) -> f64 {
264 std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
265 }
266
267 fn planform(&self) -> Planform {
271 match self.fins {
272 FinSet::Trapezoidal(f) => {
273 let (cr, ct, s, m) = (f.root_chord_m, f.tip_chord_m, f.span_m, f.leading_sweep_m);
274 let area = s * (cr + ct) / 2.0;
275 let mac = (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct));
276 let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
277 let gamma_mid = ((m + ct / 2.0 - cr / 2.0) / s).atan();
278 Planform {
279 count: f.count,
280 root_chord_m: cr,
281 span_m: s,
282 thickness_m: f.thickness_m,
283 area_m2: area,
284 mac_m: mac,
285 mac_lead_m: m * y_mac / s,
286 cos_midchord: gamma_mid.cos(),
287 cos_leading_edge: s / (s * s + m * m).sqrt(),
288 cg_x_m: (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct)),
289 cg_y_m: y_mac,
290 }
291 }
292 FinSet::Elliptical(f) => {
293 const DIVISIONS: usize = 48;
294 let (cr, s) = (f.root_chord_m, f.span_m);
295 let dy = s / (DIVISIONS - 1) as f64;
296 let mut area = 0.0;
297 let mut mac_num = 0.0;
298 let mut lead_num = 0.0;
299 let mut cos_mid = 0.0;
300 let mut cos_le = 0.0;
301 let mut previous: Option<(f64, f64)> = None;
302 for i in 0..DIVISIONS {
303 let y = i as f64 * dy;
304 let t = (y / s).min(1.0);
305 let arc = (1.0 - t * t).max(0.0).sqrt();
306 let lead = cr / 2.0 * (1.0 - arc);
307 let chord = cr * arc;
308 let mid = cr / 2.0;
309 area += chord * dy;
310 mac_num += chord * chord * dy;
311 lead_num += lead * chord * dy;
312 if let Some((prev_mid, prev_lead)) = previous {
313 let hypot_mid = dy.hypot(mid - prev_mid);
314 if hypot_mid > 0.0 {
315 cos_mid += dy / hypot_mid;
316 }
317 let hypot_lead = dy.hypot(lead - prev_lead);
318 if hypot_lead > 0.0 {
319 cos_le += dy / hypot_lead;
320 }
321 }
322 previous = Some((mid, lead));
323 }
324 cos_mid /= (DIVISIONS - 1) as f64;
325 cos_le /= (DIVISIONS - 1) as f64;
326 Planform {
327 count: f.count,
328 root_chord_m: cr,
329 span_m: s,
330 thickness_m: f.thickness_m,
331 area_m2: area,
332 mac_m: mac_num / area,
333 mac_lead_m: lead_num / area,
334 cos_midchord: cos_mid,
335 cos_leading_edge: cos_le,
336 cg_x_m: cr / 2.0,
337 cg_y_m: 4.0 * s / (3.0 * std::f64::consts::PI),
338 }
339 }
340 }
341 }
342
343 #[cfg(test)]
344 fn fin_area(&self) -> f64 {
345 self.planform().area_m2
346 }
347
348 fn fin_cp_fraction(&self, mach: f64) -> f64 {
353 if mach <= 0.5 {
354 return 0.25;
355 }
356 let planform = self.planform();
357 let ar = 2.0 * planform.span_m.powi(2) / planform.area_m2;
358 let supersonic = |m: f64| {
359 let beta = (m * m - 1.0).sqrt();
360 (ar * beta - 0.67) / (2.0 * ar * beta - 1.0)
361 };
362 if mach >= 2.0 {
363 return supersonic(mach);
364 }
365 let hi = supersonic(2.0);
366 0.25 + (hi - 0.25) * (mach - 0.5) / 1.5
367 }
368
369 fn fin_cp(&self, mach: f64) -> f64 {
371 let planform = self.planform();
372 planform.mac_lead_m + self.fin_cp_fraction(mach) * planform.mac_m
373 }
374
375 fn fin_pressure_cd(&self, mach: f64) -> f64 {
379 let planform = self.planform();
380 if planform.span_m <= 0.0 || planform.thickness_m <= 0.0 {
381 return 0.0;
382 }
383 let leading_edge = if mach < 0.9 {
384 (1.0 - mach * mach).powf(-0.417) - 1.0
385 } else if mach < 1.0 {
386 1.0 - 1.785 * (mach - 0.9)
387 } else {
388 let m2 = mach * mach;
389 1.214 - 0.502 / m2 + 0.1095 / (m2 * m2)
390 };
391 let cos_le = planform.cos_leading_edge;
392 let cd = leading_edge * cos_le * cos_le + Self::base_cd(mach) / 2.0;
393 cd * planform.span_m * planform.thickness_m / self.ref_area_m2() * planform.count as f64
396 }
397
398 fn nose_pressure_cd(&self, mach: f64) -> f64 {
404 let sinphi = match self.nose.shape {
409 NoseShape::Conical | NoseShape::Ogive => self.nose_sinphi(),
410 NoseShape::Elliptical => return self.table_pressure_cd(mach, ELLIPSOID_TABLE),
411 NoseShape::Parabolic => return self.table_pressure_cd(mach, PARABOLIC_TABLE),
412 };
413 const GAMMA: f64 = 1.4;
414 let cd_mach0 = 0.8 * sinphi * sinphi;
415 let y1 = 2.1 * sinphi * sinphi + 0.6019 * sinphi;
416 let d0 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * sinphi);
417 let d1 = -1.1341 * sinphi;
418 if mach >= 1.3 {
419 return 2.1 * sinphi * sinphi + 0.5 * sinphi / (mach * mach - 1.0).sqrt();
420 }
421 if mach >= 1.0 {
422 return Self::hermite(mach, 1.0, 1.3, sinphi, y1, d0, d1);
423 }
424 let eps = 0.01;
427 let at_eps = Self::hermite(1.0 + eps, 1.0, 1.3, sinphi, y1, d0, d1);
428 let min_deriv = (at_eps - sinphi) / eps;
429 let gap = sinphi - cd_mach0;
430 if gap <= 0.01 || min_deriv <= 0.01 {
431 return cd_mach0;
432 }
433 let b = min_deriv / gap;
434 gap * mach.powf(b) + cd_mach0
435 }
436
437 fn nose_sinphi(&self) -> f64 {
440 let l = self.nose.length_m;
441 let r = self.nose.diameter_m / 2.0;
442 let r_tip = self.nose_radius(0.99 * l);
443 (r - r_tip) / (r - r_tip).hypot(0.01 * l)
444 }
445
446 fn stagnation_cd(mach: f64) -> f64 {
448 let m2 = mach * mach;
449 if mach <= 1.0 {
450 0.85 * (1.0 + m2 / 4.0 + m2 * m2 / 40.0)
451 } else {
452 0.85 * (1.84 - 0.76 / m2 + 0.166 / (m2 * m2) + 0.035 / (m2 * m2 * m2))
453 }
454 }
455
456 fn table_interp(table: &[(f64, f64)], mach: f64) -> f64 {
458 if mach <= table[0].0 {
459 return table[0].1;
460 }
461 for pair in table.windows(2) {
462 let (lo, hi) = (pair[0], pair[1]);
463 if mach <= hi.0 {
464 let f = (mach - lo.0) / (hi.0 - lo.0);
465 return lo.1 + f * (hi.1 - lo.1);
466 }
467 }
468 table[table.len() - 1].1
469 }
470
471 fn table_pressure_cd(&self, mach: f64, table: &[(f64, f64)]) -> f64 {
475 let fineness = self.nose.length_m / self.nose.diameter_m;
476 let log4 = (fineness + 1.0).ln() / 4.0_f64.ln();
477 let value_at = |m: f64| {
478 let stag = Self::stagnation_cd(m);
479 stag * (Self::table_interp(table, m) / stag).powf(log4)
480 };
481 let min = table[0].0;
482 if mach >= min {
483 return value_at(mach);
484 }
485 let cd_mach0 = 0.8 * self.nose_sinphi().powi(2);
486 let min_value = value_at(min);
487 if min_value < 0.001 {
488 return cd_mach0;
489 }
490 let min_deriv = (value_at(min + 0.01) - min_value) / 0.01;
491 let gap = min_value - cd_mach0;
492 if gap <= 0.01 || min_deriv <= 0.01 {
493 return cd_mach0;
494 }
495 let b = min * min_deriv / gap;
496 let a = gap / min.powf(b);
497 a * mach.powf(b) + cd_mach0
498 }
499
500 #[cfg(test)]
501 fn midchord_cos(&self) -> f64 {
502 self.planform().cos_midchord
503 }
504
505 fn hermite(x: f64, x0: f64, x1: f64, y0: f64, y1: f64, d0: f64, d1: f64) -> f64 {
508 let h = x1 - x0;
509 let t = (x - x0) / h;
510 let t2 = t * t;
511 let t3 = t2 * t;
512 (2.0 * t3 - 3.0 * t2 + 1.0) * y0
513 + (t3 - 2.0 * t2 + t) * h * d0
514 + (-2.0 * t3 + 3.0 * t2) * y1
515 + (t3 - t2) * h * d1
516 }
517
518 const CNA_SUBSONIC: f64 = 0.9;
520 const CNA_SUPERSONIC: f64 = 1.5;
521
522 fn fin_cna1(&self, mach: f64) -> f64 {
528 let planform = self.planform();
529 let span = planform.span_m;
530 let area = planform.area_m2;
531 let cos = planform.cos_midchord;
532 if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
533 return 0.0;
534 }
535 let ref_area = self.ref_area_m2();
536 let subsonic = |m: f64| {
537 let ratio = (span * span) / (area * cos);
538 2.0 * std::f64::consts::PI * span * span
539 / (1.0 + (1.0 + (1.0 - m * m) * ratio * ratio).sqrt())
540 / ref_area
541 };
542 let supersonic = |m: f64| 2.0 * area / (m * m - 1.0).sqrt() / ref_area;
543 if mach <= Self::CNA_SUBSONIC {
544 return subsonic(mach);
545 }
546 if mach >= Self::CNA_SUPERSONIC {
547 return supersonic(mach);
548 }
549 let sq = (1.0
550 + (1.0 - Self::CNA_SUBSONIC * Self::CNA_SUBSONIC)
551 * (span * span / (area * cos)).powi(2))
552 .sqrt();
553 let sub_v = 2.0 * std::f64::consts::PI * span * span / ref_area / (1.0 + sq);
554 let sub_d = 2.0 * Self::CNA_SUBSONIC * std::f64::consts::PI * span.powi(6)
555 / ((area * cos).powi(2) * ref_area * sq * (1.0 + sq).powi(2));
556 let super_v = supersonic(Self::CNA_SUPERSONIC);
557 let beta_b = (Self::CNA_SUPERSONIC * Self::CNA_SUPERSONIC - 1.0).powf(1.5);
558 let super_d = -area / ref_area * 2.0 * Self::CNA_SUPERSONIC / beta_b;
559 Self::hermite(
560 mach,
561 Self::CNA_SUBSONIC,
562 Self::CNA_SUPERSONIC,
563 sub_v,
564 super_v,
565 sub_d,
566 super_d,
567 )
568 }
569
570 fn interference_tau(&self) -> f64 {
572 let r = self.max_diameter_m() / 2.0;
573 r / (self.planform().span_m + r)
574 }
575
576 fn fin_count_factor(&self) -> f64 {
577 match self.planform().count {
578 5 => 0.948,
579 6 => 0.913,
580 7 => 0.854,
581 8 => 0.810,
582 n if n >= 9 => 0.75,
583 _ => 1.0,
584 }
585 }
586
587 fn fin_set_cna(&self, mach: f64) -> f64 {
595 self.fin_cna1(mach)
596 * (1.0 + self.interference_tau())
597 * self.fin_count_factor()
598 * (self.planform().count as f64 / 2.0)
599 }
600
601 fn fin_cp_abs(&self, mach: f64) -> f64 {
603 self.nose.length_m + self.tube.length_m - self.planform().root_chord_m + self.fin_cp(mach)
604 }
605
606 fn nose_radius(&self, x: f64) -> f64 {
608 let l = self.nose.length_m;
609 let r = self.nose.diameter_m / 2.0;
610 let t = (x / l).clamp(0.0, 1.0);
611 match self.nose.shape {
612 NoseShape::Conical => r * t,
613 NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
614 NoseShape::Parabolic => r * t * (2.0 - t),
615 NoseShape::Ogive => {
616 let rho = (r * r + l * l) / (2.0 * r);
617 (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
618 }
619 }
620 }
621
622 fn nose_volume_properties(&self) -> (f64, f64) {
625 const N: usize = 1024;
626 let l = self.nose.length_m;
627 let dx = l / N as f64;
628 let mut volume = 0.0; let mut moment = 0.0; for i in 0..N {
631 let x = (i as f64 + 0.5) * dx;
632 let r2 = self.nose_radius(x).powi(2);
633 volume += r2 * dx;
634 moment += x * r2 * dx;
635 }
636 let cg = moment / volume;
637 let mut inertia = 0.0; for i in 0..N {
639 let x = (i as f64 + 0.5) * dx;
640 let r2 = self.nose_radius(x).powi(2);
641 inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
642 }
643 (cg, inertia / volume)
644 }
645
646 fn fin_planform_cg(&self) -> f64 {
648 self.planform().cg_x_m
649 }
650
651 fn fin_radial_cg(&self) -> f64 {
653 self.planform().cg_y_m
654 }
655
656 pub fn mass_properties(
664 &self,
665 nose_kg: f64,
666 tube_kg: f64,
667 fins_kg: f64,
668 casing: Option<(f64, f64)>,
669 ) -> MassProperties {
670 self.mass_properties_with_boat(nose_kg, tube_kg, fins_kg, None, casing)
671 }
672
673 pub fn mass_properties_with_boat(
676 &self,
677 nose_kg: f64,
678 tube_kg: f64,
679 fins_kg: f64,
680 boat_kg: Option<f64>,
681 casing: Option<(f64, f64)>,
682 ) -> MassProperties {
683 assert!(
684 nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
685 "component masses must be non-negative"
686 );
687 let (nose_cg, nose_inertia) = self.nose_volume_properties();
688 let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
689 let fin_cg = self.nose.length_m + self.tube.length_m - self.planform().root_chord_m
690 + self.fin_planform_cg();
691 let mut parts: Vec<(f64, f64, f64)> = vec![
692 (nose_kg, nose_cg, nose_kg * nose_inertia),
693 (
694 tube_kg,
695 tube_cg,
696 tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
697 ),
698 (fins_kg, fin_cg, 0.0),
699 ];
700 if let (Some(b), Some(kg)) = (self.boat_tail, boat_kg) {
701 let rf = b.fore_diameter_m / 2.0;
702 let ra = b.aft_diameter_m / 2.0;
703 let cg = self.nose.length_m
706 + self.tube.length_m
707 + b.length_m * (rf + 2.0 * ra) / (3.0 * (rf + ra));
708 let mean_r = (rf + ra) / 2.0;
709 parts.push((
710 kg,
711 cg,
712 kg * (b.length_m.powi(2) / 12.0 + mean_r * mean_r / 2.0),
713 ));
714 }
715 if let Some((kg, cg)) = casing {
716 parts.push((kg, cg, 0.0));
717 }
718 let total: f64 = parts.iter().map(|p| p.0).sum();
719 assert!(total > 0.0, "component masses must sum to a positive mass");
720 let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
721 let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
722 inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
723 MassProperties {
724 dry_mass_kg: total,
725 dry_cg_m: cg,
726 pitch_inertia_kg_m2: inertia,
727 }
728 }
729
730 fn boat_cna(&self) -> f64 {
733 match self.boat_tail {
734 Some(b) => {
735 let ratio = b.aft_diameter_m / b.fore_diameter_m;
736 2.0 * (ratio * ratio - 1.0)
737 }
738 None => 0.0,
739 }
740 }
741
742 fn boat_cp_abs(&self) -> f64 {
745 match self.boat_tail {
746 Some(b) => {
747 let rf = b.fore_diameter_m / 2.0;
748 let ra = b.aft_diameter_m / 2.0;
749 self.nose.length_m
750 + self.tube.length_m
751 + b.length_m * (rf + 3.0 * ra) / (4.0 * (rf + ra))
752 }
753 None => 0.0,
754 }
755 }
756
757 fn boat_pressure_cd(&self, mach: f64) -> f64 {
761 let Some(b) = self.boat_tail else {
762 return 0.0;
763 };
764 let fineness = b.length_m / (b.fore_diameter_m - b.aft_diameter_m);
765 if fineness >= 3.0 {
766 return 0.0;
767 }
768 let annulus =
769 std::f64::consts::FRAC_PI_4 * (b.fore_diameter_m.powi(2) - b.aft_diameter_m.powi(2));
770 let cd = Self::base_cd(mach) * annulus / self.ref_area_m2();
771 if fineness <= 1.0 {
772 cd
773 } else {
774 cd * (3.0 - fineness) / 2.0
775 }
776 }
777
778 fn base_area_fraction(&self) -> f64 {
780 match self.boat_tail {
781 Some(b) => (b.aft_diameter_m / self.max_diameter_m()).powi(2),
782 None => 1.0,
783 }
784 }
785
786 fn total_cp(&self, mach: f64) -> f64 {
788 let nose_cna = 2.0;
789 let fin_cna = self.fin_set_cna(mach);
790 let boat_cna = self.boat_cna();
791 let total = nose_cna + fin_cna + boat_cna;
792 if total <= 0.0 {
793 return f64::NAN;
794 }
795 (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
796 + fin_cna * self.fin_cp_abs(mach)
797 + boat_cna * self.boat_cp_abs())
798 / total
799 }
800
801 #[cfg(test)]
802 fn nose_cp(&self) -> f64 {
803 self.nose.length_m * self.nose.shape.cp_fraction()
804 }
805
806 fn base_cd(mach: f64) -> f64 {
807 if mach <= 1.0 {
808 0.12 + 0.13 * mach * mach
809 } else {
810 0.25 / mach
811 }
812 }
813
814 fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
815 let length = self.total_length_m();
816 let velocity = mach * sound_mps;
817 let reynolds = velocity * length / 1.5e-5;
818 let rough_ratio = self.roughness_m / length;
819 let cf_turbulent = if reynolds < 1.0e4 {
820 1.48e-2
821 } else {
822 1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
823 };
824 let cf_rough = 0.032 * rough_ratio.powf(0.2);
825 let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
828 let r = self.max_diameter_m() / 2.0;
829 let fineness = length / r;
830 let correction = 1.0 + 1.0 / (2.0 * fineness);
831 let boat_wet = match self.boat_tail {
832 Some(b) => {
833 let rf = b.fore_diameter_m / 2.0;
834 let ra = b.aft_diameter_m / 2.0;
835 let slant = (b.length_m.powi(2) + (rf - ra).powi(2)).sqrt();
836 std::f64::consts::PI * (rf + ra) * slant
837 }
838 None => 0.0,
839 };
840 let body_wet = self
841 .nose
842 .shape
843 .lateral_area(self.nose.length_m, self.nose.diameter_m)
844 + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m
845 + boat_wet;
846 let planform = self.planform();
847 let fin_wet = 2.0 * planform.count as f64 * planform.area_m2;
848 let thickness_factor = 1.0 + 2.0 * planform.thickness_m / planform.mac_m;
849 cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
850 }
851
852 pub fn analyze(&self) -> AeroAnalysis {
856 self.validate();
857 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];
858 let mut cd_vs_mach = Vec::new();
859 let mut cna_vs_mach = Vec::new();
860 let mut cp_vs_mach = Vec::new();
861 let mut induced_k_vs_mach = Vec::new();
862 for &m in &machs {
863 cd_vs_mach.push((
864 m,
865 Self::base_cd(m) * self.base_area_fraction()
866 + self.nose_pressure_cd(m)
867 + self.fin_pressure_cd(m)
868 + self.boat_pressure_cd(m)
869 + self.friction_cd(m, 340.0),
870 ));
871 let fin_cna = self.fin_set_cna(m);
872 let total_cna = 2.0 + fin_cna + self.boat_cna();
873 cna_vs_mach.push((m, total_cna));
874 cp_vs_mach.push((m, self.total_cp(m)));
875 induced_k_vs_mach.push((m, total_cna));
878 }
879 AeroAnalysis {
880 cd_vs_mach,
881 cna_vs_mach,
882 cp_vs_mach,
883 induced_k_vs_mach,
884 ref_area_m2: self.ref_area_m2(),
885 total_length_m: self.total_length_m(),
886 max_diameter_m: self.max_diameter_m(),
887 }
888 }
889
890 fn validate(&self) {
891 assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
892 assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
893 let planform = self.planform();
894 assert!(planform.count > 0);
895 assert!(planform.span_m > 0.0);
896 assert!(planform.root_chord_m > 0.0 && planform.thickness_m > 0.0);
897 }
898}
899
900pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
907 let mut nose = None;
908 let mut tube = None;
909 let mut fins: Option<FinSet> = None;
910 let mut boat_tail = None;
911 for item in spec.split(',') {
912 let (key, val) = item
913 .split_once('=')
914 .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
915 let parts: Vec<&str> = val.split(':').collect();
916 let number = |i: usize| -> Result<f64> {
917 parts
918 .get(i)
919 .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
920 .parse()
921 .map_err(|_| Error::invalid("geometry", "non-numeric field"))
922 };
923 match key {
924 "nose" => {
925 if parts.len() != 3 {
926 return Err(Error::invalid("nose", "expected shape:length:diameter"));
927 }
928 let shape = parts[0].parse::<NoseShape>()?;
929 nose = Some(NoseCone {
930 shape,
931 length_m: number(1)?,
932 diameter_m: number(2)?,
933 });
934 }
935 "tube" => {
936 if parts.len() != 2 {
937 return Err(Error::invalid("tube", "expected length:diameter"));
938 }
939 tube = Some(BodyTube {
940 length_m: number(0)?,
941 diameter_m: number(1)?,
942 });
943 }
944 "fins" => {
945 if !(5..=6).contains(&parts.len()) {
946 return Err(Error::invalid(
947 "fins",
948 "expected count:root:tip:span:sweep[:thickness]",
949 ));
950 }
951 let count: u32 = parts[0]
952 .parse()
953 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
954 fins = Some(
955 TrapezoidalFins {
956 count,
957 root_chord_m: number(1)?,
958 tip_chord_m: number(2)?,
959 span_m: number(3)?,
960 leading_sweep_m: number(4)?,
961 thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
962 }
963 .into(),
964 );
965 }
966 "efins" => {
967 if !(3..=4).contains(&parts.len()) {
968 return Err(Error::invalid(
969 "efins",
970 "expected count:root:span[:thickness]",
971 ));
972 }
973 let count: u32 = parts[0]
974 .parse()
975 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
976 fins = Some(
977 EllipticalFins {
978 count,
979 root_chord_m: number(1)?,
980 span_m: number(2)?,
981 thickness_m: if parts.len() == 4 { number(3)? } else { 0.002 },
982 }
983 .into(),
984 );
985 }
986 "boat" => {
987 if parts.len() != 3 {
988 return Err(Error::invalid(
989 "boat",
990 "expected boat=length:fore_diameter:aft_diameter",
991 ));
992 }
993 boat_tail = Some(BoatTail {
994 length_m: number(0)?,
995 fore_diameter_m: number(1)?,
996 aft_diameter_m: number(2)?,
997 });
998 }
999 other => {
1000 return Err(Error::invalid(
1001 "geometry key",
1002 match other {
1003 "nose" | "tube" | "fins" => unreachable!(),
1004 _ => "must be nose, tube, fins, efins or boat",
1005 },
1006 ))
1007 }
1008 }
1009 }
1010 let geometry = RocketGeometry::new(
1011 nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
1012 tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
1013 fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
1014 );
1015 match boat_tail {
1016 Some(boat) => geometry.with_boat_tail(boat),
1017 None => Ok(geometry),
1018 }
1019}
1020
1021#[derive(Debug, Clone, Copy)]
1024pub struct MassProperties {
1025 pub dry_mass_kg: f64,
1027 pub dry_cg_m: f64,
1029 pub pitch_inertia_kg_m2: f64,
1031}
1032
1033#[derive(Debug, Clone)]
1035pub struct AeroAnalysis {
1036 pub cd_vs_mach: Vec<(f64, f64)>,
1038 pub cna_vs_mach: Vec<(f64, f64)>,
1040 pub cp_vs_mach: Vec<(f64, f64)>,
1042 pub induced_k_vs_mach: Vec<(f64, f64)>,
1044 pub ref_area_m2: f64,
1046 pub total_length_m: f64,
1048 pub max_diameter_m: f64,
1050}
1051
1052#[cfg(feature = "ballistics")]
1053impl AeroAnalysis {
1054 pub fn airframe(
1056 &self,
1057 cg_dry_m: f64,
1058 cg_prop_m: f64,
1059 pitch_inertia_kg_m2: f64,
1060 recovery_cd_area_m2: f64,
1061 ) -> crate::Airframe {
1062 crate::Airframe {
1063 diameter_m: self.max_diameter_m,
1064 cd_vs_mach: self.cd_vs_mach.clone(),
1065 cna_vs_mach: self.cna_vs_mach.clone(),
1066 cp_vs_mach: self.cp_vs_mach.clone(),
1067 cg_dry_m,
1068 cg_prop_m,
1069 pitch_inertia_kg_m2,
1070 induced_k_vs_mach: self.induced_k_vs_mach.clone(),
1071 recovery_cd_area_m2,
1072 }
1073 }
1074
1075 pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
1077 crate::ballistics::BallisticForces::new(
1078 self.max_diameter_m,
1079 self.cd_vs_mach.clone(),
1080 crate::ballistics::Atmosphere::default(),
1081 wind_mps,
1082 )?
1083 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
1084 }
1085
1086 pub fn weathercock_params(
1088 &self,
1089 cg_dry_m: f64,
1090 cg_prop_m: f64,
1091 pitch_inertia_kg_m2: f64,
1092 ) -> Result<crate::WeathercockParams> {
1093 crate::WeathercockParams::new(
1094 self.cna_vs_mach.clone(),
1095 self.cp_vs_mach.clone(),
1096 cg_dry_m,
1097 cg_prop_m,
1098 self.max_diameter_m,
1099 pitch_inertia_kg_m2,
1100 0.2,
1101 )
1102 }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108
1109 fn example() -> RocketGeometry {
1110 RocketGeometry::new(
1111 NoseCone {
1112 shape: NoseShape::Conical,
1113 length_m: 0.1,
1114 diameter_m: 0.024,
1115 },
1116 BodyTube {
1117 length_m: 0.4,
1118 diameter_m: 0.024,
1119 },
1120 TrapezoidalFins {
1121 count: 3,
1122 root_chord_m: 0.05,
1123 tip_chord_m: 0.03,
1124 span_m: 0.04,
1125 leading_sweep_m: 0.02,
1126 thickness_m: 0.002,
1127 },
1128 )
1129 }
1130
1131 #[test]
1132 fn nose_cp_fractions_match_barrowman() {
1133 assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
1134 assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
1135 assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
1136 assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
1137 }
1138
1139 #[test]
1140 fn conical_pressure_drag_uses_half_angle() {
1141 let geo = example(); let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
1144 assert!((geo.nose_pressure_cd(0.0) - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
1145 }
1146
1147 #[test]
1148 fn nose_wave_drag_matches_openrocket_formula() {
1149 let geo = example(); let r = 0.012_f64;
1151 let sinphi = r / (r * r + 0.1 * 0.1).sqrt();
1152 let m = 1.5_f64;
1154 let expected = 2.1 * sinphi * sinphi + 0.5 * sinphi / (m * m - 1.0).sqrt();
1155 assert!(
1156 (geo.nose_pressure_cd(m) - expected).abs() < 1e-12,
1157 "{} vs {expected}",
1158 geo.nose_pressure_cd(m)
1159 );
1160 let below = geo.nose_pressure_cd(0.999);
1162 let at = geo.nose_pressure_cd(1.0);
1163 assert!((at - sinphi).abs() < 0.02, "at {at}");
1164 assert!(below < at && below > 0.8 * sinphi * sinphi);
1165 }
1166
1167 #[test]
1168 fn fin_cp_matches_the_trapezoid_formula() {
1169 let geo = example();
1170 let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
1172 let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
1173 let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
1174 assert!(
1175 (geo.fin_cp(0.0) - expected).abs() < 1e-12,
1176 "{}",
1177 geo.fin_cp(0.0)
1178 );
1179 }
1180
1181 #[test]
1182 fn boat_tail_physics() {
1183 let base = example();
1184 let with_boat = base
1185 .with_boat_tail(BoatTail {
1186 length_m: 0.06,
1187 fore_diameter_m: 0.024,
1188 aft_diameter_m: 0.016,
1189 })
1190 .unwrap();
1191 let delta = base.fin_set_cna(0.0) + 2.0 - (with_boat.fin_set_cna(0.0) + 2.0);
1193 let expected = 2.0 * (1.0 - (0.016_f64 / 0.024).powi(2));
1194 assert!((with_boat.boat_cna() + expected).abs() < 1e-12);
1195 assert!((delta - 0.0).abs() < 1e-12 || delta > 0.0); let cna_base = base.analyze().cna_vs_mach[0].1;
1197 let cna_boat = with_boat.analyze().cna_vs_mach[0].1;
1198 assert!(
1199 (cna_base - cna_boat - expected).abs() < 1e-9,
1200 "{cna_base} vs {cna_boat}"
1201 );
1202 let cp = with_boat.boat_cp_abs();
1204 let want = 0.1 + 0.4 + 0.06 * (0.012 + 3.0 * 0.008) / (4.0 * (0.012 + 0.008));
1205 assert!((cp - want).abs() < 1e-12, "{cp} vs {want}");
1206 assert_eq!(with_boat.boat_pressure_cd(0.5), 0.0);
1208 let stubby = base
1209 .with_boat_tail(BoatTail {
1210 length_m: 0.012,
1211 fore_diameter_m: 0.024,
1212 aft_diameter_m: 0.016,
1213 })
1214 .unwrap();
1215 assert!(stubby.boat_pressure_cd(0.5) > 0.0);
1216 assert!((with_boat.base_area_fraction() - (0.016_f64 / 0.024).powi(2)).abs() < 1e-12);
1218 assert!((with_boat.total_length_m() - 0.56).abs() < 1e-12);
1220 assert!((with_boat.fin_cp_abs(0.0) - base.fin_cp_abs(0.0)).abs() < 1e-12);
1221 assert!(base
1223 .with_boat_tail(BoatTail {
1224 length_m: 0.06,
1225 fore_diameter_m: 0.03,
1226 aft_diameter_m: 0.016,
1227 })
1228 .is_err());
1229 assert!(base
1230 .with_boat_tail(BoatTail {
1231 length_m: 0.06,
1232 fore_diameter_m: 0.024,
1233 aft_diameter_m: 0.03,
1234 })
1235 .is_err());
1236 let parsed = parse_geometry(
1238 "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",
1239 )
1240 .unwrap();
1241 assert!(parsed.boat_tail.is_some());
1242 assert!(parse_geometry(
1243 "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",
1244 )
1245 .is_err());
1246 let props = with_boat.mass_properties_with_boat(0.012, 0.020, 0.006, Some(0.004), None);
1248 assert!((props.dry_mass_kg - 0.042).abs() < 1e-12);
1249 assert!(props.pitch_inertia_kg_m2 > 0.0);
1250 }
1251
1252 #[test]
1253 fn elliptical_and_parabolic_noses_use_the_nasa_tables() {
1254 let elliptical = RocketGeometry::new(
1255 NoseCone {
1256 shape: NoseShape::Elliptical,
1257 length_m: 0.072,
1258 diameter_m: 0.024,
1259 },
1260 example().tube,
1261 example().fins,
1262 );
1263 assert!(
1265 (elliptical.nose_pressure_cd(1.4) - 0.148).abs() < 0.005,
1266 "{}",
1267 elliptical.nose_pressure_cd(1.4)
1268 );
1269 assert!(elliptical.nose_pressure_cd(0.5) < 0.02);
1271 let parabolic = RocketGeometry::new(
1272 NoseCone {
1273 shape: NoseShape::Parabolic,
1274 length_m: 0.072,
1275 diameter_m: 0.024,
1276 },
1277 example().tube,
1278 example().fins,
1279 );
1280 assert!(parabolic.nose_pressure_cd(0.8) < 1e-4);
1282 assert!(
1283 (parabolic.nose_pressure_cd(1.05) - 0.092).abs() < 0.005,
1284 "{}",
1285 parabolic.nose_pressure_cd(1.05)
1286 );
1287 let lo = RocketGeometry::stagnation_cd(0.999);
1289 let hi = RocketGeometry::stagnation_cd(1.001);
1290 assert!((lo - hi).abs() < 0.01, "{lo} vs {hi}");
1291 }
1292
1293 #[test]
1294 fn fin_cp_moves_aft_supersonic() {
1295 let geo = example();
1296 assert_eq!(geo.fin_cp_fraction(0.0), 0.25);
1297 assert_eq!(geo.fin_cp_fraction(0.5), 0.25);
1298 let ar = 2.0 * 0.04_f64.powi(2) / geo.fin_area();
1300 let beta = (4.0_f64 - 1.0).sqrt();
1301 let expected = (ar * beta - 0.67) / (2.0 * ar * beta - 1.0);
1302 assert!((geo.fin_cp_fraction(2.0) - expected).abs() < 1e-12);
1303 assert!(geo.fin_cp_fraction(1.0) > 0.25 && geo.fin_cp_fraction(1.0) < expected);
1304 }
1305
1306 #[test]
1307 fn conical_nose_volume_properties_match_the_solid_cone() {
1308 let geo = example(); let (cg, inertia) = geo.nose_volume_properties();
1310 assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
1311 let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
1312 assert!(
1313 (inertia - expected).abs() / expected < 1e-4,
1314 "inertia {inertia} expected {expected}"
1315 );
1316 }
1317
1318 #[test]
1319 fn fin_centroid_matches_polygon_limits() {
1320 let geo = example();
1321 let rectangular = RocketGeometry::new(
1323 geo.nose,
1324 geo.tube,
1325 TrapezoidalFins {
1326 count: 3,
1327 root_chord_m: 0.05,
1328 tip_chord_m: 0.05,
1329 span_m: 0.04,
1330 leading_sweep_m: 0.0,
1331 thickness_m: 0.002,
1332 },
1333 );
1334 assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
1335 assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
1336 let triangular = RocketGeometry::new(
1338 geo.nose,
1339 geo.tube,
1340 TrapezoidalFins {
1341 count: 3,
1342 root_chord_m: 0.06,
1343 tip_chord_m: 0.0,
1344 span_m: 0.03,
1345 leading_sweep_m: 0.03,
1346 thickness_m: 0.002,
1347 },
1348 );
1349 assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
1350 assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
1351 }
1352
1353 #[test]
1354 fn mass_properties_are_consistent() {
1355 let geo = example();
1356 let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1357 assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
1358 let (nose_cg, _) = geo.nose_volume_properties();
1360 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1361 let fin_cg = geo.total_length_m() - geo.planform().root_chord_m + geo.fin_planform_cg();
1362 let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
1363 assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
1364 assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
1365 assert!(props.pitch_inertia_kg_m2 > 0.0);
1366 let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
1368 assert!(with_casing.dry_cg_m > props.dry_cg_m);
1369 assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
1370 assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
1371 let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
1373 assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
1374 }
1375
1376 #[test]
1377 fn induced_k_tracks_cna() {
1378 let geo = example();
1380 let analysis = geo.analyze();
1381 for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
1382 assert_eq!(k, cna, "mach {m}");
1383 }
1384 }
1385
1386 #[test]
1387 fn mass_properties_single_component_is_exact() {
1388 let geo = example();
1389 let props = geo.mass_properties(0.0, 0.05, 0.0, None);
1390 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1392 assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
1393 let expected =
1394 0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
1395 assert!(
1396 (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
1397 "{} vs {expected}",
1398 props.pitch_inertia_kg_m2
1399 );
1400 }
1401
1402 #[test]
1403 fn elliptical_fins_match_the_half_ellipse_geometry() {
1404 let geo = RocketGeometry::new(
1405 example().nose,
1406 example().tube,
1407 EllipticalFins {
1408 count: 3,
1409 root_chord_m: 0.05,
1410 span_m: 0.05,
1411 thickness_m: 0.0032,
1412 },
1413 );
1414 let p = geo.planform();
1415 let area = std::f64::consts::PI * 0.05 * 0.05 / 4.0;
1418 assert!(
1421 (p.area_m2 - area).abs() / area < 0.02,
1422 "{} vs {area}",
1423 p.area_m2
1424 );
1425 assert!(p.cos_midchord > 0.99, "cos_midchord {}", p.cos_midchord);
1426 assert!((p.cg_x_m - 0.025).abs() < 1e-12);
1427 assert!((p.cg_y_m - 4.0 * 0.05 / (3.0 * std::f64::consts::PI)).abs() < 1e-12);
1428 let mac = 8.0 * 0.05 / (3.0 * std::f64::consts::PI);
1430 assert!((p.mac_m - mac).abs() / mac < 0.02, "{} vs {mac}", p.mac_m);
1431 let analysis = geo.analyze();
1433 assert!(analysis.cna_vs_mach[0].1 > 2.0);
1434 assert!(analysis.cp_vs_mach[0].1 > 0.0 && analysis.cp_vs_mach[0].1 < geo.total_length_m());
1435 let parsed =
1437 parse_geometry("nose=conical:0.1:0.024,tube=0.4:0.024,efins=3:0.05:0.05:0.002")
1438 .unwrap();
1439 assert!(matches!(parsed.fins, FinSet::Elliptical(_)));
1440 let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1442 assert!(props.pitch_inertia_kg_m2 > 0.0);
1443 }
1444
1445 #[test]
1446 fn fin_set_cna_matches_diederich() {
1447 let geo = example();
1448 let area = geo.fin_area();
1451 let cos = geo.midchord_cos();
1452 let span = geo.planform().span_m;
1453 let ratio = span * span / (area * cos);
1454 let one = 2.0 * std::f64::consts::PI * span * span
1455 / (1.0 + (1.0 + ratio * ratio).sqrt())
1456 / geo.ref_area_m2();
1457 let expected = 1.5 * one * (1.0 + geo.interference_tau());
1458 let actual = geo.fin_set_cna(0.0);
1459 assert!(
1460 (actual - expected).abs() / expected < 1e-9,
1461 "actual {actual} expected {expected}"
1462 );
1463 let d = geo.max_diameter_m();
1465 let classic = 4.0 * 3.0 * (geo.planform().span_m / d).powi(2)
1466 / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
1467 * (1.0 + geo.interference_tau());
1468 assert!(
1469 (actual - classic).abs() / classic < 0.02,
1470 "actual {actual} classic {classic}"
1471 );
1472 }
1473
1474 #[test]
1475 fn total_cp_is_the_moment_sum() {
1476 let geo = example();
1477 let fin_cna = geo.fin_set_cna(0.0);
1478 let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs(0.0)) / (2.0 + fin_cna);
1479 assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
1480 assert!(geo.total_cp(0.0) > geo.nose_cp());
1482 }
1483
1484 #[test]
1485 fn base_cd_matches_openrocket() {
1486 assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
1487 assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
1488 assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
1489 }
1490
1491 #[test]
1492 fn analysis_produces_finite_curves_and_roughness_costs_drag() {
1493 let geo = example();
1494 let analysis = geo.analyze();
1495 for &(m, cd) in &analysis.cd_vs_mach {
1496 assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
1497 }
1498 for &(m, cp) in &analysis.cp_vs_mach {
1499 assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
1500 }
1501 let smooth = geo.with_roughness(1e-6).analyze();
1502 let rough = geo.with_roughness(500e-6).analyze();
1503 assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
1504 }
1505}