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)]
109pub struct RocketGeometry {
110 pub nose: NoseCone,
112 pub tube: BodyTube,
114 pub fins: TrapezoidalFins,
116 pub roughness_m: f64,
118}
119
120impl RocketGeometry {
121 pub fn new(nose: NoseCone, tube: BodyTube, fins: TrapezoidalFins) -> Self {
123 Self {
124 nose,
125 tube,
126 fins,
127 roughness_m: 20e-6,
128 }
129 }
130
131 pub fn with_roughness(mut self, roughness_m: f64) -> Self {
133 self.roughness_m = roughness_m;
134 self
135 }
136
137 pub fn total_length_m(&self) -> f64 {
139 self.nose.length_m + self.tube.length_m
140 }
141
142 pub fn max_diameter_m(&self) -> f64 {
144 self.nose.diameter_m.max(self.tube.diameter_m)
145 }
146
147 pub fn ref_area_m2(&self) -> f64 {
149 std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
150 }
151
152 fn fin_area(&self) -> f64 {
153 self.fins.span_m * (self.fins.root_chord_m + self.fins.tip_chord_m) / 2.0
154 }
155
156 fn mac(&self) -> f64 {
158 let (cr, ct) = (self.fins.root_chord_m, self.fins.tip_chord_m);
159 (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct))
160 }
161
162 fn fin_cp_fraction(&self, mach: f64) -> f64 {
167 if mach <= 0.5 {
168 return 0.25;
169 }
170 let ar = 2.0 * self.fins.span_m.powi(2) / self.fin_area();
171 let supersonic = |m: f64| {
172 let beta = (m * m - 1.0).sqrt();
173 (ar * beta - 0.67) / (2.0 * ar * beta - 1.0)
174 };
175 if mach >= 2.0 {
176 return supersonic(mach);
177 }
178 let hi = supersonic(2.0);
179 0.25 + (hi - 0.25) * (mach - 0.5) / 1.5
180 }
181
182 fn fin_cp(&self, mach: f64) -> f64 {
184 let (cr, ct, s, m) = (
185 self.fins.root_chord_m,
186 self.fins.tip_chord_m,
187 self.fins.span_m,
188 self.fins.leading_sweep_m,
189 );
190 let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
191 let x_mac_le = m * y_mac / s;
192 x_mac_le + self.fin_cp_fraction(mach) * self.mac()
193 }
194
195 fn leading_edge_cos(&self) -> f64 {
197 let (s, m) = (self.fins.span_m, self.fins.leading_sweep_m);
198 s / (s * s + m * m).sqrt()
199 }
200
201 fn fin_pressure_cd(&self, mach: f64) -> f64 {
205 let span = self.fins.span_m;
206 let thickness = self.fins.thickness_m;
207 if span <= 0.0 || thickness <= 0.0 {
208 return 0.0;
209 }
210 let leading_edge = if mach < 0.9 {
211 (1.0 - mach * mach).powf(-0.417) - 1.0
212 } else if mach < 1.0 {
213 1.0 - 1.785 * (mach - 0.9)
214 } else {
215 let m2 = mach * mach;
216 1.214 - 0.502 / m2 + 0.1095 / (m2 * m2)
217 };
218 let cos_le = self.leading_edge_cos();
219 let cd = leading_edge * cos_le * cos_le + Self::base_cd(mach) / 2.0;
220 cd * span * thickness / self.ref_area_m2() * self.fins.count as f64
223 }
224
225 fn nose_pressure_cd(&self, mach: f64) -> f64 {
231 let sinphi = match self.nose.shape {
236 NoseShape::Conical | NoseShape::Ogive => {
237 let l = self.nose.length_m;
238 let r = self.nose.diameter_m / 2.0;
239 let r_tip = self.nose_radius(0.99 * l);
240 (r - r_tip) / (r - r_tip).hypot(0.01 * l)
241 }
242 _ => return 0.0,
243 };
244 const GAMMA: f64 = 1.4;
245 let cd_mach0 = 0.8 * sinphi * sinphi;
246 let y1 = 2.1 * sinphi * sinphi + 0.6019 * sinphi;
247 let d0 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * sinphi);
248 let d1 = -1.1341 * sinphi;
249 if mach >= 1.3 {
250 return 2.1 * sinphi * sinphi + 0.5 * sinphi / (mach * mach - 1.0).sqrt();
251 }
252 if mach >= 1.0 {
253 return Self::hermite(mach, 1.0, 1.3, sinphi, y1, d0, d1);
254 }
255 let eps = 0.01;
258 let at_eps = Self::hermite(1.0 + eps, 1.0, 1.3, sinphi, y1, d0, d1);
259 let min_deriv = (at_eps - sinphi) / eps;
260 let gap = sinphi - cd_mach0;
261 if gap <= 0.01 || min_deriv <= 0.01 {
262 return cd_mach0;
263 }
264 let b = min_deriv / gap;
265 gap * mach.powf(b) + cd_mach0
266 }
267
268 fn midchord_cos(&self) -> f64 {
269 let (cr, ct, s, m) = (
270 self.fins.root_chord_m,
271 self.fins.tip_chord_m,
272 self.fins.span_m,
273 self.fins.leading_sweep_m,
274 );
275 let gamma = ((m + ct / 2.0 - cr / 2.0) / s).atan();
276 gamma.cos()
277 }
278
279 fn hermite(x: f64, x0: f64, x1: f64, y0: f64, y1: f64, d0: f64, d1: f64) -> f64 {
282 let h = x1 - x0;
283 let t = (x - x0) / h;
284 let t2 = t * t;
285 let t3 = t2 * t;
286 (2.0 * t3 - 3.0 * t2 + 1.0) * y0
287 + (t3 - 2.0 * t2 + t) * h * d0
288 + (-2.0 * t3 + 3.0 * t2) * y1
289 + (t3 - t2) * h * d1
290 }
291
292 const CNA_SUBSONIC: f64 = 0.9;
294 const CNA_SUPERSONIC: f64 = 1.5;
295
296 fn fin_cna1(&self, mach: f64) -> f64 {
302 let span = self.fins.span_m;
303 let area = self.fin_area();
304 let cos = self.midchord_cos();
305 if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
306 return 0.0;
307 }
308 let ref_area = self.ref_area_m2();
309 let subsonic = |m: f64| {
310 let ratio = (span * span) / (area * cos);
311 2.0 * std::f64::consts::PI * span * span
312 / (1.0 + (1.0 + (1.0 - m * m) * ratio * ratio).sqrt())
313 / ref_area
314 };
315 let supersonic = |m: f64| 2.0 * area / (m * m - 1.0).sqrt() / ref_area;
316 if mach <= Self::CNA_SUBSONIC {
317 return subsonic(mach);
318 }
319 if mach >= Self::CNA_SUPERSONIC {
320 return supersonic(mach);
321 }
322 let sq = (1.0
323 + (1.0 - Self::CNA_SUBSONIC * Self::CNA_SUBSONIC)
324 * (span * span / (area * cos)).powi(2))
325 .sqrt();
326 let sub_v = 2.0 * std::f64::consts::PI * span * span / ref_area / (1.0 + sq);
327 let sub_d = 2.0 * Self::CNA_SUBSONIC * std::f64::consts::PI * span.powi(6)
328 / ((area * cos).powi(2) * ref_area * sq * (1.0 + sq).powi(2));
329 let super_v = supersonic(Self::CNA_SUPERSONIC);
330 let beta_b = (Self::CNA_SUPERSONIC * Self::CNA_SUPERSONIC - 1.0).powf(1.5);
331 let super_d = -area / ref_area * 2.0 * Self::CNA_SUPERSONIC / beta_b;
332 Self::hermite(
333 mach,
334 Self::CNA_SUBSONIC,
335 Self::CNA_SUPERSONIC,
336 sub_v,
337 super_v,
338 sub_d,
339 super_d,
340 )
341 }
342
343 fn interference_tau(&self) -> f64 {
345 let r = self.max_diameter_m() / 2.0;
346 r / (self.fins.span_m + r)
347 }
348
349 fn fin_count_factor(&self) -> f64 {
350 match self.fins.count {
351 5 => 0.948,
352 6 => 0.913,
353 7 => 0.854,
354 8 => 0.810,
355 n if n >= 9 => 0.75,
356 _ => 1.0,
357 }
358 }
359
360 fn fin_set_cna(&self, mach: f64) -> f64 {
368 self.fin_cna1(mach)
369 * (1.0 + self.interference_tau())
370 * self.fin_count_factor()
371 * (self.fins.count as f64 / 2.0)
372 }
373
374 fn fin_cp_abs(&self, mach: f64) -> f64 {
376 self.total_length_m() - self.fins.root_chord_m + self.fin_cp(mach)
377 }
378
379 fn nose_radius(&self, x: f64) -> f64 {
381 let l = self.nose.length_m;
382 let r = self.nose.diameter_m / 2.0;
383 let t = (x / l).clamp(0.0, 1.0);
384 match self.nose.shape {
385 NoseShape::Conical => r * t,
386 NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
387 NoseShape::Parabolic => r * t * (2.0 - t),
388 NoseShape::Ogive => {
389 let rho = (r * r + l * l) / (2.0 * r);
390 (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
391 }
392 }
393 }
394
395 fn nose_volume_properties(&self) -> (f64, f64) {
398 const N: usize = 1024;
399 let l = self.nose.length_m;
400 let dx = l / N as f64;
401 let mut volume = 0.0; let mut moment = 0.0; for i in 0..N {
404 let x = (i as f64 + 0.5) * dx;
405 let r2 = self.nose_radius(x).powi(2);
406 volume += r2 * dx;
407 moment += x * r2 * dx;
408 }
409 let cg = moment / volume;
410 let mut inertia = 0.0; for i in 0..N {
412 let x = (i as f64 + 0.5) * dx;
413 let r2 = self.nose_radius(x).powi(2);
414 inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
415 }
416 (cg, inertia / volume)
417 }
418
419 fn fin_planform_cg(&self) -> f64 {
421 let (cr, ct, m) = (
422 self.fins.root_chord_m,
423 self.fins.tip_chord_m,
424 self.fins.leading_sweep_m,
425 );
426 (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct))
427 }
428
429 fn fin_radial_cg(&self) -> f64 {
431 let (cr, ct, s) = (
432 self.fins.root_chord_m,
433 self.fins.tip_chord_m,
434 self.fins.span_m,
435 );
436 s * (cr + 2.0 * ct) / (3.0 * (cr + ct))
437 }
438
439 pub fn mass_properties(
447 &self,
448 nose_kg: f64,
449 tube_kg: f64,
450 fins_kg: f64,
451 casing: Option<(f64, f64)>,
452 ) -> MassProperties {
453 assert!(
454 nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
455 "component masses must be non-negative"
456 );
457 let (nose_cg, nose_inertia) = self.nose_volume_properties();
458 let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
459 let fin_cg = self.total_length_m() - self.fins.root_chord_m + self.fin_planform_cg();
460 let mut parts: Vec<(f64, f64, f64)> = vec![
461 (nose_kg, nose_cg, nose_kg * nose_inertia),
462 (
463 tube_kg,
464 tube_cg,
465 tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
466 ),
467 (fins_kg, fin_cg, 0.0),
468 ];
469 if let Some((kg, cg)) = casing {
470 parts.push((kg, cg, 0.0));
471 }
472 let total: f64 = parts.iter().map(|p| p.0).sum();
473 assert!(total > 0.0, "component masses must sum to a positive mass");
474 let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
475 let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
476 inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
477 MassProperties {
478 dry_mass_kg: total,
479 dry_cg_m: cg,
480 pitch_inertia_kg_m2: inertia,
481 }
482 }
483
484 fn total_cp(&self, mach: f64) -> f64 {
486 let nose_cna = 2.0;
487 let fin_cna = self.fin_set_cna(mach);
488 (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
489 + fin_cna * self.fin_cp_abs(mach))
490 / (nose_cna + fin_cna)
491 }
492
493 #[cfg(test)]
494 fn nose_cp(&self) -> f64 {
495 self.nose.length_m * self.nose.shape.cp_fraction()
496 }
497
498 fn base_cd(mach: f64) -> f64 {
499 if mach <= 1.0 {
500 0.12 + 0.13 * mach * mach
501 } else {
502 0.25 / mach
503 }
504 }
505
506 fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
507 let length = self.total_length_m();
508 let velocity = mach * sound_mps;
509 let reynolds = velocity * length / 1.5e-5;
510 let rough_ratio = self.roughness_m / length;
511 let cf_turbulent = if reynolds < 1.0e4 {
512 1.48e-2
513 } else {
514 1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
515 };
516 let cf_rough = 0.032 * rough_ratio.powf(0.2);
517 let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
520 let r = self.max_diameter_m() / 2.0;
521 let fineness = length / r;
522 let correction = 1.0 + 1.0 / (2.0 * fineness);
523 let body_wet = self
524 .nose
525 .shape
526 .lateral_area(self.nose.length_m, self.nose.diameter_m)
527 + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m;
528 let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
529 let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
530 cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
531 }
532
533 pub fn analyze(&self) -> AeroAnalysis {
537 self.validate();
538 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];
539 let mut cd_vs_mach = Vec::new();
540 let mut cna_vs_mach = Vec::new();
541 let mut cp_vs_mach = Vec::new();
542 let mut induced_k_vs_mach = Vec::new();
543 for &m in &machs {
544 cd_vs_mach.push((
545 m,
546 Self::base_cd(m)
547 + self.nose_pressure_cd(m)
548 + self.fin_pressure_cd(m)
549 + self.friction_cd(m, 340.0),
550 ));
551 let fin_cna = self.fin_set_cna(m);
552 cna_vs_mach.push((m, 2.0 + fin_cna));
553 cp_vs_mach.push((m, self.total_cp(m)));
554 induced_k_vs_mach.push((m, 2.0 + fin_cna));
557 }
558 AeroAnalysis {
559 cd_vs_mach,
560 cna_vs_mach,
561 cp_vs_mach,
562 induced_k_vs_mach,
563 ref_area_m2: self.ref_area_m2(),
564 total_length_m: self.total_length_m(),
565 max_diameter_m: self.max_diameter_m(),
566 }
567 }
568
569 fn validate(&self) {
570 assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
571 assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
572 assert!(self.fins.count > 0);
573 assert!(self.fins.span_m > 0.0);
574 assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
575 assert!(self.fins.thickness_m > 0.0);
576 }
577}
578
579pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
583 let mut nose = None;
584 let mut tube = None;
585 let mut fins = None;
586 for item in spec.split(',') {
587 let (key, val) = item
588 .split_once('=')
589 .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
590 let parts: Vec<&str> = val.split(':').collect();
591 let number = |i: usize| -> Result<f64> {
592 parts
593 .get(i)
594 .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
595 .parse()
596 .map_err(|_| Error::invalid("geometry", "non-numeric field"))
597 };
598 match key {
599 "nose" => {
600 if parts.len() != 3 {
601 return Err(Error::invalid("nose", "expected shape:length:diameter"));
602 }
603 let shape = parts[0].parse::<NoseShape>()?;
604 nose = Some(NoseCone {
605 shape,
606 length_m: number(1)?,
607 diameter_m: number(2)?,
608 });
609 }
610 "tube" => {
611 if parts.len() != 2 {
612 return Err(Error::invalid("tube", "expected length:diameter"));
613 }
614 tube = Some(BodyTube {
615 length_m: number(0)?,
616 diameter_m: number(1)?,
617 });
618 }
619 "fins" => {
620 if !(5..=6).contains(&parts.len()) {
621 return Err(Error::invalid(
622 "fins",
623 "expected count:root:tip:span:sweep[:thickness]",
624 ));
625 }
626 let count: u32 = parts[0]
627 .parse()
628 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
629 fins = Some(TrapezoidalFins {
630 count,
631 root_chord_m: number(1)?,
632 tip_chord_m: number(2)?,
633 span_m: number(3)?,
634 leading_sweep_m: number(4)?,
635 thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
636 });
637 }
638 other => {
639 return Err(Error::invalid(
640 "geometry key",
641 match other {
642 "nose" | "tube" | "fins" => unreachable!(),
643 _ => "must be nose, tube or fins",
644 },
645 ))
646 }
647 }
648 }
649 Ok(RocketGeometry::new(
650 nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
651 tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
652 fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
653 ))
654}
655
656#[derive(Debug, Clone, Copy)]
659pub struct MassProperties {
660 pub dry_mass_kg: f64,
662 pub dry_cg_m: f64,
664 pub pitch_inertia_kg_m2: f64,
666}
667
668#[derive(Debug, Clone)]
670pub struct AeroAnalysis {
671 pub cd_vs_mach: Vec<(f64, f64)>,
673 pub cna_vs_mach: Vec<(f64, f64)>,
675 pub cp_vs_mach: Vec<(f64, f64)>,
677 pub induced_k_vs_mach: Vec<(f64, f64)>,
679 pub ref_area_m2: f64,
681 pub total_length_m: f64,
683 pub max_diameter_m: f64,
685}
686
687#[cfg(feature = "ballistics")]
688impl AeroAnalysis {
689 pub fn airframe(
691 &self,
692 cg_dry_m: f64,
693 cg_prop_m: f64,
694 pitch_inertia_kg_m2: f64,
695 recovery_cd_area_m2: f64,
696 ) -> crate::Airframe {
697 crate::Airframe {
698 diameter_m: self.max_diameter_m,
699 cd_vs_mach: self.cd_vs_mach.clone(),
700 cna_vs_mach: self.cna_vs_mach.clone(),
701 cp_vs_mach: self.cp_vs_mach.clone(),
702 cg_dry_m,
703 cg_prop_m,
704 pitch_inertia_kg_m2,
705 induced_k_vs_mach: self.induced_k_vs_mach.clone(),
706 recovery_cd_area_m2,
707 }
708 }
709
710 pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
712 crate::ballistics::BallisticForces::new(
713 self.max_diameter_m,
714 self.cd_vs_mach.clone(),
715 crate::ballistics::Atmosphere::default(),
716 wind_mps,
717 )?
718 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
719 }
720
721 pub fn weathercock_params(
723 &self,
724 cg_dry_m: f64,
725 cg_prop_m: f64,
726 pitch_inertia_kg_m2: f64,
727 ) -> Result<crate::WeathercockParams> {
728 crate::WeathercockParams::new(
729 self.cna_vs_mach.clone(),
730 self.cp_vs_mach.clone(),
731 cg_dry_m,
732 cg_prop_m,
733 self.max_diameter_m,
734 pitch_inertia_kg_m2,
735 0.2,
736 )
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 fn example() -> RocketGeometry {
745 RocketGeometry::new(
746 NoseCone {
747 shape: NoseShape::Conical,
748 length_m: 0.1,
749 diameter_m: 0.024,
750 },
751 BodyTube {
752 length_m: 0.4,
753 diameter_m: 0.024,
754 },
755 TrapezoidalFins {
756 count: 3,
757 root_chord_m: 0.05,
758 tip_chord_m: 0.03,
759 span_m: 0.04,
760 leading_sweep_m: 0.02,
761 thickness_m: 0.002,
762 },
763 )
764 }
765
766 #[test]
767 fn nose_cp_fractions_match_barrowman() {
768 assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
769 assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
770 assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
771 assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
772 }
773
774 #[test]
775 fn conical_pressure_drag_uses_half_angle() {
776 let geo = example(); let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
779 assert!((geo.nose_pressure_cd(0.0) - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
780 }
781
782 #[test]
783 fn nose_wave_drag_matches_openrocket_formula() {
784 let geo = example(); let r = 0.012_f64;
786 let sinphi = r / (r * r + 0.1 * 0.1).sqrt();
787 let m = 1.5_f64;
789 let expected = 2.1 * sinphi * sinphi + 0.5 * sinphi / (m * m - 1.0).sqrt();
790 assert!(
791 (geo.nose_pressure_cd(m) - expected).abs() < 1e-12,
792 "{} vs {expected}",
793 geo.nose_pressure_cd(m)
794 );
795 let below = geo.nose_pressure_cd(0.999);
797 let at = geo.nose_pressure_cd(1.0);
798 assert!((at - sinphi).abs() < 0.02, "at {at}");
799 assert!(below < at && below > 0.8 * sinphi * sinphi);
800 }
801
802 #[test]
803 fn fin_cp_matches_the_trapezoid_formula() {
804 let geo = example();
805 let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
807 let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
808 let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
809 assert!(
810 (geo.fin_cp(0.0) - expected).abs() < 1e-12,
811 "{}",
812 geo.fin_cp(0.0)
813 );
814 }
815
816 #[test]
817 fn fin_cp_moves_aft_supersonic() {
818 let geo = example();
819 assert_eq!(geo.fin_cp_fraction(0.0), 0.25);
820 assert_eq!(geo.fin_cp_fraction(0.5), 0.25);
821 let ar = 2.0 * 0.04_f64.powi(2) / geo.fin_area();
823 let beta = (4.0_f64 - 1.0).sqrt();
824 let expected = (ar * beta - 0.67) / (2.0 * ar * beta - 1.0);
825 assert!((geo.fin_cp_fraction(2.0) - expected).abs() < 1e-12);
826 assert!(geo.fin_cp_fraction(1.0) > 0.25 && geo.fin_cp_fraction(1.0) < expected);
827 }
828
829 #[test]
830 fn conical_nose_volume_properties_match_the_solid_cone() {
831 let geo = example(); let (cg, inertia) = geo.nose_volume_properties();
833 assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
834 let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
835 assert!(
836 (inertia - expected).abs() / expected < 1e-4,
837 "inertia {inertia} expected {expected}"
838 );
839 }
840
841 #[test]
842 fn fin_centroid_matches_polygon_limits() {
843 let geo = example();
844 let rectangular = RocketGeometry::new(
846 geo.nose,
847 geo.tube,
848 TrapezoidalFins {
849 count: 3,
850 root_chord_m: 0.05,
851 tip_chord_m: 0.05,
852 span_m: 0.04,
853 leading_sweep_m: 0.0,
854 thickness_m: 0.002,
855 },
856 );
857 assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
858 assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
859 let triangular = RocketGeometry::new(
861 geo.nose,
862 geo.tube,
863 TrapezoidalFins {
864 count: 3,
865 root_chord_m: 0.06,
866 tip_chord_m: 0.0,
867 span_m: 0.03,
868 leading_sweep_m: 0.03,
869 thickness_m: 0.002,
870 },
871 );
872 assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
873 assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
874 }
875
876 #[test]
877 fn mass_properties_are_consistent() {
878 let geo = example();
879 let props = geo.mass_properties(0.012, 0.020, 0.006, None);
880 assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
881 let (nose_cg, _) = geo.nose_volume_properties();
883 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
884 let fin_cg = geo.total_length_m() - geo.fins.root_chord_m + geo.fin_planform_cg();
885 let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
886 assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
887 assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
888 assert!(props.pitch_inertia_kg_m2 > 0.0);
889 let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
891 assert!(with_casing.dry_cg_m > props.dry_cg_m);
892 assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
893 assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
894 let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
896 assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
897 }
898
899 #[test]
900 fn induced_k_tracks_cna() {
901 let geo = example();
903 let analysis = geo.analyze();
904 for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
905 assert_eq!(k, cna, "mach {m}");
906 }
907 }
908
909 #[test]
910 fn mass_properties_single_component_is_exact() {
911 let geo = example();
912 let props = geo.mass_properties(0.0, 0.05, 0.0, None);
913 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
915 assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
916 let expected =
917 0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
918 assert!(
919 (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
920 "{} vs {expected}",
921 props.pitch_inertia_kg_m2
922 );
923 }
924
925 #[test]
926 fn fin_set_cna_matches_diederich() {
927 let geo = example();
928 let area = geo.fin_area();
931 let cos = geo.midchord_cos();
932 let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
933 let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
934 / (1.0 + (1.0 + ratio * ratio).sqrt())
935 / geo.ref_area_m2();
936 let expected = 1.5 * one * (1.0 + geo.interference_tau());
937 let actual = geo.fin_set_cna(0.0);
938 assert!(
939 (actual - expected).abs() / expected < 1e-9,
940 "actual {actual} expected {expected}"
941 );
942 let d = geo.max_diameter_m();
944 let classic = 4.0 * 3.0 * (geo.fins.span_m / d).powi(2)
945 / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
946 * (1.0 + geo.interference_tau());
947 assert!(
948 (actual - classic).abs() / classic < 0.02,
949 "actual {actual} classic {classic}"
950 );
951 }
952
953 #[test]
954 fn total_cp_is_the_moment_sum() {
955 let geo = example();
956 let fin_cna = geo.fin_set_cna(0.0);
957 let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs(0.0)) / (2.0 + fin_cna);
958 assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
959 assert!(geo.total_cp(0.0) > geo.nose_cp());
961 }
962
963 #[test]
964 fn base_cd_matches_openrocket() {
965 assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
966 assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
967 assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
968 }
969
970 #[test]
971 fn analysis_produces_finite_curves_and_roughness_costs_drag() {
972 let geo = example();
973 let analysis = geo.analyze();
974 for &(m, cd) in &analysis.cd_vs_mach {
975 assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
976 }
977 for &(m, cp) in &analysis.cp_vs_mach {
978 assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
979 }
980 let smooth = geo.with_roughness(1e-6).analyze();
981 let rough = geo.with_roughness(500e-6).analyze();
982 assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
983 }
984}