1use crate::{Error, Result};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum NoseShape {
16 Conical,
18 Ogive,
20 Elliptical,
22 Parabolic,
24}
25
26impl std::str::FromStr for NoseShape {
27 type Err = Error;
28
29 fn from_str(s: &str) -> Result<Self> {
30 match s {
31 "conical" | "cone" => Ok(Self::Conical),
32 "ogive" => Ok(Self::Ogive),
33 "elliptical" | "ellipse" => Ok(Self::Elliptical),
34 "parabolic" => Ok(Self::Parabolic),
35 _ => Err(Error::invalid(
36 "nose shape",
37 "conical, ogive, elliptical or parabolic",
38 )),
39 }
40 }
41}
42
43impl NoseShape {
44 fn cp_fraction(self) -> f64 {
47 match self {
48 Self::Conical => 2.0 / 3.0,
49 Self::Ogive => 0.466,
50 Self::Elliptical => 1.0 / 3.0,
51 Self::Parabolic => 0.5,
52 }
53 }
54
55 fn pressure_cd(self, length_m: f64, diameter_m: f64) -> f64 {
59 match self {
60 Self::Conical => {
61 let r = diameter_m / 2.0;
62 let sin_phi = r / (r * r + length_m * length_m).sqrt();
63 0.8 * sin_phi * sin_phi
64 }
65 _ => 0.0,
66 }
67 }
68
69 fn lateral_area(self, length_m: f64, diameter_m: f64) -> f64 {
72 let r = diameter_m / 2.0;
73 match self {
74 Self::Conical => std::f64::consts::PI * r * (r * r + length_m * length_m).sqrt(),
75 _ => std::f64::consts::PI * diameter_m * length_m,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy)]
82pub struct NoseCone {
83 pub shape: NoseShape,
85 pub length_m: f64,
87 pub diameter_m: f64,
89}
90
91#[derive(Debug, Clone, Copy)]
93pub struct BodyTube {
94 pub length_m: f64,
96 pub diameter_m: f64,
98}
99
100#[derive(Debug, Clone, Copy)]
103pub struct TrapezoidalFins {
104 pub count: u32,
106 pub root_chord_m: f64,
108 pub tip_chord_m: f64,
110 pub span_m: f64,
112 pub leading_sweep_m: f64,
114 pub thickness_m: f64,
116}
117
118#[derive(Debug, Clone, Copy)]
120pub struct RocketGeometry {
121 pub nose: NoseCone,
123 pub tube: BodyTube,
125 pub fins: TrapezoidalFins,
127 pub roughness_m: f64,
129}
130
131impl RocketGeometry {
132 pub fn new(nose: NoseCone, tube: BodyTube, fins: TrapezoidalFins) -> Self {
134 Self {
135 nose,
136 tube,
137 fins,
138 roughness_m: 20e-6,
139 }
140 }
141
142 pub fn with_roughness(mut self, roughness_m: f64) -> Self {
144 self.roughness_m = roughness_m;
145 self
146 }
147
148 pub fn total_length_m(&self) -> f64 {
150 self.nose.length_m + self.tube.length_m
151 }
152
153 pub fn max_diameter_m(&self) -> f64 {
155 self.nose.diameter_m.max(self.tube.diameter_m)
156 }
157
158 pub fn ref_area_m2(&self) -> f64 {
160 std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
161 }
162
163 fn fin_area(&self) -> f64 {
164 self.fins.span_m * (self.fins.root_chord_m + self.fins.tip_chord_m) / 2.0
165 }
166
167 fn mac(&self) -> f64 {
169 let (cr, ct) = (self.fins.root_chord_m, self.fins.tip_chord_m);
170 (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct))
171 }
172
173 fn fin_cp(&self) -> f64 {
175 let (cr, ct, s, m) = (
176 self.fins.root_chord_m,
177 self.fins.tip_chord_m,
178 self.fins.span_m,
179 self.fins.leading_sweep_m,
180 );
181 let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
182 let x_mac_le = m * y_mac / s;
183 x_mac_le + 0.25 * self.mac()
184 }
185
186 fn midchord_cos(&self) -> f64 {
187 let (cr, ct, s, m) = (
188 self.fins.root_chord_m,
189 self.fins.tip_chord_m,
190 self.fins.span_m,
191 self.fins.leading_sweep_m,
192 );
193 let gamma = ((m + ct / 2.0 - cr / 2.0) / s).atan();
194 gamma.cos()
195 }
196
197 fn fin_cna1(&self, mach: f64) -> f64 {
200 let span = self.fins.span_m;
201 let area = self.fin_area();
202 let cos = self.midchord_cos();
203 if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
204 return 0.0;
205 }
206 let ratio = (span * span) / (area * cos);
207 2.0 * std::f64::consts::PI * span * span
208 / (1.0 + (1.0 + (1.0 - mach * mach) * ratio * ratio).sqrt())
209 / self.ref_area_m2()
210 }
211
212 fn interference_tau(&self) -> f64 {
214 let r = self.max_diameter_m() / 2.0;
215 r / (self.fins.span_m + r)
216 }
217
218 fn fin_count_factor(&self) -> f64 {
219 match self.fins.count {
220 5 => 0.948,
221 6 => 0.913,
222 7 => 0.854,
223 8 => 0.810,
224 n if n >= 9 => 0.75,
225 _ => 1.0,
226 }
227 }
228
229 fn fin_set_cna(&self, mach: f64) -> f64 {
237 self.fin_cna1(mach)
238 * (1.0 + self.interference_tau())
239 * self.fin_count_factor()
240 * (self.fins.count as f64 / 2.0)
241 }
242
243 fn fin_cp_abs(&self) -> f64 {
245 self.total_length_m() - self.fins.root_chord_m + self.fin_cp()
246 }
247
248 fn nose_radius(&self, x: f64) -> f64 {
250 let l = self.nose.length_m;
251 let r = self.nose.diameter_m / 2.0;
252 let t = (x / l).clamp(0.0, 1.0);
253 match self.nose.shape {
254 NoseShape::Conical => r * t,
255 NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
256 NoseShape::Parabolic => r * t * (2.0 - t),
257 NoseShape::Ogive => {
258 let rho = (r * r + l * l) / (2.0 * r);
259 (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
260 }
261 }
262 }
263
264 fn nose_volume_properties(&self) -> (f64, f64) {
267 const N: usize = 1024;
268 let l = self.nose.length_m;
269 let dx = l / N as f64;
270 let mut volume = 0.0; let mut moment = 0.0; for i in 0..N {
273 let x = (i as f64 + 0.5) * dx;
274 let r2 = self.nose_radius(x).powi(2);
275 volume += r2 * dx;
276 moment += x * r2 * dx;
277 }
278 let cg = moment / volume;
279 let mut inertia = 0.0; for i in 0..N {
281 let x = (i as f64 + 0.5) * dx;
282 let r2 = self.nose_radius(x).powi(2);
283 inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
284 }
285 (cg, inertia / volume)
286 }
287
288 fn fin_planform_cg(&self) -> f64 {
290 let (cr, ct, m) = (
291 self.fins.root_chord_m,
292 self.fins.tip_chord_m,
293 self.fins.leading_sweep_m,
294 );
295 (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct))
296 }
297
298 fn fin_radial_cg(&self) -> f64 {
300 let (cr, ct, s) = (
301 self.fins.root_chord_m,
302 self.fins.tip_chord_m,
303 self.fins.span_m,
304 );
305 s * (cr + 2.0 * ct) / (3.0 * (cr + ct))
306 }
307
308 pub fn mass_properties(
316 &self,
317 nose_kg: f64,
318 tube_kg: f64,
319 fins_kg: f64,
320 casing: Option<(f64, f64)>,
321 ) -> MassProperties {
322 assert!(
323 nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
324 "component masses must be non-negative"
325 );
326 let (nose_cg, nose_inertia) = self.nose_volume_properties();
327 let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
328 let fin_cg = self.total_length_m() - self.fins.root_chord_m + self.fin_planform_cg();
329 let mut parts: Vec<(f64, f64, f64)> = vec![
330 (nose_kg, nose_cg, nose_kg * nose_inertia),
331 (
332 tube_kg,
333 tube_cg,
334 tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
335 ),
336 (fins_kg, fin_cg, 0.0),
337 ];
338 if let Some((kg, cg)) = casing {
339 parts.push((kg, cg, 0.0));
340 }
341 let total: f64 = parts.iter().map(|p| p.0).sum();
342 assert!(total > 0.0, "component masses must sum to a positive mass");
343 let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
344 let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
345 inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
346 MassProperties {
347 dry_mass_kg: total,
348 dry_cg_m: cg,
349 pitch_inertia_kg_m2: inertia,
350 }
351 }
352
353 fn total_cp(&self, mach: f64) -> f64 {
355 let nose_cna = 2.0;
356 let fin_cna = self.fin_set_cna(mach);
357 (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
358 + fin_cna * self.fin_cp_abs())
359 / (nose_cna + fin_cna)
360 }
361
362 #[cfg(test)]
363 fn nose_cp(&self) -> f64 {
364 self.nose.length_m * self.nose.shape.cp_fraction()
365 }
366
367 fn base_cd(mach: f64) -> f64 {
368 if mach <= 1.0 {
369 0.12 + 0.13 * mach * mach
370 } else {
371 0.25 / mach
372 }
373 }
374
375 fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
376 let length = self.total_length_m();
377 let velocity = mach * sound_mps;
378 let reynolds = velocity * length / 1.5e-5;
379 let rough_ratio = self.roughness_m / length;
380 let cf_turbulent = if reynolds < 1.0e4 {
381 1.48e-2
382 } else {
383 1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
384 };
385 let cf_rough = 0.032 * rough_ratio.powf(0.2);
386 let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
389 let r = self.max_diameter_m() / 2.0;
390 let fineness = length / r;
391 let correction = 1.0 + 1.0 / (2.0 * fineness);
392 let body_wet = self
393 .nose
394 .shape
395 .lateral_area(self.nose.length_m, self.nose.diameter_m)
396 + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m;
397 let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
398 let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
399 cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
400 }
401
402 pub fn analyze(&self) -> AeroAnalysis {
405 self.validate();
406 let machs: [f64; 7] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 3.0];
407 let mut cd_vs_mach = Vec::new();
408 let mut cna_vs_mach = Vec::new();
409 let mut cp_vs_mach = Vec::new();
410 let mut induced_k_vs_mach = Vec::new();
411 for &m in &machs {
412 let effective = m.min(1.0);
413 cd_vs_mach.push((
414 m,
415 Self::base_cd(effective)
416 + self
417 .nose
418 .shape
419 .pressure_cd(self.nose.length_m, self.nose.diameter_m)
420 + self.friction_cd(effective, 340.0),
421 ));
422 let fin_cna = self.fin_set_cna(effective);
423 cna_vs_mach.push((m, 2.0 + fin_cna));
424 cp_vs_mach.push((m, self.total_cp(effective)));
425 induced_k_vs_mach.push((m, 2.0 + fin_cna));
428 }
429 AeroAnalysis {
430 cd_vs_mach,
431 cna_vs_mach,
432 cp_vs_mach,
433 induced_k_vs_mach,
434 ref_area_m2: self.ref_area_m2(),
435 total_length_m: self.total_length_m(),
436 max_diameter_m: self.max_diameter_m(),
437 }
438 }
439
440 fn validate(&self) {
441 assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
442 assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
443 assert!(self.fins.count > 0);
444 assert!(self.fins.span_m > 0.0);
445 assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
446 assert!(self.fins.thickness_m > 0.0);
447 }
448}
449
450pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
454 let mut nose = None;
455 let mut tube = None;
456 let mut fins = None;
457 for item in spec.split(',') {
458 let (key, val) = item
459 .split_once('=')
460 .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
461 let parts: Vec<&str> = val.split(':').collect();
462 let number = |i: usize| -> Result<f64> {
463 parts
464 .get(i)
465 .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
466 .parse()
467 .map_err(|_| Error::invalid("geometry", "non-numeric field"))
468 };
469 match key {
470 "nose" => {
471 if parts.len() != 3 {
472 return Err(Error::invalid("nose", "expected shape:length:diameter"));
473 }
474 let shape = parts[0].parse::<NoseShape>()?;
475 nose = Some(NoseCone {
476 shape,
477 length_m: number(1)?,
478 diameter_m: number(2)?,
479 });
480 }
481 "tube" => {
482 if parts.len() != 2 {
483 return Err(Error::invalid("tube", "expected length:diameter"));
484 }
485 tube = Some(BodyTube {
486 length_m: number(0)?,
487 diameter_m: number(1)?,
488 });
489 }
490 "fins" => {
491 if !(5..=6).contains(&parts.len()) {
492 return Err(Error::invalid(
493 "fins",
494 "expected count:root:tip:span:sweep[:thickness]",
495 ));
496 }
497 let count: u32 = parts[0]
498 .parse()
499 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
500 fins = Some(TrapezoidalFins {
501 count,
502 root_chord_m: number(1)?,
503 tip_chord_m: number(2)?,
504 span_m: number(3)?,
505 leading_sweep_m: number(4)?,
506 thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
507 });
508 }
509 other => {
510 return Err(Error::invalid(
511 "geometry key",
512 match other {
513 "nose" | "tube" | "fins" => unreachable!(),
514 _ => "must be nose, tube or fins",
515 },
516 ))
517 }
518 }
519 }
520 Ok(RocketGeometry::new(
521 nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
522 tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
523 fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
524 ))
525}
526
527#[derive(Debug, Clone, Copy)]
530pub struct MassProperties {
531 pub dry_mass_kg: f64,
533 pub dry_cg_m: f64,
535 pub pitch_inertia_kg_m2: f64,
537}
538
539#[derive(Debug, Clone)]
541pub struct AeroAnalysis {
542 pub cd_vs_mach: Vec<(f64, f64)>,
544 pub cna_vs_mach: Vec<(f64, f64)>,
546 pub cp_vs_mach: Vec<(f64, f64)>,
548 pub induced_k_vs_mach: Vec<(f64, f64)>,
550 pub ref_area_m2: f64,
552 pub total_length_m: f64,
554 pub max_diameter_m: f64,
556}
557
558#[cfg(feature = "ballistics")]
559impl AeroAnalysis {
560 pub fn airframe(
562 &self,
563 cg_dry_m: f64,
564 cg_prop_m: f64,
565 pitch_inertia_kg_m2: f64,
566 recovery_cd_area_m2: f64,
567 ) -> crate::Airframe {
568 crate::Airframe {
569 diameter_m: self.max_diameter_m,
570 cd_vs_mach: self.cd_vs_mach.clone(),
571 cna_vs_mach: self.cna_vs_mach.clone(),
572 cp_vs_mach: self.cp_vs_mach.clone(),
573 cg_dry_m,
574 cg_prop_m,
575 pitch_inertia_kg_m2,
576 induced_k_vs_mach: self.induced_k_vs_mach.clone(),
577 recovery_cd_area_m2,
578 }
579 }
580
581 pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
583 crate::ballistics::BallisticForces::new(
584 self.max_diameter_m,
585 self.cd_vs_mach.clone(),
586 crate::ballistics::Atmosphere::default(),
587 wind_mps,
588 )?
589 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
590 }
591
592 pub fn weathercock_params(
594 &self,
595 cg_dry_m: f64,
596 cg_prop_m: f64,
597 pitch_inertia_kg_m2: f64,
598 ) -> Result<crate::WeathercockParams> {
599 crate::WeathercockParams::new(
600 self.cna_vs_mach.clone(),
601 self.cp_vs_mach.clone(),
602 cg_dry_m,
603 cg_prop_m,
604 self.max_diameter_m,
605 pitch_inertia_kg_m2,
606 0.2,
607 )
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 fn example() -> RocketGeometry {
616 RocketGeometry::new(
617 NoseCone {
618 shape: NoseShape::Conical,
619 length_m: 0.1,
620 diameter_m: 0.024,
621 },
622 BodyTube {
623 length_m: 0.4,
624 diameter_m: 0.024,
625 },
626 TrapezoidalFins {
627 count: 3,
628 root_chord_m: 0.05,
629 tip_chord_m: 0.03,
630 span_m: 0.04,
631 leading_sweep_m: 0.02,
632 thickness_m: 0.002,
633 },
634 )
635 }
636
637 #[test]
638 fn nose_cp_fractions_match_barrowman() {
639 assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
640 assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
641 assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
642 assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
643 }
644
645 #[test]
646 fn conical_pressure_drag_uses_half_angle() {
647 let cd = NoseShape::Conical.pressure_cd(0.1, 0.024);
648 let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
649 assert!((cd - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
650 assert_eq!(NoseShape::Ogive.pressure_cd(0.1, 0.024), 0.0);
651 }
652
653 #[test]
654 fn fin_cp_matches_the_trapezoid_formula() {
655 let geo = example();
656 let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
658 let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
659 let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
660 assert!((geo.fin_cp() - expected).abs() < 1e-12, "{}", geo.fin_cp());
661 }
662
663 #[test]
664 fn conical_nose_volume_properties_match_the_solid_cone() {
665 let geo = example(); let (cg, inertia) = geo.nose_volume_properties();
667 assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
668 let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
669 assert!(
670 (inertia - expected).abs() / expected < 1e-4,
671 "inertia {inertia} expected {expected}"
672 );
673 }
674
675 #[test]
676 fn fin_centroid_matches_polygon_limits() {
677 let geo = example();
678 let rectangular = RocketGeometry::new(
680 geo.nose,
681 geo.tube,
682 TrapezoidalFins {
683 count: 3,
684 root_chord_m: 0.05,
685 tip_chord_m: 0.05,
686 span_m: 0.04,
687 leading_sweep_m: 0.0,
688 thickness_m: 0.002,
689 },
690 );
691 assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
692 assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
693 let triangular = RocketGeometry::new(
695 geo.nose,
696 geo.tube,
697 TrapezoidalFins {
698 count: 3,
699 root_chord_m: 0.06,
700 tip_chord_m: 0.0,
701 span_m: 0.03,
702 leading_sweep_m: 0.03,
703 thickness_m: 0.002,
704 },
705 );
706 assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
707 assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
708 }
709
710 #[test]
711 fn mass_properties_are_consistent() {
712 let geo = example();
713 let props = geo.mass_properties(0.012, 0.020, 0.006, None);
714 assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
715 let (nose_cg, _) = geo.nose_volume_properties();
717 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
718 let fin_cg = geo.total_length_m() - geo.fins.root_chord_m + geo.fin_planform_cg();
719 let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
720 assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
721 assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
722 assert!(props.pitch_inertia_kg_m2 > 0.0);
723 let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
725 assert!(with_casing.dry_cg_m > props.dry_cg_m);
726 assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
727 assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
728 let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
730 assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
731 }
732
733 #[test]
734 fn induced_k_tracks_cna() {
735 let geo = example();
737 let analysis = geo.analyze();
738 for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
739 assert_eq!(k, cna, "mach {m}");
740 }
741 }
742
743 #[test]
744 fn mass_properties_single_component_is_exact() {
745 let geo = example();
746 let props = geo.mass_properties(0.0, 0.05, 0.0, None);
747 let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
749 assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
750 let expected =
751 0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
752 assert!(
753 (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
754 "{} vs {expected}",
755 props.pitch_inertia_kg_m2
756 );
757 }
758
759 #[test]
760 fn fin_set_cna_matches_diederich() {
761 let geo = example();
762 let area = geo.fin_area();
765 let cos = geo.midchord_cos();
766 let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
767 let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
768 / (1.0 + (1.0 + ratio * ratio).sqrt())
769 / geo.ref_area_m2();
770 let expected = 1.5 * one * (1.0 + geo.interference_tau());
771 let actual = geo.fin_set_cna(0.0);
772 assert!(
773 (actual - expected).abs() / expected < 1e-9,
774 "actual {actual} expected {expected}"
775 );
776 let d = geo.max_diameter_m();
778 let classic = 4.0 * 3.0 * (geo.fins.span_m / d).powi(2)
779 / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
780 * (1.0 + geo.interference_tau());
781 assert!(
782 (actual - classic).abs() / classic < 0.02,
783 "actual {actual} classic {classic}"
784 );
785 }
786
787 #[test]
788 fn total_cp_is_the_moment_sum() {
789 let geo = example();
790 let fin_cna = geo.fin_set_cna(0.0);
791 let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs()) / (2.0 + fin_cna);
792 assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
793 assert!(geo.total_cp(0.0) > geo.nose_cp());
795 }
796
797 #[test]
798 fn base_cd_matches_openrocket() {
799 assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
800 assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
801 assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
802 }
803
804 #[test]
805 fn analysis_produces_finite_curves_and_roughness_costs_drag() {
806 let geo = example();
807 let analysis = geo.analyze();
808 for &(m, cd) in &analysis.cd_vs_mach {
809 assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
810 }
811 for &(m, cp) in &analysis.cp_vs_mach {
812 assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
813 }
814 let smooth = geo.with_roughness(1e-6).analyze();
815 let rough = geo.with_roughness(500e-6).analyze();
816 assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
817 }
818}