1use hpr_core::interp::Lookup;
38use hpr_design::{
39 FinCrossSection, FinSet, LaunchLug, NoseShape, PlacedComponent, RailButton, TubeFinSet,
40};
41use serde::{Deserialize, Serialize};
42
43use crate::afterbody::Boattail;
44use crate::body::BodyGeometry;
45use crate::error::{AeroError, check_dimension, check_mach};
46use crate::fins::FinGeometry;
47use crate::nose_drag::PressureDragCurve;
48use crate::tube_fins::TUBE_FIN_MACH_LIMIT;
49
50pub const LOW_REYNOLDS: f64 = 1.0e4;
53
54pub const LOW_REYNOLDS_FRICTION: f64 = 1.48e-2;
56
57pub const SUBSONIC_MACH_LIMIT: f64 = 0.8;
61
62pub const BUILDUP_MACH_LIMIT: f64 = 5.0;
67
68pub(crate) fn check_mach_any(mach: f64) -> Result<(), AeroError> {
70 if mach.is_finite() && mach >= 0.0 {
71 Ok(())
72 } else {
73 Err(AeroError::Domain {
74 what: "Mach number",
75 value: mach,
76 })
77 }
78}
79
80fn turbulent_friction(reynolds: f64) -> f64 {
84 if reynolds < LOW_REYNOLDS {
85 LOW_REYNOLDS_FRICTION
86 } else {
87 let d = 1.50 * reynolds.ln() - 5.6;
88 1.0 / (d * d)
89 }
90}
91
92pub fn critical_reynolds(relative_roughness: f64) -> Result<f64, AeroError> {
99 check_dimension("relative roughness", relative_roughness, true)?;
100 Ok(51.0 * relative_roughness.powf(-1.039))
101}
102
103pub fn incompressible_skin_friction(
119 reynolds: f64,
120 relative_roughness: f64,
121) -> Result<f64, AeroError> {
122 check_dimension("Reynolds number", reynolds, true)?;
123 Ok(if roughness_limited(reynolds, relative_roughness)? {
124 0.032 * relative_roughness.powf(0.2)
125 } else {
126 turbulent_friction(reynolds)
127 })
128}
129
130fn roughness_limited(reynolds: f64, relative_roughness: f64) -> Result<bool, AeroError> {
133 Ok(reynolds >= LOW_REYNOLDS && reynolds >= critical_reynolds(relative_roughness)?)
134}
135
136pub fn skin_friction_coefficient(
152 reynolds: f64,
153 relative_roughness: f64,
154 mach: f64,
155) -> Result<f64, AeroError> {
156 check_mach_any(mach)?;
157 let cf = incompressible_skin_friction(reynolds, relative_roughness)?;
158 let m2 = mach * mach;
159 if mach < 1.0 {
160 return Ok(cf * (1.0 - 0.1 * m2));
161 }
162 let turbulent = turbulent_friction(reynolds) / (1.0 + 0.15 * m2).powf(0.58);
163 Ok(if roughness_limited(reynolds, relative_roughness)? {
164 (cf / (1.0 + 0.18 * m2)).max(turbulent)
165 } else {
166 turbulent
167 })
168}
169
170pub fn body_friction_form_factor(fineness_ratio: f64) -> Result<f64, AeroError> {
177 check_dimension("body fineness ratio", fineness_ratio, false)?;
178 Ok(1.0 + 0.5 / fineness_ratio)
179}
180
181pub fn fin_friction_thickness_factor(
188 thickness_m: f64,
189 mac_length_m: f64,
190) -> Result<f64, AeroError> {
191 check_dimension("fin thickness", thickness_m, true)?;
192 check_dimension("fin mean aerodynamic chord", mac_length_m, false)?;
193 Ok(1.0 + 2.0 * thickness_m / mac_length_m)
194}
195
196pub fn stagnation_pressure_ratio(mach: f64) -> Result<f64, AeroError> {
204 check_mach_any(mach)?;
205 Ok(stagnation_ratio(mach))
206}
207
208pub(crate) fn stagnation_ratio(mach: f64) -> f64 {
210 let m2 = mach * mach;
211 if mach < 1.0 {
212 1.0 + 0.25 * m2 + m2 * m2 / 40.0
213 } else {
214 let i2 = 1.0 / m2;
215 1.84 - 0.76 * i2 + 0.166 * i2 * i2 + 0.035 * i2 * i2 * i2
216 }
217}
218
219pub fn stagnation_drag_coefficient(mach: f64) -> Result<f64, AeroError> {
226 Ok(0.85 * stagnation_pressure_ratio(mach)?)
227}
228
229pub fn base_drag_coefficient(mach: f64) -> Result<f64, AeroError> {
236 check_mach_any(mach)?;
237 Ok(if mach < 1.0 {
238 0.12 + 0.13 * mach * mach
239 } else {
240 0.25 / mach
241 })
242}
243
244pub fn joint_pressure_drag_coefficient(joint_angle_rad: f64) -> Result<f64, AeroError> {
256 if !(0.0..=std::f64::consts::FRAC_PI_2).contains(&joint_angle_rad) {
257 return Err(AeroError::Domain {
258 what: "joint angle",
259 value: joint_angle_rad,
260 });
261 }
262 let s = joint_angle_rad.sin();
263 Ok(0.8 * s * s)
264}
265
266pub fn boattail_factor(
275 length_m: f64,
276 fore_diameter_m: f64,
277 aft_diameter_m: f64,
278) -> Result<f64, AeroError> {
279 check_dimension("boattail length", length_m, true)?;
280 check_dimension("boattail aft diameter", aft_diameter_m, true)?;
281 if !(fore_diameter_m.is_finite() && fore_diameter_m > aft_diameter_m) {
282 return Err(AeroError::Domain {
283 what: "boattail fore diameter",
284 value: fore_diameter_m,
285 });
286 }
287 let gamma = length_m / (fore_diameter_m - aft_diameter_m);
288 Ok(if gamma <= 1.0 {
289 1.0
290 } else if gamma < 3.0 {
291 0.5 * (3.0 - gamma)
292 } else {
293 0.0
294 })
295}
296
297pub fn fin_pressure_drag_coefficient(
316 cross_section: FinCrossSection,
317 leading_edge_sweep_rad: f64,
318 mach: f64,
319) -> Result<f64, AeroError> {
320 check_mach_any(mach)?;
321 let half_pi = std::f64::consts::FRAC_PI_2;
322 if leading_edge_sweep_rad.is_nan() || leading_edge_sweep_rad.abs() >= half_pi {
323 return Err(AeroError::Domain {
324 what: "fin leading-edge sweep",
325 value: leading_edge_sweep_rad,
326 });
327 }
328 let rounded = || {
329 if mach < 0.9 {
330 (1.0 - mach * mach).powf(-0.417) - 1.0
331 } else if mach < 1.0 {
332 1.0 - 1.785 * (mach - 0.9)
333 } else {
334 let i2 = 1.0 / (mach * mach);
335 1.214 - 0.502 * i2 + 0.1095 * i2 * i2
336 }
337 };
338 let base = base_drag_coefficient(mach)?;
339 let (leading, trailing) = match cross_section {
340 FinCrossSection::Square => (stagnation_drag_coefficient(mach)?, base),
341 FinCrossSection::Rounded => (rounded(), 0.5 * base),
342 FinCrossSection::Airfoil => (rounded(), 0.0),
343 _ => {
345 return Err(AeroError::Unsupported(
346 "this fin cross-section (no drag model)".to_owned(),
347 ));
348 }
349 };
350 let c = leading_edge_sweep_rad.cos();
351 Ok(leading * c * c + trailing)
352}
353
354pub fn launch_lug_drag(
368 length_m: f64,
369 outer_radius_m: f64,
370 inner_radius_m: f64,
371 mach: f64,
372) -> Result<(f64, f64), AeroError> {
373 let (factor, area) = launch_lug_factor_and_area(length_m, outer_radius_m, inner_radius_m)?;
374 Ok((factor * stagnation_drag_coefficient(mach)?, area))
375}
376
377fn launch_lug_factor_and_area(
380 length_m: f64,
381 outer_radius_m: f64,
382 inner_radius_m: f64,
383) -> Result<(f64, f64), AeroError> {
384 check_dimension("launch lug length", length_m, true)?;
385 check_dimension("launch lug outer radius", outer_radius_m, false)?;
386 check_dimension("launch lug inner radius", inner_radius_m, true)?;
387 if inner_radius_m > outer_radius_m {
388 return Err(AeroError::Domain {
389 what: "launch lug inner radius",
390 value: inner_radius_m,
391 });
392 }
393 let l_over_d = length_m / (2.0 * outer_radius_m);
394 let pi = std::f64::consts::PI;
395 let area = pi * outer_radius_m * outer_radius_m
396 - pi * inner_radius_m * inner_radius_m * (1.0 - l_over_d).max(0.0);
397 Ok(((1.3 - 0.3 * l_over_d).max(1.0), area))
398}
399
400pub fn rail_button_drag_coefficient(mach: f64) -> Result<f64, AeroError> {
408 stagnation_drag_coefficient(mach)
409}
410
411pub fn axial_drag_alpha_factor(alpha_rad: f64) -> Result<f64, AeroError> {
434 if !(0.0..=std::f64::consts::PI).contains(&alpha_rad) {
435 return Err(AeroError::Domain {
436 what: "angle of attack",
437 value: alpha_rad,
438 });
439 }
440 let degrees = alpha_rad.to_degrees();
441 let (a, sign) = if degrees > 90.0 {
442 (180.0 - degrees, -1.0)
443 } else {
444 (degrees, 1.0)
445 };
446 Ok(sign
447 * if a <= 17.0 {
448 let t = a / 17.0;
449 1.0 + 0.3 * t * t * (3.0 - 2.0 * t)
450 } else {
451 let u = (a - 17.0) / 73.0;
452 1.3 * (1.0 - u * u * (3.0 - 2.0 * u))
453 })
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
458#[serde(deny_unknown_fields)]
459#[non_exhaustive]
460pub struct DragConditions {
461 pub reynolds_per_m: f64,
464 pub thrusting: bool,
466 pub thrusting_motor_area_m2: f64,
469 #[serde(default)]
474 pub thrusting_pod_motor_areas_m2: [f64; MOTOR_POD_SETS],
475}
476
477pub const MOTOR_POD_SETS: usize = 4;
481
482impl DragConditions {
483 pub fn coasting(reynolds_per_m: f64) -> Self {
485 Self {
486 reynolds_per_m,
487 thrusting: false,
488 thrusting_motor_area_m2: 0.0,
489 thrusting_pod_motor_areas_m2: [0.0; MOTOR_POD_SETS],
490 }
491 }
492
493 pub fn thrusting(reynolds_per_m: f64, motor_area_m2: f64) -> Self {
496 Self {
497 reynolds_per_m,
498 thrusting: true,
499 thrusting_motor_area_m2: motor_area_m2,
500 thrusting_pod_motor_areas_m2: [0.0; MOTOR_POD_SETS],
501 }
502 }
503
504 #[must_use]
509 pub fn with_pod_motors(mut self, pod_motor_areas_m2: [f64; MOTOR_POD_SETS]) -> Self {
510 self.thrusting_pod_motor_areas_m2 = pod_motor_areas_m2;
511 self
512 }
513
514 pub fn validate(&self) -> Result<(), AeroError> {
521 check_dimension("Reynolds number per meter", self.reynolds_per_m, true)?;
522 check_dimension("thrusting motor area", self.thrusting_motor_area_m2, true)?;
523 for area in self.thrusting_pod_motor_areas_m2 {
524 check_dimension("thrusting pod motor area", area, true)?;
525 }
526 for area in
527 std::iter::once(self.thrusting_motor_area_m2).chain(self.thrusting_pod_motor_areas_m2)
528 {
529 if !self.thrusting && area > 0.0 {
530 return Err(AeroError::Domain {
531 what: "thrusting motor area while coasting",
532 value: area,
533 });
534 }
535 }
536 Ok(())
537 }
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
543#[non_exhaustive]
544pub struct Drag {
545 pub zero_lift_coefficient: f64,
548 pub axial_coefficient: f64,
551 pub friction: f64,
553 pub pressure: f64,
555 pub base: f64,
557 pub parasitic: f64,
559 #[serde(default)]
562 pub stated: f64,
563 pub table: Option<Lookup>,
566}
567
568#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
570#[non_exhaustive]
571pub struct ComponentDrag {
572 pub id: String,
574 pub drag: Drag,
579}
580
581#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
583#[non_exhaustive]
584pub struct FinPressureTerms {
585 pub cross_section: FinCrossSection,
587 pub leading_edge_sweep_rad: f64,
589 pub frontal_area_ratio: f64,
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
595#[non_exhaustive]
596pub struct PressureDragTerm {
597 pub curve: PressureDragCurve,
599 pub area_ratio: f64,
601}
602
603#[derive(Debug, Clone, PartialEq, Serialize)]
618#[non_exhaustive]
619pub struct BoattailTerm {
620 pub own: Boattail,
622 pub own_area_ratio: f64,
624 pub merged: Vec<MergedBoattail>,
627 pub own_weight: f64,
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
633#[non_exhaustive]
634pub struct MergedBoattail {
635 pub through_aft: Boattail,
637 pub through_fore: Boattail,
639 pub area_ratio: f64,
641 pub weight: f64,
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
663#[non_exhaustive]
664pub struct WakeTerm {
665 pub boattail: Boattail,
668 pub step_fraction: f64,
670 pub shoulder_fraction: f64,
672}
673
674pub const WAKE_FULL_RISE: f64 = 0.25;
677
678pub const WAKE_NONE_RISE: f64 = 0.5;
681
682pub const MERGE_FULL_TURN_RAD: f64 = 3.0 * std::f64::consts::PI / 180.0;
685
686pub const MERGE_NONE_TURN_RAD: f64 = 10.0 * std::f64::consts::PI / 180.0;
689
690#[derive(Debug, Clone, PartialEq, Serialize)]
695#[non_exhaustive]
696pub struct BaseBehindBoattail {
697 pub sources: Vec<ReliefSource>,
699}
700
701#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
703#[non_exhaustive]
704pub struct ReliefSource {
705 pub boattail: Boattail,
708 pub area_ratio: f64,
710 pub weight: f64,
713}
714
715const STEP_LENGTH_RATIO: f64 = 1e-9;
719
720pub const MERGE_MIN_ANGLE_RAD: f64 = std::f64::consts::PI / 180.0;
724
725fn gap_weight(gap_m: f64, scale_m: f64) -> f64 {
727 if scale_m > 0.0 {
728 (1.0 - gap_m / scale_m).clamp(0.0, 1.0)
729 } else {
730 0.0
731 }
732}
733
734fn merge_fraction(angle_rad: f64, previous_angle_rad: f64) -> f64 {
740 let turn = (angle_rad - previous_angle_rad).abs();
741 let smooth = ((MERGE_NONE_TURN_RAD - turn) / (MERGE_NONE_TURN_RAD - MERGE_FULL_TURN_RAD))
742 .clamp(0.0, 1.0);
743 let (low, high) = (
744 angle_rad.min(previous_angle_rad),
745 angle_rad.max(previous_angle_rad),
746 );
747 let steep = if high > 0.0 {
748 (low / high.min(MERGE_MIN_ANGLE_RAD)).clamp(0.0, 1.0)
749 } else {
750 0.0
751 };
752 smooth * steep
753}
754
755#[derive(Clone, Copy, PartialEq)]
758struct Tail {
759 start: (f64, f64),
761 end: (f64, f64),
763 angle_rad: f64,
765 cone: Boattail,
767 fall_m: f64,
769 share: f64,
771 fade_m: f64,
774 lip: f64,
776}
777
778impl Tail {
779 fn own(cone: Boattail, fore: (f64, f64), aft: (f64, f64), share: f64) -> Self {
781 Self {
782 start: fore,
783 end: aft,
784 angle_rad: cone.half_angle_rad,
785 cone,
786 fall_m: cone.fore_diameter_m - cone.aft_diameter_m,
787 share,
788 fade_m: 0.0,
789 lip: 1.0,
790 }
791 }
792
793 fn share_by_rise(&self, top_m: f64) -> f64 {
795 let rise = 2.0 * (top_m - self.end.1) / self.fall_m;
796 ((WAKE_NONE_RISE - rise) / (WAKE_NONE_RISE - WAKE_FULL_RISE)).clamp(0.0, 1.0)
797 }
798
799 fn hold(&self) -> f64 {
801 self.share * gap_weight(self.fade_m, self.fall_m) * self.lip
802 }
803
804 fn same_as(&self, other: &Self) -> bool {
806 Self {
807 share: other.share,
808 ..*self
809 } == *other
810 }
811}
812
813#[cfg(test)]
814thread_local! {
815 static PEAK_TAILS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
817}
818
819fn add_tail(tails: &mut Vec<Tail>, tail: Tail) {
821 match tails.iter_mut().find(|t| t.same_as(&tail)) {
822 Some(same) => same.share += tail.share,
823 None => tails.push(tail),
824 }
825}
826
827fn wake_fraction(tails: &[Tail]) -> f64 {
829 tails.iter().map(Tail::hold).sum::<f64>().min(1.0)
830}
831
832fn strongest(tails: &[Tail]) -> Option<Boattail> {
834 tails
835 .iter()
836 .fold(None::<&Tail>, |best, t| match best {
837 Some(b) if b.hold() >= t.hold() => Some(b),
838 _ => Some(t),
839 })
840 .map(|t| t.cone)
841}
842
843pub(crate) fn couple_afterbody(
859 terms: &mut [ComponentDragTerms],
860 bodies: &[(usize, BodyGeometry)],
861 reference_area_m2: f64,
862) -> Result<(), AeroError> {
863 use std::f64::consts::PI;
864 let radius = |area: f64| (area / PI).sqrt();
865 let mut tails: Vec<Tail> = Vec::new();
866 let mut x = 0.0;
867 let mut previous_aft_radius: Option<f64> = None;
868 for (index, geometry) in bodies {
869 let (x0, x1) = (x, x + geometry.length_m);
870 let (r0, r1) = (radius(geometry.fore_area_m2), radius(geometry.aft_area_m2));
871 x = x1;
872 let before = previous_aft_radius.unwrap_or(r0);
873 previous_aft_radius = Some(r1);
874 let terms = &mut terms[*index];
876 let mut wake: Option<WakeTerm> = None;
877 if r0 < before {
878 for t in &mut tails {
880 t.fade_m += 2.0 * (before - r0);
881 }
882 tails.retain(|t| t.hold() > 0.0);
883 let held: f64 = tails.iter().map(Tail::hold).sum();
884 if held < 1.0 {
885 let corner =
886 Boattail::new(STEP_LENGTH_RATIO * (before - r0), 2.0 * before, 2.0 * r0)?;
887 add_tail(
888 &mut tails,
889 Tail::own(corner, (x0, before), (x0, r0), 1.0 - held),
890 );
891 }
892 } else if r0 > before {
893 for t in &mut tails {
895 t.lip = t.lip.min(t.share_by_rise(r0));
896 }
897 wake = strongest(&tails).map(|boattail| WakeTerm {
898 boattail,
899 step_fraction: wake_fraction(&tails),
900 shoulder_fraction: 0.0,
901 });
902 }
903 tails.retain(|t| t.hold() > 0.0);
904 if geometry.aft_area_m2 > geometry.fore_area_m2 {
905 for t in &mut tails {
907 t.lip = t.lip.min(t.share_by_rise(r1));
908 }
909 if let Some(boattail) = strongest(&tails) {
910 wake = Some(WakeTerm {
911 boattail,
912 step_fraction: wake.map_or(0.0, |w| w.step_fraction),
913 shoulder_fraction: wake_fraction(&tails),
914 });
915 }
916 for t in &mut tails {
917 t.fade_m += geometry.length_m;
918 }
919 } else if let Some(term) = &mut terms.boattail {
920 let own = term.own;
921 let angle = own.half_angle_rad;
922 let mut next: Vec<Tail> = Vec::with_capacity(2 * tails.len() + 1);
923 for t in &mut tails {
924 let fraction = merge_fraction(angle, t.angle_rad);
925 let weight = fraction * t.hold();
926 if weight > 0.0 && x0 > t.start.0 && t.start.1 > r0 {
927 let through_aft = Boattail::new(x1 - t.start.0, 2.0 * t.start.1, 2.0 * r1)?;
928 let through_fore = Boattail::new(x0 - t.start.0, 2.0 * t.start.1, 2.0 * r0)?;
929 match term
930 .merged
931 .iter_mut()
932 .find(|m| m.through_aft == through_aft && m.through_fore == through_fore)
933 {
934 Some(same) => same.weight += weight,
935 None => term.merged.push(MergedBoattail {
936 through_aft,
937 through_fore,
938 area_ratio: PI * t.start.1 * t.start.1 / reference_area_m2,
939 weight,
940 }),
941 }
942 add_tail(
943 &mut next,
944 Tail {
945 angle_rad: angle,
946 ..Tail::own(through_aft, t.start, (x1, r1), weight)
947 },
948 );
949 t.share *= 1.0 - fraction;
950 }
951 t.fade_m += geometry.length_m + 2.0 * (r0 - r1);
953 }
954 for t in tails.iter().filter(|t| t.hold() > 0.0) {
955 add_tail(&mut next, *t);
956 }
957 let merged: f64 = term.merged.iter().map(|m| m.weight).sum();
958 term.own_weight = (1.0 - merged).max(0.0);
959 let held: f64 = next.iter().map(Tail::hold).sum();
960 if held < 1.0 {
961 add_tail(&mut next, Tail::own(own, (x0, r0), (x1, r1), 1.0 - held));
962 }
963 tails = next;
964 } else {
965 for t in &mut tails {
967 t.fade_m += geometry.length_m + 2.0 * (r0 - r1).max(0.0);
968 }
969 }
970 terms.in_wake_of = wake.filter(|w| w.step_fraction > 0.0 || w.shoulder_fraction > 0.0);
971 tails.retain(|t| t.hold() > 0.0);
972 #[cfg(test)]
973 PEAK_TAILS.with(|p| p.set(p.get().max(tails.len())));
974 }
975 if let Some((index, geometry)) = bodies.last()
977 && geometry.aft_area_m2 > 0.0
978 && !tails.is_empty()
979 {
980 let base = geometry.aft_area_m2;
981 let mut sources: Vec<ReliefSource> = Vec::new();
982 for t in &tails {
983 match sources.iter_mut().find(|s| s.boattail == t.cone) {
984 Some(same) => same.weight += t.hold(),
985 None => sources.push(ReliefSource {
986 boattail: t.cone,
987 area_ratio: (base
988 / (0.25 * PI * t.cone.fore_diameter_m * t.cone.fore_diameter_m))
989 .min(1.0),
990 weight: t.hold(),
991 }),
992 }
993 }
994 terms[*index].base_behind = Some(BaseBehindBoattail { sources });
995 }
996 Ok(())
997}
998
999#[derive(Debug, Clone, PartialEq, Serialize)]
1004#[non_exhaustive]
1005pub struct ComponentDragTerms {
1006 pub id: String,
1008 pub friction_area_ratio: f64,
1011 pub relative_roughness: f64,
1013 pub step: Option<PressureDragTerm>,
1016 pub shoulder: Option<PressureDragTerm>,
1018 pub unsupported: Option<String>,
1023 pub mach_limit: Option<(f64, &'static str)>,
1027 pub boattail_area_ratio: f64,
1030 pub fore_step_down_area_ratio: f64,
1037 pub stated: Option<f64>,
1042 pub boattail: Option<BoattailTerm>,
1046 pub in_wake_of: Option<WakeTerm>,
1050 pub base_behind: Option<BaseBehindBoattail>,
1054 pub fins: Option<FinPressureTerms>,
1056 pub parasitic_area_ratio: f64,
1059 pub base_area_m2: f64,
1062 pub copies: u32,
1066 pub in_pod: bool,
1069 pub motor_pod_set: Option<usize>,
1074}
1075
1076impl ComponentDragTerms {
1077 fn empty(component: &PlacedComponent, length_m: f64) -> Result<Self, AeroError> {
1078 Ok(Self {
1079 id: component.id.clone(),
1080 friction_area_ratio: 0.0,
1081 relative_roughness: component.finish.roughness_m()? / length_m,
1082 step: None,
1083 shoulder: None,
1084 unsupported: None,
1085 mach_limit: None,
1086 boattail_area_ratio: 0.0,
1087 fore_step_down_area_ratio: 0.0,
1088 stated: None,
1089 boattail: None,
1090 in_wake_of: None,
1091 base_behind: None,
1092 fins: None,
1093 parasitic_area_ratio: 0.0,
1094 base_area_m2: 0.0,
1095 copies: 1,
1096 in_pod: false,
1097 motor_pod_set: None,
1098 })
1099 }
1100
1101 pub(crate) fn body(
1117 component: &PlacedComponent,
1118 geometry: &BodyGeometry,
1119 shape: Option<NoseShape>,
1120 previous_aft_area_m2: Option<f64>,
1121 form_factor: f64,
1122 length_m: f64,
1123 reference_area_m2: f64,
1124 ) -> Result<Self, AeroError> {
1125 use std::f64::consts::PI;
1126 let mut terms = Self::empty(component, length_m)?;
1127 terms.friction_area_ratio =
1128 form_factor * PI * geometry.planform_area_m2 / reference_area_m2;
1129 let step = geometry.fore_area_m2 - previous_aft_area_m2.unwrap_or(0.0);
1130 if step > 0.0 {
1131 terms.step = Some(PressureDragTerm {
1132 curve: PressureDragCurve::step(),
1133 area_ratio: step / reference_area_m2,
1134 });
1135 } else if step < 0.0 {
1136 terms.boattail_area_ratio -= step;
1138 terms.fore_step_down_area_ratio = -step / reference_area_m2;
1139 }
1140 let diameter = |area: f64| 2.0 * (area / PI).sqrt();
1141 let change = geometry.aft_area_m2 - geometry.fore_area_m2;
1142 let rise = diameter(geometry.aft_area_m2) - diameter(geometry.fore_area_m2);
1143 if change > 0.0 && rise > 0.0 {
1145 let shape = shape.ok_or_else(|| {
1146 AeroError::Layout("a body that widens needs a profile shape".to_owned())
1147 })?;
1148 let joint = geometry.aft_angle_rad.max(0.0);
1149 match PressureDragCurve::new(shape, geometry.length_m / rise, joint) {
1150 Ok(curve) => {
1151 terms.shoulder = Some(PressureDragTerm {
1152 curve,
1153 area_ratio: change / reference_area_m2,
1154 });
1155 }
1156 Err(AeroError::Unsupported(why)) => terms.unsupported = Some(why),
1157 Err(error) => return Err(error),
1158 }
1159 } else if change < 0.0 {
1160 let (fore, aft) = (
1161 diameter(geometry.fore_area_m2),
1162 diameter(geometry.aft_area_m2),
1163 );
1164 if geometry.length_m > 0.0 && fore > aft {
1165 terms.boattail = Some(BoattailTerm {
1166 own: Boattail::new(geometry.length_m, fore, aft)?,
1167 own_area_ratio: geometry.fore_area_m2 / reference_area_m2,
1168 merged: Vec::new(),
1169 own_weight: 1.0,
1170 });
1171 } else {
1172 terms.boattail_area_ratio -= change;
1175 }
1176 }
1177 terms.boattail_area_ratio /= reference_area_m2;
1178 Ok(terms)
1179 }
1180
1181 pub(crate) fn fins(
1184 component: &PlacedComponent,
1185 set: &FinSet,
1186 geometry: &FinGeometry,
1187 length_m: f64,
1188 reference_area_m2: f64,
1189 ) -> Result<Self, AeroError> {
1190 let mut terms = Self::empty(component, length_m)?;
1191 let count = f64::from(set.count);
1192 let factor = fin_friction_thickness_factor(set.thickness_m, geometry.mac_length_m)?;
1193 fin_pressure_drag_coefficient(set.cross_section, geometry.leading_edge_sweep_rad, 0.0)?;
1195 terms.friction_area_ratio = 2.0 * count * geometry.area_m2 * factor / reference_area_m2;
1196 terms.fins = Some(FinPressureTerms {
1197 cross_section: set.cross_section,
1198 leading_edge_sweep_rad: geometry.leading_edge_sweep_rad,
1199 frontal_area_ratio: count * set.thickness_m * geometry.span_m / reference_area_m2,
1200 });
1201 Ok(terms)
1202 }
1203
1204 pub(crate) fn tube_fins(
1209 component: &PlacedComponent,
1210 set: &TubeFinSet,
1211 length_m: f64,
1212 reference_area_m2: f64,
1213 ) -> Result<Self, AeroError> {
1214 let mut terms = Self::empty(component, length_m)?;
1215 check_dimension("tube fin length", set.length_m, false)?;
1216 check_dimension("tube fin outer radius", set.outer_radius_m, false)?;
1217 check_dimension("tube fin thickness", set.thickness_m, true)?;
1218 let (outer, inner) = (set.outer_radius_m, set.outer_radius_m - set.thickness_m);
1219 check_dimension("tube fin inner radius", inner, false)?;
1220 let count = f64::from(set.count);
1221 let wetted = 2.0 * std::f64::consts::PI * set.length_m * (outer + inner);
1222 terms.friction_area_ratio = count * wetted / reference_area_m2;
1223 terms.fins = Some(FinPressureTerms {
1224 cross_section: FinCrossSection::Square,
1225 leading_edge_sweep_rad: 0.0,
1226 frontal_area_ratio: count * std::f64::consts::PI * (outer * outer - inner * inner)
1227 / reference_area_m2,
1228 });
1229 terms.mach_limit = Some((TUBE_FIN_MACH_LIMIT, "the tube-fin model"));
1230 Ok(terms)
1231 }
1232
1233 pub(crate) fn launch_lugs(
1235 component: &PlacedComponent,
1236 lug: &LaunchLug,
1237 length_m: f64,
1238 reference_area_m2: f64,
1239 ) -> Result<Self, AeroError> {
1240 let mut terms = Self::empty(component, length_m)?;
1241 let (factor, area) = launch_lug_factor_and_area(
1242 lug.length_m,
1243 lug.outer_radius_m,
1244 lug.outer_radius_m - lug.thickness_m,
1245 )?;
1246 terms.parasitic_area_ratio = f64::from(lug.count) * factor * area / reference_area_m2;
1247 Ok(terms)
1248 }
1249
1250 pub(crate) fn rail_buttons(
1253 component: &PlacedComponent,
1254 button: &RailButton,
1255 length_m: f64,
1256 reference_area_m2: f64,
1257 ) -> Result<Self, AeroError> {
1258 let mut terms = Self::empty(component, length_m)?;
1259 check_dimension("rail button outer diameter", button.outer_diameter_m, false)?;
1260 check_dimension("rail button inner diameter", button.inner_diameter_m, true)?;
1261 let ends = button.base_height_m + button.flange_height_m;
1262 let waist = button.height_m - ends;
1263 check_dimension("rail button base and flange height", ends, true)?;
1264 check_dimension("rail button waist height", waist, true)?;
1265 let frontal = button.outer_diameter_m * ends + button.inner_diameter_m * waist;
1266 terms.parasitic_area_ratio = f64::from(button.count) * frontal / reference_area_m2;
1267 Ok(terms)
1268 }
1269
1270 pub(crate) fn stage(id: &str, coefficient: f64) -> Self {
1272 Self {
1273 id: id.to_owned(),
1274 friction_area_ratio: 0.0,
1275 relative_roughness: 0.0,
1276 step: None,
1277 shoulder: None,
1278 unsupported: None,
1279 mach_limit: None,
1280 boattail_area_ratio: 0.0,
1281 fore_step_down_area_ratio: 0.0,
1282 stated: Some(coefficient),
1283 boattail: None,
1284 in_wake_of: None,
1285 base_behind: None,
1286 fins: None,
1287 parasitic_area_ratio: 0.0,
1288 base_area_m2: 0.0,
1289 copies: 1,
1290 in_pod: false,
1291 motor_pod_set: None,
1292 }
1293 }
1294
1295 pub(crate) fn evaluate(
1298 &self,
1299 reynolds: f64,
1300 mach: f64,
1301 conditions: &DragConditions,
1302 reference_area_m2: f64,
1303 ) -> Result<Drag, AeroError> {
1304 if let Some(stated) = self.stated {
1307 let copies = f64::from(self.copies);
1308 let pressure = copies * base_drag_coefficient(mach)? * self.fore_step_down_area_ratio;
1310 let stated = copies * stated;
1311 return Ok(Drag {
1312 zero_lift_coefficient: pressure + stated,
1313 axial_coefficient: pressure + stated,
1314 pressure,
1315 stated,
1316 ..Drag::default()
1317 });
1318 }
1319 if let Some(why) = &self.unsupported {
1320 return Err(AeroError::InComponent {
1321 id: self.id.clone(),
1322 source: Box::new(AeroError::Unsupported(why.clone())),
1323 });
1324 }
1325 if let Some((limit, model)) = self.mach_limit {
1326 check_mach(mach, limit, model).map_err(|e| AeroError::InComponent {
1327 id: self.id.clone(),
1328 source: Box::new(e),
1329 })?;
1330 }
1331 let friction = if self.friction_area_ratio > 0.0 {
1332 skin_friction_coefficient(reynolds, self.relative_roughness, mach)?
1333 * self.friction_area_ratio
1334 } else {
1335 0.0
1336 };
1337 let base_coefficient = base_drag_coefficient(mach)?;
1338 let mut pressure = base_coefficient * self.boattail_area_ratio;
1339 if let Some(term) = &self.boattail {
1340 let mut merged = 0.0;
1341 for m in &term.merged {
1342 let share = m.area_ratio
1343 * (m.through_aft.pressure_drag_coefficient(mach)?
1344 - m.through_fore.pressure_drag_coefficient(mach)?);
1345 merged += m.weight * share;
1348 }
1349 if term.own_weight > 0.0 {
1350 merged += term.own_weight
1351 * term.own_area_ratio
1352 * term.own.pressure_drag_coefficient(mach)?;
1353 }
1354 pressure += merged;
1355 }
1356 let wake = self.in_wake_of;
1358 for (term, fraction) in [
1359 (&self.step, wake.map_or(0.0, |w| w.step_fraction)),
1360 (&self.shoulder, wake.map_or(0.0, |w| w.shoulder_fraction)),
1361 ] {
1362 if let Some(term) = term {
1363 pressure += (1.0 - fraction) * term.area_ratio * term.curve.coefficient(mach)?;
1364 }
1365 }
1366 if let Some(fins) = &self.fins {
1367 pressure += fins.frontal_area_ratio
1368 * fin_pressure_drag_coefficient(
1369 fins.cross_section,
1370 fins.leading_edge_sweep_rad,
1371 mach,
1372 )?;
1373 }
1374 let parasitic = if self.parasitic_area_ratio > 0.0 {
1375 stagnation_drag_coefficient(mach)? * self.parasitic_area_ratio
1376 } else {
1377 0.0
1378 };
1379 let mut relief = 1.0;
1381 for source in self.base_behind.iter().flat_map(|b| &b.sources) {
1382 let k = source
1383 .boattail
1384 .base_pressure_ratio(mach, source.area_ratio)?;
1385 relief -= source.weight * (1.0 - k);
1386 }
1387 if relief < 0.0 {
1389 relief = 0.0;
1390 }
1391 let motor_area_m2 = match (self.in_pod, self.motor_pod_set) {
1393 (false, _) => conditions.thrusting_motor_area_m2,
1394 (true, Some(set)) if self.copies > 0 => {
1395 let total = conditions
1396 .thrusting_pod_motor_areas_m2
1397 .get(set)
1398 .copied()
1399 .ok_or(AeroError::Domain {
1400 what: "pod set holding motor mounts, by its place",
1401 value: set as f64,
1402 })?;
1403 total / f64::from(self.copies)
1404 }
1405 (true, _) => 0.0,
1406 };
1407 let base = base_coefficient * relief * (self.base_area_m2 - motor_area_m2).max(0.0)
1408 / reference_area_m2;
1409 let copies = f64::from(self.copies);
1410 let (friction, pressure, base, parasitic) = (
1411 copies * friction,
1412 copies * pressure,
1413 copies * base,
1414 copies * parasitic,
1415 );
1416 let zero_lift = friction + pressure + base + parasitic;
1417 Ok(Drag {
1418 zero_lift_coefficient: zero_lift,
1419 axial_coefficient: zero_lift,
1420 friction,
1421 pressure,
1422 base,
1423 parasitic,
1424 stated: 0.0,
1425 table: None,
1426 })
1427 }
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use std::f64::consts::{FRAC_PI_2, PI};
1433
1434 use hpr_design::{
1435 FinPlanform, Finish, LaunchLug, NoseShape, Part, Position, RailButton, ReferenceDiameter,
1436 Rocket,
1437 };
1438
1439 use super::*;
1440 use crate::table::DragTable;
1441 use crate::testing::{body_part, component, fin_set, material, nose, one_stage};
1442 use crate::{AeroModel, Flow};
1443
1444 fn step_by_hand(mach: f64) -> f64 {
1447 let m2 = mach * mach;
1448 0.85 * (1.0 + m2 / 4.0 + m2 * m2 / 40.0)
1449 }
1450
1451 fn close(got: f64, want: f64, rel: f64, what: &str) {
1452 let err = if want == 0.0 {
1453 got.abs()
1454 } else {
1455 ((got - want) / want).abs()
1456 };
1457 assert!(
1458 err <= rel,
1459 "{what}: got {got}, want {want}, rel err {err:e}"
1460 );
1461 }
1462
1463 fn model(rocket: &Rocket) -> AeroModel {
1464 AeroModel::new(&rocket.layout().unwrap()).unwrap()
1465 }
1466
1467 const RE_PER_M: f64 = 0.3 * 340.294 / 1.4607e-5;
1469
1470 fn fins_on(
1471 tube: &str,
1472 radius: f64,
1473 length: f64,
1474 sets: Vec<hpr_design::Component>,
1475 ) -> hpr_design::Component {
1476 let mut c = component(tube, body_part(length, radius, radius), None);
1477 c.children = sets;
1478 c
1479 }
1480
1481 fn trapezoid() -> FinPlanform {
1482 FinPlanform::Trapezoidal {
1483 root_chord_m: 0.12,
1484 tip_chord_m: 0.05,
1485 span_m: 0.06,
1486 sweep_m: 0.07,
1487 }
1488 }
1489
1490 fn with_fin(
1491 id: &str,
1492 count: u32,
1493 thickness: f64,
1494 section: FinCrossSection,
1495 aft_offset: f64,
1496 ) -> hpr_design::Component {
1497 let mut part = fin_set(count, trapezoid());
1498 if let Part::FinSet(set) = &mut part {
1499 set.thickness_m = thickness;
1500 set.cross_section = section;
1501 }
1502 component(
1503 id,
1504 part,
1505 Some(Position::Bottom {
1506 aft_offset_m: aft_offset,
1507 }),
1508 )
1509 }
1510
1511 #[test]
1515 fn skin_friction_follows_eq_3_81_and_drag_invariants_hold() {
1516 let rr = 60e-6; assert_eq!(incompressible_skin_friction(9_999.0, rr).unwrap(), 1.48e-2);
1519 assert_eq!(incompressible_skin_friction(0.0, rr).unwrap(), 1.48e-2);
1520 close(
1521 incompressible_skin_friction(1e4, rr).unwrap(),
1522 1.48e-2,
1523 2e-3,
1524 "continuous at 1e4",
1525 );
1526 for r in [1e4, 3e4, 1e5, 1e6] {
1527 let d = 1.50 * f64::ln(r) - 5.6;
1528 assert_eq!(
1529 incompressible_skin_friction(r, rr).unwrap(),
1530 1.0 / (d * d),
1531 "eq. 3.78 at {r}"
1532 );
1533 }
1534 let critical = critical_reynolds(rr).unwrap();
1535 assert_eq!(critical, 51.0 * rr.powf(-1.039));
1536 close(critical, 1.242e6, 1e-3, "R_crit for 60 µm on 1 m");
1537 let rough = 0.032 * rr.powf(0.2);
1538 assert_eq!(incompressible_skin_friction(critical, rr).unwrap(), rough);
1539 assert_eq!(incompressible_skin_friction(1e9, rr).unwrap(), rough);
1540 let below = incompressible_skin_friction(critical * (1.0 - 1e-12), rr).unwrap();
1542 close(below, 0.00419, 2e-3, "turbulent just below R_crit");
1543 close(rough, 0.00458, 2e-3, "roughness-limited at R_crit");
1544 assert_eq!(critical_reynolds(0.0).unwrap(), f64::INFINITY);
1546 let d = 1.50 * f64::ln(1e9) - 5.6;
1547 assert_eq!(
1548 incompressible_skin_friction(1e9, 0.0).unwrap(),
1549 1.0 / (d * d)
1550 );
1551
1552 for (r, rr) in [(1e5, rr), (1e9, rr)] {
1554 let cf = incompressible_skin_friction(r, rr).unwrap();
1555 close(
1556 skin_friction_coefficient(r, rr, 0.8).unwrap(),
1557 cf * (1.0 - 0.064),
1558 1e-15,
1559 "eq. 3.82",
1560 );
1561 }
1562 let d = 1.50 * f64::ln(1e5) - 5.6;
1564 close(
1565 skin_friction_coefficient(1e5, rr, 2.0).unwrap(),
1566 1.0 / (d * d) / 1.6f64.powf(0.58),
1567 1e-15,
1568 "eq. 3.83",
1569 );
1570 close(
1571 skin_friction_coefficient(1e9, rr, 2.0).unwrap(),
1572 rough / 1.72,
1573 1e-15,
1574 "eq. 3.84",
1575 );
1576 let rr_small = 2e-6;
1579 for mach in (0..=100).map(|k| 0.05 * f64::from(k)) {
1580 for r in [0.0, 1e3, 1e4, 1e5, 1e7, 1e9] {
1581 for rr in [0.0, 2e-6, 60e-6, 1e-3] {
1582 let cf = skin_friction_coefficient(r, rr, mach).unwrap();
1583 assert!(
1584 cf.is_finite() && cf > 0.0,
1585 "C_f {cf} at M {mach}, R {r}, R_s/L {rr}"
1586 );
1587 if mach >= 1.0 {
1588 let d = 1.50 * f64::ln(r.max(1e4)) - 5.6;
1589 let smooth = if r < 1e4 { 1.48e-2 } else { 1.0 / (d * d) };
1590 let floor = smooth / (1.0 + 0.15 * mach * mach).powf(0.58);
1591 assert!(cf >= floor * (1.0 - 1e-15), "below turbulent at M {mach}");
1592 }
1593 }
1594 }
1595 for f in [
1596 stagnation_drag_coefficient(mach).unwrap(),
1597 base_drag_coefficient(mach).unwrap(),
1598 fin_pressure_drag_coefficient(FinCrossSection::Rounded, 0.3, mach).unwrap(),
1599 fin_pressure_drag_coefficient(FinCrossSection::Square, 0.3, mach).unwrap(),
1600 ] {
1601 assert!(f.is_finite() && f >= 0.0, "term {f} at M {mach}");
1602 }
1603 }
1604 assert!(critical_reynolds(rr_small).unwrap() < 1e9);
1607 let rough_m5 = 0.032 * rr_small.powf(0.2) / (1.0 + 0.18 * 25.0);
1608 let turbulent_m5 = {
1609 let d = 1.50 * f64::ln(1e9) - 5.6;
1610 1.0 / (d * d) / (1.0f64 + 0.15 * 25.0).powf(0.58)
1611 };
1612 assert!(rough_m5 < turbulent_m5);
1613 assert_eq!(
1614 skin_friction_coefficient(1e9, rr_small, 5.0).unwrap(),
1615 turbulent_m5
1616 );
1617 assert!(critical_reynolds(2e-2).unwrap() < 5e3);
1619 assert_eq!(incompressible_skin_friction(5e3, 2e-2).unwrap(), 1.48e-2);
1620 assert_eq!(
1621 incompressible_skin_friction(2e4, 2e-2).unwrap(),
1622 0.032 * 2e-2f64.powf(0.2)
1623 );
1624
1625 let below = base_drag_coefficient(1.0 - 1e-12).unwrap();
1627 let at = base_drag_coefficient(1.0).unwrap();
1628 close(below, 0.25, 1e-11, "base drag below Mach 1");
1629 assert_eq!(at, 0.25);
1630
1631 let one_set = one_stage(
1633 vec![
1634 component(
1635 "nose",
1636 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, 0.027),
1637 None,
1638 ),
1639 fins_on(
1640 "tail",
1641 0.027,
1642 0.6,
1643 vec![with_fin("fins", 4, 0.003, FinCrossSection::Rounded, 0.0)],
1644 ),
1645 ],
1646 ReferenceDiameter::Maximum {},
1647 );
1648 let mut half_b = with_fin("fins-b", 2, 0.003, FinCrossSection::Rounded, 0.0);
1649 if let Part::FinSet(set) = &mut half_b.part {
1650 set.base_angle_rad = FRAC_PI_2;
1651 }
1652 let split = one_stage(
1653 vec![
1654 component(
1655 "nose",
1656 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, 0.027),
1657 None,
1658 ),
1659 fins_on(
1660 "tail",
1661 0.027,
1662 0.6,
1663 vec![
1664 with_fin("fins-a", 2, 0.003, FinCrossSection::Rounded, 0.0),
1665 half_b,
1666 ],
1667 ),
1668 ],
1669 ReferenceDiameter::Maximum {},
1670 );
1671 let conditions = DragConditions::coasting(RE_PER_M);
1672 for alpha in [0.0, 0.1] {
1673 let flow = Flow::new(0.3, alpha, 0.0);
1674 let a = model(&one_set).drag(&flow, &conditions).unwrap();
1675 let b = model(&split).drag(&flow, &conditions).unwrap();
1676 close(
1677 b.zero_lift_coefficient,
1678 a.zero_lift_coefficient,
1679 1e-15,
1680 "split C_D0",
1681 );
1682 close(b.pressure, a.pressure, 1e-15, "split pressure drag");
1683 close(b.friction, a.friction, 1e-15, "split friction drag");
1684 }
1685 }
1686
1687 #[test]
1690 fn drag_invariant_to_fin_set_order() {
1691 let sets = || {
1692 let mut canted = with_fin("aft", 3, 0.004, FinCrossSection::Square, 0.0);
1693 if let Part::FinSet(set) = &mut canted.part {
1694 set.planform = FinPlanform::Elliptical {
1695 root_chord_m: 0.1,
1696 span_m: 0.05,
1697 };
1698 set.base_angle_rad = 0.2;
1699 }
1700 (
1701 with_fin("fore", 4, 0.002, FinCrossSection::Airfoil, 0.3),
1702 canted,
1703 )
1704 };
1705 let build = |sets: Vec<hpr_design::Component>| {
1706 let rocket = one_stage(
1707 vec![
1708 component("nose", nose(NoseShape::Conical {}, 0.2, 0.027), None),
1709 fins_on("tail", 0.027, 0.8, sets),
1710 ],
1711 ReferenceDiameter::Maximum {},
1712 );
1713 model(&rocket)
1714 };
1715 let (a, b) = sets();
1716 let forward = build(vec![a, b]);
1717 let (a, b) = sets();
1718 let reversed = build(vec![b, a]);
1719 for (mach, alpha, motor) in [(0.1, 0.0, 0.0), (0.5, 0.2, 1e-3), (0.9, 1.0, 0.0)] {
1720 let flow = Flow::new(mach, alpha, 0.0);
1721 let conditions = DragConditions::thrusting(RE_PER_M, motor);
1722 let f = forward.drag(&flow, &conditions).unwrap();
1723 let r = reversed.drag(&flow, &conditions).unwrap();
1724 close(
1725 r.zero_lift_coefficient,
1726 f.zero_lift_coefficient,
1727 1e-15,
1728 "C_D0",
1729 );
1730 close(r.axial_coefficient, f.axial_coefficient, 1e-15, "C_A");
1731 close(r.pressure, f.pressure, 1e-15, "pressure");
1732 close(r.friction, f.friction, 1e-15, "friction");
1733 }
1734 }
1735
1736 #[test]
1741 fn form_factor_and_roughness_match_cited_values() {
1742 assert_eq!(body_friction_form_factor(4.0).unwrap(), 1.125);
1743 assert_eq!(body_friction_form_factor(10.0).unwrap(), 1.05);
1744 assert_eq!(fin_friction_thickness_factor(0.003, 0.1).unwrap(), 1.06);
1745 assert_eq!(fin_friction_thickness_factor(0.0, 0.1).unwrap(), 1.0);
1746 let cf = incompressible_skin_friction(1e6, 0.0).unwrap();
1747 close(
1748 skin_friction_coefficient(1e6, 0.0, 0.5).unwrap(),
1749 cf * 0.975,
1750 1e-15,
1751 "1 − 0.1 M²",
1752 );
1753
1754 let table = [
1756 0.0, 0.1, 0.5, 2.0, 5.0, 15.0, 20.0, 50.0, 50.0, 100.0, 150.0, 200.0, 250.0, 500.0,
1757 1000.0,
1758 ];
1759 for (finish, microns) in Finish::NAMED.iter().zip(table) {
1760 close(
1761 finish.roughness_m().unwrap(),
1762 microns * 1e-6,
1763 1e-15,
1764 &format!("{finish:?}"),
1765 );
1766 }
1767 let niskanen = [
1769 (Finish::AverageGlass {}, 0.1),
1770 (Finish::Polished {}, 0.5),
1771 (Finish::OptimumPaint {}, 5.0),
1772 (Finish::PlanedWood {}, 15.0),
1773 (Finish::MassProductionPaint {}, 20.0),
1774 (Finish::SmoothCement {}, 50.0),
1775 (Finish::DipGalvanized {}, 150.0),
1776 (Finish::PoorPaint {}, 200.0),
1777 (Finish::RawWood {}, 500.0),
1778 (Finish::Concrete {}, 1000.0),
1779 ];
1780 for (finish, microns) in niskanen {
1781 close(
1782 finish.roughness_m().unwrap(),
1783 microns * 1e-6,
1784 1e-15,
1785 &format!("{finish:?}"),
1786 );
1787 }
1788 assert_eq!(Finish::default(), Finish::MassProductionPaint {});
1789 assert_eq!(
1790 Finish::Custom { roughness_m: 6e-5 }.roughness_m().unwrap(),
1791 6e-5
1792 );
1793
1794 let mut tube = component("tube", body_part(1.0, 0.025, 0.025), None);
1797 tube.finish = Some(Finish::RawWood {});
1798 let rocket = one_stage(
1799 vec![
1800 component("nose", nose(NoseShape::Conical {}, 0.25, 0.025), None),
1801 tube,
1802 ],
1803 ReferenceDiameter::Maximum {},
1804 );
1805 let m = model(&rocket);
1806 let terms = &m.drag_terms()[1];
1807 close(terms.relative_roughness, 500e-6 / 1.25, 1e-15, "R_s/L");
1808 let a_ref = PI * 0.025 * 0.025;
1809 close(
1810 terms.friction_area_ratio,
1811 (1.0 + 1.0 / 50.0) * 2.0 * PI * 0.025 * 1.0 / a_ref,
1812 1e-14,
1813 "form factor × wetted area",
1814 );
1815 let cone = &m.drag_terms()[0];
1817 close(
1818 cone.friction_area_ratio,
1819 (1.0 + 1.0 / 50.0) * PI * 0.025 * 0.25 / a_ref,
1820 1e-12,
1821 "cone projection",
1822 );
1823 }
1824
1825 #[test]
1828 fn power_on_base_drag_subtracts_thrusting_motor_area() {
1829 let r = 0.04;
1830 let rocket = one_stage(
1831 vec![
1832 component(
1833 "nose",
1834 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.3, r),
1835 None,
1836 ),
1837 component("tube", body_part(1.2, r, r), None),
1838 ],
1839 ReferenceDiameter::Maximum {},
1840 );
1841 let m = model(&rocket);
1842 let a_base = PI * r * r;
1843 let motor = PI * 0.027 * 0.027;
1844 let flow = Flow::axial(0.5);
1845 let c_base = 0.12 + 0.13 * 0.25;
1846 let off = m.drag(&flow, &DragConditions::coasting(RE_PER_M)).unwrap();
1847 let on = m
1848 .drag(&flow, &DragConditions::thrusting(RE_PER_M, motor))
1849 .unwrap();
1850 let full = m
1851 .drag(&flow, &DragConditions::thrusting(RE_PER_M, a_base))
1852 .unwrap();
1853 let over = m
1854 .drag(&flow, &DragConditions::thrusting(RE_PER_M, 2.0 * a_base))
1855 .unwrap();
1856 close(
1857 off.base,
1858 c_base,
1859 1e-15,
1860 "coasting base drag on A_ref = A_base",
1861 );
1862 close(
1863 on.base,
1864 c_base * (a_base - motor) / a_base,
1865 1e-14,
1866 "power-on base drag",
1867 );
1868 assert_eq!(full.base, 0.0);
1869 assert_eq!(over.base, 0.0);
1870 assert_eq!(on.friction, off.friction);
1872 assert_eq!(on.pressure, off.pressure);
1873 close(
1874 off.zero_lift_coefficient - on.zero_lift_coefficient,
1875 off.base - on.base,
1876 1e-12,
1877 "sum",
1878 );
1879 let parts = m
1881 .buildup_components(&flow, &DragConditions::thrusting(RE_PER_M, motor))
1882 .unwrap();
1883 assert_eq!(parts[0].drag.base, 0.0);
1884 assert_eq!(parts[1].drag.base, on.base);
1885
1886 let table = DragTable::from_csv("0,0.5\n1,0.5\n", Some("0,0.4\n1,0.4\n")).unwrap();
1888 let t = m.clone().with_drag_table(table);
1889 assert_eq!(
1890 t.drag(&flow, &DragConditions::coasting(RE_PER_M))
1891 .unwrap()
1892 .zero_lift_coefficient,
1893 0.5
1894 );
1895 assert_eq!(
1896 t.drag(&flow, &DragConditions::thrusting(RE_PER_M, motor))
1897 .unwrap()
1898 .zero_lift_coefficient,
1899 0.4
1900 );
1901 }
1902
1903 #[test]
1907 fn launch_lug_drag_matches_cited_hollow_tube_formula() {
1908 let (ro, ri) = (0.005, 0.004);
1909 let annulus = PI * (ro * ro - ri * ri);
1910 let face = PI * ro * ro;
1911 let stag = |m: f64| 0.85 * (1.0 + m * m / 4.0 + m.powi(4) / 40.0);
1912 let (c, a) = launch_lug_drag(0.0, ro, ri, 0.0).unwrap();
1914 close(c, 1.3 * 0.85, 1e-15, "ring coefficient");
1915 close(a, annulus, 1e-15, "ring area");
1916 let (c, a) = launch_lug_drag(ro, ro, ri, 0.3).unwrap();
1918 close(c, 1.15 * stag(0.3), 1e-15, "l = d/2 coefficient");
1919 close(a, face - 0.5 * PI * ri * ri, 1e-15, "l = d/2 area");
1920 for l in [2.0 * ro, 0.05] {
1922 let (c, a) = launch_lug_drag(l, ro, ri, 0.7).unwrap();
1923 close(c, stag(0.7), 1e-15, "long lug coefficient");
1924 close(a, face, 1e-15, "long lug area");
1925 }
1926 let at = |l: f64| {
1928 let (c, a) = launch_lug_drag(l, ro, ri, 0.3).unwrap();
1929 c * a
1930 };
1931 close(
1932 at(2.0 * ro - 1e-12),
1933 at(2.0 * ro),
1934 1e-9,
1935 "continuous at l = d",
1936 );
1937 close(at(1e-15), at(0.0), 1e-9, "continuous at l = 0");
1938 assert!(launch_lug_drag(0.03, ro, 0.006, 0.3).is_err());
1939
1940 let mut tube = component("tube", body_part(1.0, 0.03, 0.03), None);
1942 tube.children = vec![
1943 component(
1944 "lugs",
1945 Part::LaunchLug(LaunchLug {
1946 length_m: 0.03,
1947 outer_radius_m: ro,
1948 thickness_m: ro - ri,
1949 angle_rad: 0.0,
1950 count: 2,
1951 spacing_m: 0.5,
1952 material: material(),
1953 }),
1954 Some(Position::Top { aft_offset_m: 0.1 }),
1955 ),
1956 component(
1957 "buttons",
1958 Part::RailButton(RailButton {
1959 outer_diameter_m: 0.0113,
1960 inner_diameter_m: 0.0064,
1961 height_m: 0.0081,
1962 base_height_m: 0.002,
1963 flange_height_m: 0.002,
1964 screw_height_m: 0.0,
1965 angle_rad: 0.0,
1966 count: 2,
1967 spacing_m: 0.5,
1968 material: material(),
1969 }),
1970 Some(Position::Top { aft_offset_m: 0.1 }),
1971 ),
1972 ];
1973 let rocket = one_stage(
1974 vec![
1975 component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
1976 tube,
1977 ],
1978 ReferenceDiameter::Maximum {},
1979 );
1980 let m = model(&rocket);
1981 let a_ref = PI * 0.03 * 0.03;
1982 let parts = m
1983 .buildup_components(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
1984 .unwrap();
1985 let find = |id: &str| parts.iter().find(|p| p.id == id).unwrap().drag;
1986 close(
1987 find("lugs").parasitic,
1988 2.0 * stag(0.3) * face / a_ref,
1989 1e-14,
1990 "lugs",
1991 );
1992 let profile = 0.0113 * 0.004 + 0.0064 * 0.0041;
1993 close(
1994 find("buttons").parasitic,
1995 2.0 * stag(0.3) * profile / a_ref,
1996 1e-14,
1997 "buttons",
1998 );
1999 assert_eq!(find("lugs").friction, 0.0);
2000 }
2001
2002 #[test]
2006 fn shoulder_drag_continuous_as_transition_length_tends_to_zero() {
2007 let (small, big) = (0.02, 0.03);
2008 let rocket = |fore: f64, aft: f64, length: Option<f64>| {
2009 let mut body = vec![
2010 component(
2011 "nose",
2012 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, fore),
2013 None,
2014 ),
2015 component("fore-tube", body_part(0.4, fore, fore), None),
2016 ];
2017 if let Some(l) = length {
2018 body.push(component("change", body_part(l, fore, aft), None));
2019 }
2020 body.push(component("aft-tube", body_part(0.6, aft, aft), None));
2021 one_stage(body, ReferenceDiameter::Custom { diameter_m: 0.06 })
2022 };
2023 let a_ref = PI * 0.03 * 0.03;
2024 let delta = PI * (big * big - small * small);
2025 let conditions = DragConditions::coasting(RE_PER_M);
2026 let flow = Flow::axial(0.3);
2027 let pressure_of = |r: &Rocket| {
2028 model(r)
2029 .buildup_components(&flow, &conditions)
2030 .unwrap()
2031 .iter()
2032 .filter(|c| c.id == "change" || c.id == "aft-tube")
2033 .map(|c| c.drag.pressure)
2034 .sum::<f64>()
2035 };
2036 let total = |r: &Rocket| {
2037 model(r)
2038 .drag(&flow, &conditions)
2039 .unwrap()
2040 .zero_lift_coefficient
2041 };
2042
2043 let step = rocket(small, big, None);
2046 close(
2047 pressure_of(&step),
2048 step_by_hand(0.3) * delta / a_ref,
2049 1e-13,
2050 "bare step up",
2051 );
2052 let s5 = 0.1f64.atan().sin();
2054 let rest = 0.8 * s5 * s5;
2055 let b = 4.0 / 2.4 * (1.0 - 0.5 * s5) / (s5 - rest);
2056 close(
2057 pressure_of(&rocket(small, big, Some(0.1))),
2058 (rest + (s5 - rest) * 0.3f64.powf(b)) * delta / a_ref,
2059 1e-12,
2060 "shoulder by hand",
2061 );
2062 let mut previous = f64::INFINITY;
2063 for l in [0.1, 0.01, 1e-3, 1e-5, 1e-8] {
2064 let s = rocket(small, big, Some(l));
2065 let phi = f64::atan((big - small) / l);
2066 let curve =
2067 PressureDragCurve::new(NoseShape::Conical {}, l / (2.0 * (big - small)), phi)
2068 .unwrap();
2069 close(
2070 pressure_of(&s),
2071 curve.coefficient(0.3).unwrap() * delta / a_ref,
2072 1e-12,
2073 "shoulder",
2074 );
2075 let gap = (total(&s) - total(&step)).abs();
2076 assert!(gap < previous, "shoulder gap {gap} at l = {l}");
2077 previous = gap;
2078 }
2079 assert!(previous < 1e-6 * total(&step));
2080
2081 let c_base = 0.12 + 0.13 * 0.09;
2083 let step = rocket(big, small, None);
2084 close(
2085 pressure_of(&step),
2086 c_base * delta / a_ref,
2087 1e-14,
2088 "bare step down",
2089 );
2090 for (l, factor) in [
2091 (0.01, 1.0),
2092 (0.02, 1.0),
2093 (0.04, 0.5),
2094 (0.06, 0.0),
2095 (0.1, 0.0),
2096 ] {
2097 close(
2098 pressure_of(&rocket(big, small, Some(l))),
2099 factor * c_base * delta / a_ref,
2100 1e-14,
2101 &format!("boattail at l = {l}"),
2102 );
2103 }
2104 let gap = (total(&rocket(big, small, Some(1e-8))) - total(&step)).abs();
2105 assert!(gap < 1e-6 * total(&step), "boattail gap {gap}");
2106 }
2107
2108 #[test]
2111 fn malformed_geometry_is_an_error_not_a_clamped_cd() {
2112 let base = |child: hpr_design::Component| {
2113 let mut tube = component("tube", body_part(1.0, 0.03, 0.03), None);
2114 tube.children = vec![child];
2115 one_stage(
2116 vec![
2117 component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
2118 tube,
2119 ],
2120 ReferenceDiameter::Maximum {},
2121 )
2122 };
2123 let button = component(
2125 "buttons",
2126 Part::RailButton(RailButton {
2127 outer_diameter_m: 0.0113,
2128 inner_diameter_m: 0.0064,
2129 height_m: 0.003,
2130 base_height_m: 0.002,
2131 flange_height_m: 0.002,
2132 screw_height_m: 0.0,
2133 angle_rad: 0.0,
2134 count: 1,
2135 spacing_m: 0.0,
2136 material: material(),
2137 }),
2138 Some(Position::Top { aft_offset_m: 0.1 }),
2139 );
2140 let rocket = base(button);
2142 assert!(rocket.layout().is_err());
2143 let mut layout = base(with_fin("fins", 3, 0.003, FinCrossSection::Square, 0.0))
2144 .layout()
2145 .unwrap();
2146 let Part::RailButton(bad) = &rocket.stages[0].components[1].children[0].part else {
2147 unreachable!("the rail button built above");
2148 };
2149 let mut placed = layout.components[0].clone();
2150 placed.id = "buttons".to_owned();
2151 placed.part = Part::RailButton(bad.clone());
2152 let err = ComponentDragTerms::rail_buttons(&placed, bad, 1.0, 1e-3).unwrap_err();
2153 assert!(matches!(err, AeroError::Domain { .. }), "{err:?}");
2154 layout.components.clear();
2156 assert!(matches!(
2157 AeroModel::new(&layout),
2158 Err(AeroError::Domain { .. })
2159 ));
2160 let mut fins = with_fin("fins", 3, 0.003, FinCrossSection::Square, 0.0);
2163 fins.finish = Some(Finish::Custom { roughness_m: -1e-6 });
2164 assert!(base(fins).layout().is_err());
2165 let mut layout = base(with_fin("fins", 3, 0.003, FinCrossSection::Square, 0.0))
2166 .layout()
2167 .unwrap();
2168 let index = layout.find("fins").unwrap().0;
2169 layout.components[index].finish = Finish::Custom { roughness_m: -1e-6 };
2170 let (id, source) = match AeroModel::new(&layout) {
2171 Err(AeroError::InComponent { id, source }) => (id, *source),
2172 other => panic!("expected an error in a component, got {other:?}"),
2173 };
2174 assert_eq!(id, "fins");
2175 assert!(matches!(source, AeroError::Design(_)), "{source:?}");
2176 let m = model(&base(with_fin(
2178 "fins",
2179 3,
2180 0.003,
2181 FinCrossSection::Square,
2182 0.0,
2183 )));
2184 let flow = Flow::axial(0.3);
2185 for conditions in [
2186 DragConditions::coasting(f64::NAN),
2187 DragConditions::coasting(-1.0),
2188 DragConditions::thrusting(RE_PER_M, f64::INFINITY),
2189 DragConditions {
2190 thrusting_motor_area_m2: 1e-3,
2191 ..DragConditions::coasting(RE_PER_M)
2192 },
2193 DragConditions::coasting(RE_PER_M).with_pod_motors([0.0, 0.0, 0.0, 1e-3]),
2194 DragConditions::thrusting(RE_PER_M, 0.0).with_pod_motors([0.0, -1e-3, 0.0, 0.0]),
2195 DragConditions::thrusting(RE_PER_M, 0.0).with_pod_motors([0.0, 0.0, f64::NAN, 0.0]),
2196 ] {
2197 assert!(m.drag(&flow, &conditions).is_err(), "{conditions:?}");
2198 }
2199 assert!(matches!(
2200 m.drag(&Flow::axial(5.0), &DragConditions::coasting(RE_PER_M)),
2201 Err(AeroError::Mach { .. })
2202 ));
2203
2204 let brick = one_stage(
2206 vec![component("block", body_part(0.1, 0.05, 0.05), None)],
2207 ReferenceDiameter::Custom { diameter_m: 0.01 },
2208 );
2209 let d = model(&brick)
2210 .drag(&flow, &DragConditions::coasting(RE_PER_M))
2211 .unwrap();
2212 let area_ratio = 100.0;
2213 close(
2214 d.pressure,
2215 step_by_hand(0.3) * area_ratio,
2216 1e-13,
2217 "flat face",
2218 );
2219 close(
2220 d.base,
2221 (0.12 + 0.13 * 0.09) * area_ratio,
2222 1e-14,
2223 "flat base",
2224 );
2225 assert!(d.zero_lift_coefficient > 90.0);
2226 }
2227
2228 #[test]
2234 fn leading_edge_and_cone_pressure_drag_have_supersonic_branches() {
2235 let sweep: f64 = 0.4;
2236 let c2 = sweep.cos().powi(2);
2237 let at_1 = fin_pressure_drag_coefficient(FinCrossSection::Airfoil, sweep, 1.0).unwrap();
2238 close(at_1, 0.8215 * c2, 1e-12, "rounded edge at Mach 1");
2239 for m in [1.5f64, 2.0, 3.0, 4.5] {
2240 let rounded = 1.214 - 0.502 / (m * m) + 0.1095 / m.powi(4);
2241 close(
2242 fin_pressure_drag_coefficient(FinCrossSection::Airfoil, sweep, m).unwrap(),
2243 rounded * c2,
2244 1e-14,
2245 "rounded, supersonic",
2246 );
2247 let i2 = 1.0 / (m * m);
2248 let stagnation = 0.85 * (1.84 - 0.76 * i2 + 0.166 * i2 * i2 + 0.035 * i2 * i2 * i2);
2249 close(
2250 fin_pressure_drag_coefficient(FinCrossSection::Square, sweep, m).unwrap(),
2251 stagnation * c2 + 0.25 / m,
2252 1e-14,
2253 "square, supersonic",
2254 );
2255 assert!((rounded * c2 - at_1).abs() > 0.05, "not frozen at Mach {m}");
2256 }
2257
2258 let (r, l) = (0.025, 0.15);
2260 let rocket = one_stage(
2261 vec![
2262 component("nose", nose(NoseShape::Conical {}, l, r), None),
2263 component("tube", body_part(0.8, r, r), None),
2264 ],
2265 ReferenceDiameter::Maximum {},
2266 );
2267 let m = model(&rocket);
2268 let nose_pressure = |mach: f64| {
2269 m.buildup_components(&Flow::axial(mach), &DragConditions::coasting(RE_PER_M))
2270 .unwrap()[0]
2271 .drag
2272 .pressure
2273 };
2274 let s = 1.0 / 37f64.sqrt();
2275 close(nose_pressure(0.0), 0.8 * s * s, 1e-14, "at rest");
2276 close(nose_pressure(1.0), s, 1e-14, "eq. B.6 at Mach 1");
2277 let mut previous = f64::INFINITY;
2278 for mach in [1.3f64, 1.5, 2.0, 3.0, 4.9] {
2279 let b4 = 2.1 * s * s + 0.5 * s / (mach * mach - 1.0).sqrt();
2280 let got = nose_pressure(mach);
2281 close(got, b4, 1e-14, "eq. B.4");
2282 assert!(got < previous, "falls past the peak: {got} at Mach {mach}");
2283 previous = got;
2284 }
2285 assert!(nose_pressure(4.9) > 2.1 * s * s);
2286 }
2287
2288 #[test]
2293 fn a_shape_without_drag_data_refuses_only_the_buildup() {
2294 let (r, l) = (0.03, 0.2);
2295 for shape in [
2296 NoseShape::Ogive { radius_ratio: 0.5 },
2297 NoseShape::Haack { parameter: 0.5 },
2298 ] {
2299 let rocket = one_stage(
2300 vec![
2301 component("nose", nose(shape, l, r), None),
2302 component("tube", body_part(0.8, r, r), None),
2303 ],
2304 ReferenceDiameter::Maximum {},
2305 );
2306 let m = model(&rocket);
2307 assert!(m.normal_force(&Flow::axial(0.3)).is_ok(), "{shape:?}");
2308 let conditions = DragConditions::coasting(RE_PER_M);
2309 let error = m.drag(&Flow::axial(0.3), &conditions).unwrap_err();
2310 assert!(
2311 matches!(&error, AeroError::InComponent { id, source }
2312 if id == "nose" && matches!(**source, AeroError::Unsupported(_))),
2313 "{shape:?}: {error}"
2314 );
2315 assert!(
2316 m.buildup_components(&Flow::axial(0.3), &conditions)
2317 .is_err()
2318 );
2319 let table = DragTable::from_csv("0,0.5\n2,0.5\n", None).unwrap();
2320 let with_table = m.with_drag_table(table);
2321 assert_eq!(
2322 with_table
2323 .drag(&Flow::axial(0.3), &conditions)
2324 .unwrap()
2325 .zero_lift_coefficient,
2326 0.5
2327 );
2328 }
2329 let wider = r * (1.0 + f64::EPSILON);
2331 let rocket = one_stage(
2332 vec![
2333 component("nose", nose(NoseShape::Conical {}, l, r), None),
2334 component("tube", body_part(0.4, r, r), None),
2335 component("flare", body_part(0.05, r, wider), None),
2336 component("aft", body_part(0.4, wider, wider), None),
2337 ],
2338 ReferenceDiameter::Maximum {},
2339 );
2340 let d = model(&rocket)
2341 .drag(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
2342 .unwrap();
2343 assert!(d.zero_lift_coefficient.is_finite());
2344 }
2345
2346 #[test]
2350 fn stagnation_pressure_limits() {
2351 assert_eq!(stagnation_pressure_ratio(0.0).unwrap(), 1.0);
2352 for m in [0.05, 0.1, 0.2] {
2353 let isentropic = ((1.0f64 + 0.2 * m * m).powf(3.5) - 1.0) / (0.7 * m * m);
2354 let got = stagnation_pressure_ratio(m).unwrap();
2355 assert!(
2356 (got - isentropic).abs() < m.powi(6),
2357 "M {m}: {got} vs {isentropic}"
2358 );
2359 }
2360 close(
2361 stagnation_pressure_ratio(1.0 - 1e-12).unwrap(),
2362 1.275,
2363 1e-11,
2364 "below Mach 1",
2365 );
2366 close(
2367 stagnation_pressure_ratio(1.0).unwrap(),
2368 1.281,
2369 1e-14,
2370 "at Mach 1",
2371 );
2372 close(
2373 stagnation_pressure_ratio(1e6).unwrap(),
2374 1.84,
2375 1e-11,
2376 "hypersonic limit",
2377 );
2378 assert_eq!(stagnation_drag_coefficient(0.0).unwrap(), 0.85);
2379 for bad in [-0.1, f64::NAN, f64::INFINITY] {
2380 assert!(matches!(
2381 stagnation_pressure_ratio(bad),
2382 Err(AeroError::Domain { .. })
2383 ));
2384 }
2385 }
2386
2387 #[test]
2390 fn base_joint_and_boattail_limits() {
2391 assert_eq!(base_drag_coefficient(0.0).unwrap(), 0.12);
2392 assert_eq!(base_drag_coefficient(2.0).unwrap(), 0.125);
2393 assert!(base_drag_coefficient(1e9).unwrap() < 1e-9);
2394
2395 assert_eq!(joint_pressure_drag_coefficient(0.0).unwrap(), 0.0);
2396 assert_eq!(joint_pressure_drag_coefficient(FRAC_PI_2).unwrap(), 0.8);
2397 close(
2398 joint_pressure_drag_coefficient(PI / 6.0).unwrap(),
2399 0.2,
2400 1e-15,
2401 "0.8 sin² 30°",
2402 );
2403 for bad in [-1e-9, FRAC_PI_2 + 1e-9, f64::NAN] {
2404 assert!(joint_pressure_drag_coefficient(bad).is_err());
2405 }
2406
2407 let factor = |l: f64| boattail_factor(l, 0.06, 0.04).unwrap();
2409 assert_eq!(factor(0.0), 1.0);
2410 close(factor(0.02), 1.0, 1e-15, "γ = 1");
2411 close(factor(0.03), 0.75, 1e-15, "γ = 1.5");
2412 close(factor(0.04), 0.5, 1e-15, "γ = 2");
2413 assert_eq!(factor(0.06), 0.0);
2414 assert_eq!(factor(1.0), 0.0);
2415 close(
2416 factor(0.02 * (1.0 + 1e-12)),
2417 1.0,
2418 1e-11,
2419 "continuous at γ = 1",
2420 );
2421 assert!(factor(0.06 * (1.0 - 1e-12)) < 1e-11, "continuous at γ = 3");
2422 assert!(boattail_factor(0.01, 0.04, 0.04).is_err());
2423 assert!(boattail_factor(0.01, 0.04, 0.06).is_err());
2424 assert!(boattail_factor(-0.01, 0.06, 0.04).is_err());
2425 }
2426
2427 #[test]
2431 fn fin_pressure_drag_limits() {
2432 let at =
2433 |section, sweep, mach| fin_pressure_drag_coefficient(section, sweep, mach).unwrap();
2434 assert_eq!(at(FinCrossSection::Square, 0.0, 0.0), 0.85 + 0.12);
2435 assert_eq!(at(FinCrossSection::Rounded, 0.0, 0.0), 0.06);
2436 assert_eq!(at(FinCrossSection::Airfoil, 0.0, 0.0), 0.0);
2437 let rounded_le = |m: f64| at(FinCrossSection::Airfoil, 0.0, m);
2438 close(rounded_le(0.9 - 1e-12), 0.998_76, 1e-5, "below Mach 0.9");
2439 assert_eq!(rounded_le(0.9), 1.0);
2440 close(rounded_le(1.0 - 1e-12), 0.8215, 1e-11, "below Mach 1");
2441 close(rounded_le(1.0), 0.8215, 1e-15, "at Mach 1");
2442 close(
2443 rounded_le(0.5),
2444 (0.75f64).powf(-0.417) - 1.0,
2445 1e-15,
2446 "eq. 3.89 at Mach 0.5",
2447 );
2448 close(rounded_le(1e6), 1.214, 1e-11, "supersonic limit");
2449 let sweep: f64 = 0.6;
2451 let c2 = sweep.cos() * sweep.cos();
2452 close(
2453 at(FinCrossSection::Square, sweep, 0.5),
2454 stagnation_drag_coefficient(0.5).unwrap() * c2 + base_drag_coefficient(0.5).unwrap(),
2455 1e-15,
2456 "square, swept",
2457 );
2458 close(
2459 at(FinCrossSection::Rounded, -sweep, 0.5),
2460 rounded_le(0.5) * c2 + 0.5 * base_drag_coefficient(0.5).unwrap(),
2461 1e-15,
2462 "rounded, swept forward",
2463 );
2464 for bad in [FRAC_PI_2, -FRAC_PI_2, f64::NAN] {
2465 assert!(fin_pressure_drag_coefficient(FinCrossSection::Square, bad, 0.3).is_err());
2466 }
2467 }
2468
2469 #[test]
2473 fn axial_drag_alpha_factor_limits() {
2474 let f = |deg: f64| axial_drag_alpha_factor(deg.to_radians()).unwrap();
2475 assert_eq!(f(0.0), 1.0);
2476 close(f(17.0), 1.3, 1e-15, "peak");
2477 assert!(f(90.0).abs() < 1e-15);
2478 let slope = |deg: f64| {
2479 let h = 1e-6;
2480 (f((deg + h).min(180.0)) - f((deg - h).max(0.0))) / (2.0 * h)
2481 };
2482 for deg in [0.0, 17.0, 90.0] {
2483 assert!(slope(deg).abs() < 1e-5, "slope {} at {deg}°", slope(deg));
2484 }
2485 let mut previous = f(0.0);
2486 for k in 1..=170 {
2487 let deg = 0.1 * f64::from(k);
2488 assert!(f(deg) > previous, "rising at {deg}°");
2489 previous = f(deg);
2490 }
2491 for k in 171..=900 {
2492 let deg = 0.1 * f64::from(k);
2493 assert!(f(deg) < previous, "falling at {deg}°");
2494 previous = f(deg);
2495 }
2496 for deg in [5.0, 17.0, 45.0, 89.0] {
2497 close(f(180.0 - deg), -f(deg), 1e-12, "mirror");
2498 }
2499 assert_eq!(f(180.0), -1.0);
2500 let rocket = one_stage(
2502 vec![
2503 component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
2504 component("tube", body_part(0.8, 0.03, 0.03), None),
2505 ],
2506 ReferenceDiameter::Maximum {},
2507 );
2508 let d = model(&rocket)
2509 .drag(
2510 &Flow::new(0.3, 135f64.to_radians(), 0.0),
2511 &DragConditions::coasting(RE_PER_M),
2512 )
2513 .unwrap();
2514 assert!(d.zero_lift_coefficient > 0.0 && d.axial_coefficient < 0.0);
2515 close(
2516 d.axial_coefficient,
2517 -d.zero_lift_coefficient * f(45.0),
2518 1e-14,
2519 "C_A at 135°",
2520 );
2521 assert!(axial_drag_alpha_factor(-1e-9).is_err());
2522 assert!(axial_drag_alpha_factor(PI + 1e-9).is_err());
2523 }
2524
2525 #[test]
2529 fn leading_edge_sweep_of_every_planform() {
2530 use crate::fins::FinGeometry;
2531 let trap = FinGeometry::from_planform(&trapezoid()).unwrap();
2532 close(
2533 trap.leading_edge_sweep_rad,
2534 (0.07f64 / 0.06).atan(),
2535 1e-15,
2536 "trapezoid",
2537 );
2538 let outline = FinPlanform::Freeform {
2539 points_m: vec![[0.0, 0.0], [0.07, 0.06], [0.12, 0.06], [0.12, 0.0]],
2540 root_m: Vec::new(),
2541 };
2542 let free = FinGeometry::from_planform(&outline).unwrap();
2543 close(
2544 free.leading_edge_sweep_rad,
2545 trap.leading_edge_sweep_rad,
2546 1e-12,
2547 "as freeform",
2548 );
2549 let kinked = FinPlanform::Freeform {
2551 points_m: vec![
2552 [0.0, 0.0],
2553 [0.0, 0.03],
2554 [0.03, 0.06],
2555 [0.1, 0.06],
2556 [0.1, 0.0],
2557 ],
2558 root_m: Vec::new(),
2559 };
2560 let kinked = FinGeometry::from_planform(&kinked).unwrap();
2561 close(
2562 kinked.leading_edge_sweep_rad,
2563 0.5 * PI / 4.0,
2564 1e-12,
2565 "kinked",
2566 );
2567 for (c_r, s) in [(0.1, 0.05), (0.1, 0.1), (0.2, 0.05), (0.01, 1.0)] {
2568 let e = FinGeometry::from_planform(&FinPlanform::Elliptical {
2569 root_chord_m: c_r,
2570 span_m: s,
2571 })
2572 .unwrap();
2573 let k: f64 = 0.5 * c_r / s;
2574 let n = 200_000;
2576 let h = FRAC_PI_2 / f64::from(n);
2577 let sum: f64 = (0..n)
2578 .map(|i| {
2579 let t = (f64::from(i) + 0.5) * h;
2580 (k * t.tan()).atan() * t.cos() * h
2581 })
2582 .sum();
2583 close(e.leading_edge_sweep_rad, sum, 1e-8, "elliptical average");
2584 }
2585 }
2586
2587 #[test]
2592 fn buildup_by_hand() {
2593 let (r, l_nose, l_tube) = (0.025, 0.25, 1.0);
2594 let mut tube = component("tube", body_part(l_tube, r, r), None);
2595 let mut fins = with_fin("fins", 3, 0.004, FinCrossSection::Square, 0.0);
2596 fins.finish = Some(Finish::PlanedWood {});
2597 tube.children = vec![
2598 fins,
2599 component(
2600 "lug",
2601 Part::LaunchLug(LaunchLug {
2602 length_m: 0.05,
2603 outer_radius_m: 0.004,
2604 thickness_m: 0.0005,
2605 angle_rad: 0.0,
2606 count: 1,
2607 spacing_m: 0.0,
2608 material: material(),
2609 }),
2610 Some(Position::Top { aft_offset_m: 0.2 }),
2611 ),
2612 ];
2613 let rocket = one_stage(
2614 vec![
2615 component("nose", nose(NoseShape::Conical {}, l_nose, r), None),
2616 tube,
2617 ],
2618 ReferenceDiameter::Maximum {},
2619 );
2620 let m = model(&rocket);
2621 let (mach, alpha, re_per_m) = (0.5, 5f64.to_radians(), 5e6);
2622 let flow = Flow::new(mach, alpha, 0.0);
2623 let conditions = DragConditions::coasting(re_per_m);
2624 let got = m.drag(&flow, &conditions).unwrap();
2625
2626 let length = l_nose + l_tube;
2627 let a_ref = PI * r * r;
2628 let re = re_per_m * length;
2629 let cf = |roughness: f64| {
2630 let rr: f64 = roughness / length;
2631 let critical = 51.0 * rr.powf(-1.039);
2632 let incompressible = if re < critical {
2633 1.0 / (1.50 * re.ln() - 5.6).powi(2)
2634 } else {
2635 0.032 * rr.powf(0.2)
2636 };
2637 incompressible * (1.0 - 0.1 * mach * mach)
2638 };
2639 let form = 1.0 + 1.0 / (2.0 * length / (2.0 * r));
2640 let body_friction = cf(20e-6) * form * (PI * r * l_nose + 2.0 * PI * r * l_tube) / a_ref;
2641 let (c_r, c_t, span) = (0.12, 0.05, 0.06);
2642 let fin_area = 0.5 * span * (c_r + c_t);
2643 let mac = 2.0 / 3.0 * (c_r * c_r + c_r * c_t + c_t * c_t) / (c_r + c_t);
2644 let fin_friction = cf(15e-6) * (1.0 + 2.0 * 0.004 / mac) * 2.0 * 3.0 * fin_area / a_ref;
2645 let stag = 0.85 * (1.0 + mach * mach / 4.0 + mach.powi(4) / 40.0);
2646 let c_base = 0.12 + 0.13 * mach * mach;
2647 let gamma_l = (0.07f64 / span).atan();
2648 let fin_pressure = (stag * gamma_l.cos().powi(2) + c_base) * 3.0 * 0.004 * span / a_ref;
2649 let s = (r / l_nose).atan().sin();
2651 let rest = 0.8 * s * s;
2652 let slope_at_1 = 4.0 / 2.4 * (1.0 - 0.5 * s);
2653 let b = slope_at_1 / (s - rest);
2654 let nose_pressure = (s - rest) * mach.powf(b) + rest;
2655 let base = c_base;
2656 let lug = stag * PI * 0.004 * 0.004 / a_ref;
2657 let cd0 = body_friction + fin_friction + fin_pressure + nose_pressure + base + lug;
2658
2659 close(
2660 got.friction,
2661 body_friction + fin_friction,
2662 1e-12,
2663 "friction",
2664 );
2665 close(
2666 got.pressure,
2667 fin_pressure + nose_pressure,
2668 1e-12,
2669 "pressure",
2670 );
2671 close(got.base, base, 1e-14, "base");
2672 close(got.parasitic, lug, 1e-14, "parasitic");
2673 close(got.zero_lift_coefficient, cd0, 1e-12, "C_D0");
2674 let t = 5.0 / 17.0;
2675 close(
2676 got.axial_coefficient,
2677 cd0 * (1.0 + 0.3 * t * t * (3.0 - 2.0 * t)),
2678 1e-12,
2679 "C_A",
2680 );
2681 assert_eq!(got.table, None);
2682
2683 let parts = m.buildup_components(&flow, &conditions).unwrap();
2684 let ids: Vec<&str> = parts.iter().map(|p| p.id.as_str()).collect();
2685 assert_eq!(ids, ["nose", "tube", "fins", "lug"]);
2686 let sum = |f: fn(&Drag) -> f64| parts.iter().map(|p| f(&p.drag)).sum::<f64>();
2687 close(
2688 sum(|d| d.zero_lift_coefficient),
2689 got.zero_lift_coefficient,
2690 1e-14,
2691 "C_D0 sum",
2692 );
2693 close(
2694 sum(|d| d.axial_coefficient),
2695 got.axial_coefficient,
2696 1e-14,
2697 "C_A sum",
2698 );
2699
2700 let unknown = m
2702 .drag(&flow, &DragConditions::thrusting(re_per_m, 0.0))
2703 .unwrap();
2704 assert_eq!(unknown.base, got.base);
2705
2706 let table = DragTable::from_csv("0.1,0.6\n1.2,0.9\n", None).unwrap();
2709 let o = m.clone().with_drag_table(table);
2710 let d = o.drag(&flow, &conditions).unwrap();
2711 close(
2712 d.zero_lift_coefficient,
2713 0.6 + 0.3 * 0.4 / 1.1,
2714 1e-14,
2715 "table C_D0",
2716 );
2717 close(
2718 d.axial_coefficient / d.zero_lift_coefficient,
2719 got.axial_coefficient / cd0,
2720 1e-14,
2721 "factor",
2722 );
2723 assert_eq!(
2724 (d.friction, d.pressure, d.base, d.parasitic),
2725 (0.0, 0.0, 0.0, 0.0)
2726 );
2727 let fast = o.drag(&Flow::axial(5.0), &conditions).unwrap();
2728 assert_eq!(fast.zero_lift_coefficient, 0.9);
2729 assert!(fast.table.unwrap().extrapolated.is_some());
2730 assert!(m.drag(&Flow::axial(5.0), &conditions).is_err());
2731 assert!(m.drag(&Flow::axial(4.99), &conditions).is_ok());
2732 assert!(o.drag(&Flow::new(0.5, 4.0, 0.0), &conditions).is_err());
2733 assert!(
2734 o.buildup_components(&Flow::axial(5.0), &conditions)
2735 .is_err()
2736 );
2737 }
2738
2739 #[test]
2742 fn a_boattail_split_in_two_drags_as_one() {
2743 let (big, small, l) = (0.03, 0.02, 0.04);
2744 let mid = 0.5 * (big + small);
2745 let rocket = |split: bool| {
2746 let mut components = vec![
2747 component(
2748 "nose",
2749 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2750 None,
2751 ),
2752 component("tube", body_part(0.8, big, big), None),
2753 ];
2754 if split {
2755 components.push(component("tail-a", body_part(0.5 * l, big, mid), None));
2756 components.push(component("tail-b", body_part(0.5 * l, mid, small), None));
2757 } else {
2758 components.push(component("tail", body_part(l, big, small), None));
2759 }
2760 model(&one_stage(components, ReferenceDiameter::Maximum {}))
2761 };
2762 let (one, two) = (rocket(false), rocket(true));
2763 let conditions = DragConditions::coasting(RE_PER_M);
2764 for mach in [0.3, 0.85, 0.95, 1.1, 1.5, 3.0, 4.9] {
2765 let (a, b) = (
2766 one.drag(&Flow::axial(mach), &conditions).unwrap(),
2767 two.drag(&Flow::axial(mach), &conditions).unwrap(),
2768 );
2769 close(
2770 b.zero_lift_coefficient,
2771 a.zero_lift_coefficient,
2772 1e-12,
2773 "total",
2774 );
2775 close(b.pressure, a.pressure, 1e-12, "pressure");
2776 close(b.base, a.base, 1e-12, "base");
2777 }
2778 let terms = two.drag_terms();
2781 let (a, b) = (
2782 terms[2].boattail.clone().unwrap(),
2783 terms[3].boattail.clone().unwrap(),
2784 );
2785 let whole = one.drag_terms()[2].boattail.clone().unwrap().own;
2786 assert!(a.merged.is_empty());
2787 assert_eq!(b.merged.len(), 1);
2788 let m = b.merged[0];
2789 assert_eq!(m.weight, 1.0);
2790 close(m.through_fore.length_m, a.own.length_m, 1e-12, "first part");
2791 close(
2792 m.through_fore.aft_diameter_m,
2793 a.own.aft_diameter_m,
2794 1e-12,
2795 "first part's end",
2796 );
2797 close(
2798 m.through_aft.length_m,
2799 whole.length_m,
2800 1e-12,
2801 "whole length",
2802 );
2803 close(
2804 m.through_aft.aft_diameter_m,
2805 whole.aft_diameter_m,
2806 1e-12,
2807 "whole end",
2808 );
2809 close(m.area_ratio, a.own_area_ratio, 1e-12, "same fore area");
2810 }
2811
2812 #[test]
2817 fn a_sharp_corner_keeps_its_boattails_apart() {
2818 let (big, small, tip) = (0.03, 0.02, 0.008);
2819 let l = (big - small) / 15f64.to_radians().tan();
2820 let rocket = |closure: Option<f64>, gap: Option<f64>| {
2821 let mut components = vec![
2822 component(
2823 "nose",
2824 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2825 None,
2826 ),
2827 component("tube", body_part(0.8, big, big), None),
2828 component("tail", body_part(l, big, small), None),
2829 ];
2830 if let Some(gap) = gap {
2831 components.push(component("gap", body_part(gap, small, small), None));
2832 }
2833 if let Some(length) = closure {
2834 components.push(component("closure", body_part(length, small, tip), None));
2835 }
2836 components.push(component("end", body_part(0.01, tip, tip), None));
2837 model(&one_stage(components, ReferenceDiameter::Maximum {}))
2838 };
2839 let conditions = DragConditions::coasting(RE_PER_M);
2840 let total = |m: &AeroModel, mach: f64| {
2841 m.drag(&Flow::axial(mach), &conditions)
2842 .unwrap()
2843 .zero_lift_coefficient
2844 };
2845 let (step, corner, apart) = (
2846 rocket(None, None),
2847 rocket(Some(1e-6), None),
2848 rocket(Some(1e-6), Some(1e-6)),
2849 );
2850 for mach in [0.3, 0.6, 0.9, 1.0, 1.5, 3.0] {
2851 let s = total(&step, mach);
2852 assert!((total(&corner, mach) - s).abs() < 2e-3 * s, "Mach {mach}");
2855 assert!(
2856 (total(&apart, mach) - total(&corner, mach)).abs() < 1e-5 * s,
2857 "Mach {mach}"
2858 );
2859 }
2860 let pair = |turn_deg: f64| {
2862 let (first, second) = (6f64.to_radians(), (6.0 + turn_deg).to_radians());
2863 let mid = big - 0.01 * first.tan();
2864 let end = mid - 0.01 * second.tan();
2865 let m = model(&one_stage(
2866 vec![
2867 component(
2868 "nose",
2869 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2870 None,
2871 ),
2872 component("tube", body_part(0.8, big, big), None),
2873 component("a", body_part(0.01, big, mid), None),
2874 component("b", body_part(0.01, mid, end), None),
2875 ],
2876 ReferenceDiameter::Maximum {},
2877 ));
2878 let merges = !m.drag_terms()[3]
2879 .boattail
2880 .clone()
2881 .unwrap()
2882 .merged
2883 .is_empty();
2884 (m, merges)
2885 };
2886 assert!(pair(2.0).1 && pair(9.0).1 && !pair(10.5).1);
2887 for edge in [3.0, 10.0] {
2888 for mach in [0.5, 1.5, 2.5] {
2889 let (a, b) = (
2890 total(&pair(edge - 1e-7).0, mach),
2891 total(&pair(edge + 1e-7).0, mach),
2892 );
2893 assert!(
2894 (a - b).abs() < 1e-7 * a,
2895 "turn {edge}° at Mach {mach}: {a} against {b}"
2896 );
2897 }
2898 }
2899 }
2900
2901 #[test]
2905 fn a_lip_drawn_as_a_step_up_is_a_lip() {
2906 let (big, small, lip, l) = (0.03, 0.02, 0.0215, 0.04);
2907 let rocket = |shoulder: bool| {
2908 let mut components = vec![
2909 component(
2910 "nose",
2911 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2912 None,
2913 ),
2914 component("tube", body_part(0.8, big, big), None),
2915 component("tail", body_part(l, big, small), None),
2916 ];
2917 if shoulder {
2918 components.push(component("rise", body_part(1e-6, small, lip), None));
2919 }
2920 components.push(component("lip", body_part(0.00135, lip, lip), None));
2921 model(&one_stage(components, ReferenceDiameter::Maximum {}))
2922 };
2923 let (step, shoulder) = (rocket(false), rocket(true));
2924 let conditions = DragConditions::coasting(RE_PER_M);
2925 for mach in [0.6, 0.95, 1.5, 3.0] {
2926 let total = |m: &AeroModel| {
2927 m.drag(&Flow::axial(mach), &conditions)
2928 .unwrap()
2929 .zero_lift_coefficient
2930 };
2931 let (a, b) = (total(&step), total(&shoulder));
2932 assert!((a - b).abs() < 1e-3 * a, "Mach {mach}: {a} against {b}");
2933 }
2934 let terms = step.drag_terms();
2935 let last = terms.iter().find(|t| t.id == "lip").unwrap();
2936 assert!(last.step.is_some() && last.in_wake_of.unwrap().step_fraction == 1.0);
2937 }
2938
2939 #[test]
2945 fn soft_merges_stay_between_their_limits() {
2946 let big = 0.03;
2947 let conditions = DragConditions::coasting(RE_PER_M);
2948 let rocket = |parts: &[(&str, f64, f64, f64)]| {
2949 let mut components = vec![
2950 component(
2951 "nose",
2952 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2953 None,
2954 ),
2955 component("tube", body_part(0.8, big, big), None),
2956 ];
2957 for &(id, length, fore, aft) in parts {
2958 components.push(component(id, body_part(length, fore, aft), None));
2959 }
2960 model(&one_stage(components, ReferenceDiameter::Maximum {}))
2961 };
2962 for turn in (-24..=24).map(|t| f64::from(t) * 0.5) {
2963 let (first, second) = (15f64.to_radians(), (15.0 + turn).to_radians());
2964 let mid = big - 0.01 * first.tan();
2965 let end = mid - 0.01 * second.tan();
2966 let joined = rocket(&[("a", 0.01, big, mid), ("b", 0.01, mid, end)]);
2967 let apart = rocket(&[
2969 ("a", 0.01, big, mid),
2970 ("gap", 0.05, mid, mid),
2971 ("b", 0.01, mid, end),
2972 ]);
2973 let whole = rocket(&[("ab", 0.02, big, end)]);
2975 let weight: f64 = joined.drag_terms()[3]
2976 .boattail
2977 .as_ref()
2978 .unwrap()
2979 .merged
2980 .iter()
2981 .map(|m| m.weight)
2982 .sum();
2983 for step in 0..98 {
2984 let mach = f64::from(step) * 0.05;
2985 let flow = Flow::axial(mach);
2986 let parts = joined.buildup_components(&flow, &conditions).unwrap();
2987 let what = format!("turn {turn}° at Mach {mach:.2}");
2988 let got = parts[2].drag.pressure + parts[3].drag.pressure;
2989 assert!(got >= 0.0, "{what}");
2990 let separate = apart.buildup_components(&flow, &conditions).unwrap();
2991 let (own_a, own_b) = (separate[2].drag.pressure, separate[4].drag.pressure);
2992 let cone = whole.buildup_components(&flow, &conditions).unwrap()[2]
2993 .drag
2994 .pressure;
2995 let merged = cone;
2996 if turn == 0.0 {
2997 assert_eq!(weight, 1.0, "{what}");
2998 assert!((got - cone).abs() <= 1e-12 * cone.max(1e-3), "{what}");
2999 } else if weight == 0.0 {
3000 assert!((got - own_a - own_b).abs() <= 1e-12, "{what}");
3001 }
3002 let (lo, hi) = ((own_a + own_b).min(merged), (own_a + own_b).max(merged));
3003 assert!(
3004 got >= lo - 1e-12 && got <= hi + 1e-12,
3005 "{what}: {got} not in [{lo}, {hi}]"
3006 );
3007 }
3008 }
3009 }
3010
3011 #[test]
3017 fn a_part_narrowing_by_nothing_is_a_tube_and_one_of_no_length_a_step() {
3018 let (big, l) = (0.03, 0.04);
3019 let conditions = DragConditions::coasting(RE_PER_M);
3020 let total = |m: &AeroModel, mach: f64| {
3021 m.drag(&Flow::axial(mach), &conditions)
3022 .unwrap()
3023 .zero_lift_coefficient
3024 };
3025 type Case = fn(f64, f64, f64, f64) -> (f64, Vec<(f64, f64, f64)>);
3026 let cases: [(&str, Case); 8] = [
3029 ("a spacer narrowing by ε before a lip", |e, s, lip, _| {
3030 (0.0, vec![(0.001, s, s - e), (0.0013, s - e, lip)])
3031 }),
3032 ("an aft section narrowing by ε", |e, s, _, _| {
3033 (0.0, vec![(0.1, s, s - e)])
3034 }),
3035 ("an aft section widening by ε", |e, s, _, _| {
3036 (0.0, vec![(0.1, s, s + e)])
3037 }),
3038 ("a step down drawn ε long", |e, s, _, d| {
3039 let low = s - 0.05 * d;
3040 let mut parts = vec![(0.005, low, low)];
3041 if e > 0.0 {
3042 parts.insert(0, (e, s, low));
3043 }
3044 (0.0, parts)
3045 }),
3046 ("a step down drawn ε long, then a lip", |e, s, _, d| {
3047 let low = s - 0.25 * d;
3048 let mut parts = vec![(0.003, low, low + 0.05 * d)];
3049 if e > 0.0 {
3050 parts.insert(0, (e, s, low));
3051 }
3052 (0.0, parts)
3053 }),
3054 ("a step up and a shoulder ε apart", |e, s, _, d| {
3055 let (top, high) = (s + 0.1 * d, s + 0.3 * d);
3056 let mut parts = vec![(0.002, top, high)];
3057 if e > 0.0 {
3058 parts.insert(0, (e, top, top));
3059 }
3060 (0.0, parts)
3061 }),
3062 (
3063 "a gap of ε before a boattail's second part",
3064 |e, s, _, _| {
3065 let end = s - 0.005 * 8f64.to_radians().tan();
3066 let mut parts = vec![(0.005, s, end)];
3067 if e > 0.0 {
3068 parts.insert(0, (e, s, s));
3069 }
3070 (0.0, parts)
3071 },
3072 ),
3073 ("the tube ahead tapered by ε", |e, _, _, _| (e, vec![])),
3074 ];
3075 for angle in [2.0_f64, 5.0, 9.0, 14.0] {
3076 let small = big - l * angle.to_radians().tan();
3077 let drop = 2.0 * (big - small);
3078 let lip = small + 0.5 * 0.17 * drop;
3079 let rocket = |(taper, parts): (f64, Vec<(f64, f64, f64)>)| {
3080 let mut components = vec![
3081 component(
3082 "nose",
3083 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3084 None,
3085 ),
3086 component("tube", body_part(0.8, big, big - taper), None),
3087 component("tail", body_part(l, big - taper, small), None),
3088 ];
3089 for (i, &(length, fore, aft)) in parts.iter().enumerate() {
3090 components.push(component(
3091 &format!("p{i}"),
3092 body_part(length, fore, aft),
3093 None,
3094 ));
3095 }
3096 model(&one_stage(components, ReferenceDiameter::Maximum {}))
3097 };
3098 for (what, case) in cases {
3099 let exact = rocket(case(0.0, small, lip, drop));
3100 for mach in [0.5, 0.95, 1.5, 3.0, 4.5] {
3101 if what.starts_with("a step down") && mach == 0.95 {
3106 continue;
3107 }
3108 let base = total(&exact, mach);
3109 let off =
3110 |e: f64| (total(&rocket(case(e, small, lip, drop)), mach) - base).abs();
3111 let (coarse, fine) = (off(1e-6), off(1e-8));
3112 let at = format!("{what} behind {angle}° at Mach {mach}");
3113 assert!(coarse < 1e-3 * base, "{at}: {coarse}");
3114 assert!(
3115 fine <= 0.02 * coarse + 1e-13,
3116 "{at}: {fine} against {coarse}"
3117 );
3118 }
3119 }
3120 }
3121 let (aft, tip) = (big - 0.02 * 8f64.to_radians().tan(), 0.02);
3124 let mid = big - 0.01 * 8f64.to_radians().tan();
3125 let gap = 0.3 * 2.0 * (big - aft);
3126 let end = aft - 0.01 * 12f64.to_radians().tan();
3127 let cone = |parts: &[(f64, f64, f64)]| {
3128 let mut components = vec![
3129 component(
3130 "nose",
3131 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3132 None,
3133 ),
3134 component("tube", body_part(0.8, big, big), None),
3135 ];
3136 for (i, &(length, fore, aft)) in parts.iter().enumerate() {
3137 components.push(component(
3138 &format!("p{i}"),
3139 body_part(length, fore, aft),
3140 None,
3141 ));
3142 }
3143 components.push(component("end", body_part(0.01, tip, tip), None));
3144 model(&one_stage(components, ReferenceDiameter::Maximum {}))
3145 };
3146 let one = cone(&[(0.02, big, aft), (gap, aft, aft), (0.01, aft, end)]);
3147 let two = cone(&[
3148 (0.01, big, mid),
3149 (0.01, mid, aft),
3150 (gap, aft, aft),
3151 (0.01, aft, end),
3152 ]);
3153 assert!(
3154 !one.drag_terms()[4]
3155 .boattail
3156 .as_ref()
3157 .unwrap()
3158 .merged
3159 .is_empty()
3160 );
3161 for mach in [0.5, 1.5, 3.0] {
3162 let (a, b) = (total(&one, mach), total(&two, mach));
3163 assert!(
3164 (a - b).abs() < 1e-12 * a,
3165 "cone in parts at Mach {mach}: {a} against {b}"
3166 );
3167 }
3168 }
3169
3170 #[test]
3174 fn a_partial_merge_shares_the_flow() {
3175 let big = 0.03;
3176 let (first, length) = (5f64.to_radians(), 0.01);
3177 let mid = big - length * first.tan();
3178 for turn in (0..=24).map(|t| f64::from(t) * 0.5) {
3179 let second = first + turn.to_radians();
3180 let end = mid - length * second.tan();
3181 let drop = 2.0 * (big - end);
3182 let rocket = |lip: bool| {
3183 let mut components = vec![
3184 component(
3185 "nose",
3186 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3187 None,
3188 ),
3189 component("tube", body_part(0.8, big, big), None),
3190 component("a", body_part(length, big, mid), None),
3191 component("b", body_part(length, mid, end), None),
3192 ];
3193 if lip {
3194 components.push(component("lip", body_part(1e-4, end, end), None));
3195 let top = end + 0.5 * 0.1 * 2.0 * (mid - end);
3196 components.push(component("rise", body_part(1e-4, end, top), None));
3197 }
3198 model(&one_stage(components, ReferenceDiameter::Maximum {}))
3199 };
3200 let what = format!("turn {turn}°, drop {drop}");
3201 let bare = rocket(false);
3202 let sources = &bare.drag_terms()[3].base_behind.as_ref().unwrap().sources;
3203 let shares: f64 = sources.iter().map(|s| s.weight).sum();
3204 assert!((shares - 1.0).abs() < 1e-12, "{what}: {shares}");
3205 let with_lip = rocket(true);
3206 let wake = with_lip.drag_terms()[5].in_wake_of.unwrap();
3207 let least = 1.0 - 1e-4 / (2.0 * (mid - end));
3210 assert!(
3211 wake.shoulder_fraction >= least - 1e-12 && wake.shoulder_fraction <= 1.0,
3212 "{what}: {}",
3213 wake.shoulder_fraction
3214 );
3215 }
3216 }
3217
3218 #[test]
3222 fn a_zigzag_boattail_keeps_its_tails_few() {
3223 let big = 0.03;
3224 let mut components = vec![
3225 component(
3226 "nose",
3227 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3228 None,
3229 ),
3230 component("tube", body_part(0.8, big, big), None),
3231 ];
3232 let mut r = big;
3233 let parts = 40;
3234 for i in 0..parts {
3235 let angle = if i % 2 == 0 { 12f64 } else { 5.0 };
3236 let next = r - 0.0005 * angle.to_radians().tan();
3237 components.push(component(
3238 &format!("z{i}"),
3239 body_part(0.0005, r, next),
3240 None,
3241 ));
3242 r = next;
3243 }
3244 super::PEAK_TAILS.with(|p| p.set(0));
3245 let m = model(&one_stage(components, ReferenceDiameter::Maximum {}));
3246 let peak = super::PEAK_TAILS.with(std::cell::Cell::get);
3248 assert!(peak <= 2 * parts, "{peak} tails");
3249 let terms = m.drag_terms();
3250 for t in terms.iter().skip(2) {
3251 let merges = t.boattail.as_ref().map_or(0, |b| b.merged.len());
3252 assert!(merges <= parts, "{}: {merges}", t.id);
3253 }
3254 let sources = terms
3255 .last()
3256 .unwrap()
3257 .base_behind
3258 .as_ref()
3259 .unwrap()
3260 .sources
3261 .len();
3262 assert!(sources <= 2 * parts, "{sources}");
3264 let drag = m
3265 .drag(&Flow::axial(1.5), &DragConditions::coasting(RE_PER_M))
3266 .unwrap();
3267 assert!(drag.zero_lift_coefficient.is_finite());
3268 }
3269
3270 #[test]
3276 fn a_straight_cone_in_parts_is_one_cone() {
3277 let conditions = DragConditions::coasting(RE_PER_M);
3278 let cones = [
3280 (0.03, 0.8_f64, 0.03 - 0.3 * 0.8_f64.to_radians().tan()),
3281 (0.03, 0.5, 0.03 - 0.3 * 0.5_f64.to_radians().tan()),
3282 (0.049, 7.0, 0.022),
3283 (0.049, 5.0, 0.049 / 8.0),
3284 ];
3285 for (big, angle, end) in cones {
3286 let length = (big - end) / angle.to_radians().tan();
3287 let rocket = |parts: usize| {
3288 let mut components = vec![
3289 component(
3290 "nose",
3291 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3292 None,
3293 ),
3294 component("tube", body_part(0.8, big, big), None),
3295 ];
3296 let at = |k: usize| big - (big - end) * k as f64 / parts as f64;
3297 for k in 0..parts {
3298 components.push(component(
3299 &format!("c{k}"),
3300 body_part(length / parts as f64, at(k), at(k + 1)),
3301 None,
3302 ));
3303 }
3304 model(&one_stage(components, ReferenceDiameter::Maximum {}))
3305 };
3306 let one = rocket(1);
3307 for parts in [2, 4, 8] {
3308 let split = rocket(parts);
3309 for t in split.drag_terms().iter().skip(3) {
3311 let term = t.boattail.as_ref().unwrap();
3312 let merged: f64 = term.merged.iter().map(|m| m.weight).sum();
3313 assert!(
3314 term.own_weight < 1e-12 && (merged - 1.0).abs() < 1e-12,
3315 "{}",
3316 t.id
3317 );
3318 }
3319 for mach in [0.5, 0.95, 1.0, 1.2, 1.3, 1.5, 3.0] {
3320 let (a, b) = (
3321 one.drag(&Flow::axial(mach), &conditions).unwrap(),
3322 split.drag(&Flow::axial(mach), &conditions).unwrap(),
3323 );
3324 let what = format!("{angle}° in {parts} at Mach {mach}");
3325 close(
3326 b.zero_lift_coefficient,
3327 a.zero_lift_coefficient,
3328 1e-12,
3329 &what,
3330 );
3331 }
3332 }
3333 }
3334 }
3335
3336 #[test]
3342 fn a_retainer_behind_a_step_down_is_in_its_wake() {
3343 let (body, motor, retainer) = (0.049, 0.027, 0.031);
3344 let m = model(&one_stage(
3345 vec![
3346 component(
3347 "nose",
3348 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.3, body),
3349 None,
3350 ),
3351 component("tube", body_part(1.0, body, body), None),
3352 component("motor", body_part(0.012, motor, motor), None),
3353 component("retainer", body_part(0.02, retainer, retainer), None),
3354 ],
3355 ReferenceDiameter::Maximum {},
3356 ));
3357 let terms = m.drag_terms();
3358 let wake = terms[3].in_wake_of.unwrap();
3359 let fall = 2.0 * (body - motor);
3360 close(
3361 wake.step_fraction,
3362 1.0 - 0.012 / fall,
3363 1e-12,
3364 "step fraction",
3365 );
3366 assert_eq!(wake.shoulder_fraction, 0.0);
3367 assert!(wake.boattail.half_angle_rad > 1.57);
3368 }
3369
3370 #[test]
3374 fn a_hairline_step_before_a_lip_changes_nothing() {
3375 let (big, small, lip, l) = (0.03, 0.02, 0.0215, 0.04);
3376 let rocket = |step: Option<f64>| {
3377 let mut components = vec![
3378 component(
3379 "nose",
3380 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3381 None,
3382 ),
3383 component("tube", body_part(0.8, big, big), None),
3384 component("tail", body_part(l, big, small), None),
3385 ];
3386 if let Some(step) = step {
3387 components.push(component(
3388 "hair",
3389 body_part(0.001, small + step, small + step),
3390 None,
3391 ));
3392 } else {
3393 components.push(component("hair", body_part(0.001, small, small), None));
3394 }
3395 components.push(component("lip", body_part(0.00135, small, lip), None));
3396 model(&one_stage(components, ReferenceDiameter::Maximum {}))
3397 };
3398 let conditions = DragConditions::coasting(RE_PER_M);
3399 let exact = rocket(None);
3400 for mach in [0.6, 1.5, 3.0] {
3401 let total = |m: &AeroModel| {
3402 m.drag(&Flow::axial(mach), &conditions)
3403 .unwrap()
3404 .zero_lift_coefficient
3405 };
3406 let base = total(&exact);
3407 for step in [1e-5, 1e-7, 2e-9] {
3408 let got = total(&rocket(Some(step)));
3409 assert!(
3410 (got - base).abs() < 1e-3 * base,
3411 "step {step} at Mach {mach}: {got} against {base}"
3412 );
3413 }
3414 }
3415 }
3416
3417 #[test]
3423 fn a_lip_in_a_boattails_wake_fades_with_its_rise() {
3424 let (big, small, l) = (0.03, 0.02, 0.04);
3425 let drop = 2.0 * (big - small);
3426 let rocket = |rise: f64, gap: Option<f64>, step: f64| {
3428 let lip_fore = small + step;
3429 let mut components = vec![
3430 component(
3431 "nose",
3432 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3433 None,
3434 ),
3435 component("tube", body_part(0.8, big, big), None),
3436 component("tail", body_part(l, big, small), None),
3437 ];
3438 if let Some(gap) = gap {
3439 components.push(component("gap", body_part(gap, small, small), None));
3440 }
3441 components.push(component(
3442 "lip",
3443 body_part(0.001, lip_fore, small + 0.5 * rise * drop),
3444 None,
3445 ));
3446 model(&one_stage(components, ReferenceDiameter::Maximum {}))
3447 };
3448 let conditions = DragConditions::coasting(RE_PER_M);
3449 let lip = |m: &AeroModel, mach: f64| {
3450 let parts = m
3451 .buildup_components(&Flow::axial(mach), &conditions)
3452 .unwrap();
3453 let part = parts.iter().find(|p| p.id == "lip").unwrap();
3454 (part.drag.pressure, part.drag.base)
3455 };
3456 let fraction_of = |m: &AeroModel| {
3457 let terms = m.drag_terms();
3458 let last = terms.iter().find(|t| t.id == "lip").unwrap();
3459 (
3460 last.in_wake_of.map_or(0.0, |w| w.shoulder_fraction),
3461 last.base_behind
3462 .as_ref()
3463 .map_or(0.0, |b| b.sources.iter().map(|s| s.weight).sum::<f64>()),
3464 )
3465 };
3466 let lip_gap = 1.0 - 0.001 / drop;
3468 for mach in [0.5, 1.5, 3.0] {
3469 for (rise, fraction) in [
3470 (0.1, 1.0),
3471 (0.25, 1.0),
3472 (0.375, 0.5),
3473 (0.5, 0.0),
3474 (0.7, 0.0),
3475 ] {
3476 let (inside, outside) = (rocket(rise, None, 0.0), rocket(rise, Some(0.05), 0.0));
3478 let free = lip(&outside, mach).0;
3479 assert!(free > 0.0 && fraction_of(&outside) == (0.0, 0.0));
3480 let what = format!("rise {rise} at Mach {mach}");
3481 let want = (1.0 - fraction) * free;
3482 assert!((lip(&inside, mach).0 - want).abs() < 1e-9 * free, "{what}");
3483 let (wake, weight) = fraction_of(&inside);
3484 assert!((wake - fraction).abs() < 1e-9, "{what}: {wake}");
3485 assert!(
3486 (weight - fraction * lip_gap).abs() < 1e-9,
3487 "{what}: {weight}"
3488 );
3489 }
3490 let (wake, weight) = fraction_of(&rocket(0.1, Some(0.5 * drop), 0.0));
3492 assert!((wake - 0.5).abs() < 1e-9 && (weight - (lip_gap - 0.5)).abs() < 1e-9);
3493 for edge in [0.25, 0.5] {
3495 let below = lip(&rocket(edge - 1e-9, None, 0.0), mach);
3496 let above = lip(&rocket(edge + 1e-9, None, 0.0), mach);
3497 assert!((below.0 - above.0).abs() < 1e-8, "pressure at {edge}");
3498 assert!((below.1 - above.1).abs() < 1e-8, "base at {edge}");
3499 }
3500 let exact = rocket(0.1, None, 0.0);
3502 let total = |m: &AeroModel| {
3503 m.drag(&Flow::axial(mach), &conditions)
3504 .unwrap()
3505 .zero_lift_coefficient
3506 };
3507 for other in [
3508 rocket(0.1, None, 1e-6),
3509 rocket(0.1, None, -1e-6),
3510 rocket(0.1, Some(1e-6), 0.0),
3511 ] {
3512 let (a, b) = (total(&exact), total(&other));
3513 assert!((a - b).abs() < 1e-3 * a, "Mach {mach}: {a} against {b}");
3514 }
3515 }
3516 }
3517
3518 #[test]
3522 fn curved_boattails_build_and_use_the_boattail_rule() {
3523 let (big, small, l) = (0.03, 0.02, 0.04);
3524 let a_ref = PI * big * big;
3525 let delta = PI * (big * big - small * small);
3526 let c_base = 0.12 + 0.13 * 0.09;
3527 for shape in [
3528 NoseShape::Elliptical {},
3529 NoseShape::Haack { parameter: 0.0 },
3530 NoseShape::PowerSeries { exponent: 0.5 },
3531 NoseShape::Conical {},
3532 ] {
3533 let mut tail = body_part(l, big, small);
3534 if let Part::Transition(t) = &mut tail {
3535 t.shape = shape;
3536 }
3537 let rocket = one_stage(
3538 vec![
3539 component(
3540 "nose",
3541 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3542 None,
3543 ),
3544 component("tube", body_part(0.8, big, big), None),
3545 component("tail", tail, None),
3546 ],
3547 ReferenceDiameter::Maximum {},
3548 );
3549 let m = model(&rocket);
3550 let tail = &m.bodies()[2];
3551 assert!(tail.geometry.aft_angle_rad <= 0.0, "{shape:?}");
3552 let parts = m
3553 .buildup_components(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
3554 .unwrap();
3555 close(
3557 parts[2].drag.pressure,
3558 0.5 * c_base * delta / a_ref,
3559 1e-14,
3560 "boattail",
3561 );
3562 close(
3563 parts[2].drag.base,
3564 c_base * PI * small * small / a_ref,
3565 1e-14,
3566 "base",
3567 );
3568 }
3569 }
3570
3571 #[test]
3573 fn drag_table_on_another_reference_diameter_is_rescaled() {
3574 let rocket = one_stage(
3575 vec![
3576 component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
3577 component("tube", body_part(0.8, 0.03, 0.03), None),
3578 ],
3579 ReferenceDiameter::Maximum {},
3580 );
3581 let m = model(&rocket);
3582 let conditions = DragConditions::coasting(RE_PER_M);
3583 let flow = Flow::axial(0.3);
3584 let table = DragTable::from_csv("0,0.5\n1,0.5\n", None).unwrap();
3585 let same = m.clone().with_drag_table(table.clone());
3586 assert_eq!(
3587 same.drag(&flow, &conditions).unwrap().zero_lift_coefficient,
3588 0.5
3589 );
3590 let wider = m
3591 .clone()
3592 .with_drag_table(table.clone().with_reference_diameter_m(0.09).unwrap());
3593 close(
3594 wider
3595 .drag(&flow, &conditions)
3596 .unwrap()
3597 .zero_lift_coefficient,
3598 0.5 * 2.25,
3599 1e-14,
3600 "a 90 mm reference on a 60 mm rocket",
3601 );
3602 for diameter_m in [0.0, -0.09, f64::INFINITY, f64::NAN] {
3605 let refused = table.clone().with_reference_diameter_m(diameter_m);
3606 assert!(
3607 matches!(&refused, Err(AeroError::Domain { what, .. })
3608 if *what == "drag table reference diameter"),
3609 "{diameter_m}: {refused:?}"
3610 );
3611 }
3612 let mut set = table;
3613 set.reference_diameter_m = Some(0.0);
3614 let bad = m.with_drag_table(set);
3615 assert!(matches!(
3616 bad.drag(&flow, &conditions),
3617 Err(AeroError::Domain { .. })
3618 ));
3619 }
3620
3621 #[test]
3625 fn joint_angles_from_the_profile_and_a_closed_tail() {
3626 let (r, l) = (0.03, 0.24);
3627 let a_ref = PI * r * r;
3628 let nose_pressure = |shape: NoseShape| {
3629 let rocket = one_stage(
3630 vec![
3631 component("nose", nose(shape, l, r), None),
3632 component("tube", body_part(0.8, r, r), None),
3633 ],
3634 ReferenceDiameter::Maximum {},
3635 );
3636 model(&rocket)
3637 .buildup_components(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
3638 .unwrap()[0]
3639 .drag
3640 .pressure
3641 };
3642 let phi = (0.5 * r / l).atan();
3645 let rest = 0.8 * phi.sin().powi(2);
3646 close(
3647 nose_pressure(NoseShape::PowerSeries { exponent: 0.5 }),
3648 rest * (1.0 - (0.3f64 / 0.8).powi(2)),
3649 1e-12,
3650 "x^0.5 nose",
3651 );
3652 let s = (r / l).atan().sin();
3653 let rest = 0.8 * s * s;
3654 let b = 4.0 / 2.4 * (1.0 - 0.5 * s) / (s - rest);
3655 close(
3656 nose_pressure(NoseShape::Conical {}),
3657 rest + (s - rest) * 0.3f64.powf(b),
3658 1e-12,
3659 "cone",
3660 );
3661 assert!(nose_pressure(NoseShape::Haack { parameter: 0.0 }) < 1e-20);
3664 let s = (0.125f64).atan().sin();
3665 let b = 4.0 / 2.4 * (1.0 - 0.5 * s) / s;
3666 close(
3667 nose_pressure(NoseShape::Ogive { radius_ratio: 1.0 }),
3668 s * 0.3f64.powf(b),
3669 1e-12,
3670 "tangent ogive",
3671 );
3672
3673 let rocket = one_stage(
3675 vec![
3676 component(
3677 "nose",
3678 nose(NoseShape::Ogive { radius_ratio: 1.0 }, l, r),
3679 None,
3680 ),
3681 component("tube", body_part(0.8, r, r), None),
3682 component("tail", body_part(0.1, r, 0.0), None),
3683 ],
3684 ReferenceDiameter::Maximum {},
3685 );
3686 let parts = model(&rocket)
3687 .buildup_components(
3688 &Flow::axial(0.3),
3689 &DragConditions::thrusting(RE_PER_M, 1e-3),
3690 )
3691 .unwrap();
3692 let c_base = 0.12 + 0.13 * 0.09;
3693 let gamma: f64 = 0.1 / 0.06;
3694 close(
3695 parts[2].drag.pressure,
3696 0.5 * (3.0 - gamma) * c_base * a_ref / a_ref,
3697 1e-14,
3698 "tail",
3699 );
3700 assert_eq!(parts[2].drag.base, 0.0);
3701 }
3702
3703 #[test]
3723 fn supersonic_cd_against_rasaero_tables() {
3724 use serde::Deserialize;
3725
3726 #[derive(Deserialize)]
3727 struct Fixture {
3728 tolerance_rel: f64,
3729 cases: Vec<Case>,
3730 }
3731 #[derive(Deserialize)]
3732 struct Case {
3733 id: String,
3734 design: String,
3735 curve: String,
3736 thrusting: bool,
3737 curve_cd0: f64,
3738 sweep: Sweep,
3739 }
3740 #[derive(Deserialize)]
3741 struct Sweep {
3742 usable_to_mach: Option<f64>,
3743 bands: Vec<Band>,
3744 rows: Vec<Row>,
3745 }
3746 #[derive(Deserialize)]
3747 struct Band {
3748 band: String,
3749 rows: usize,
3750 within_target: usize,
3751 min_error: f64,
3752 max_error: f64,
3753 rms_error: f64,
3754 }
3755 #[derive(Deserialize)]
3756 struct Row {
3757 mach: f64,
3758 band: String,
3759 hpr_cd0: f64,
3760 relative_error: f64,
3761 within_target: bool,
3762 }
3763
3764 let fixture: Fixture = serde_json::from_str(include_str!(
3765 "../../../validation/fixtures/aero/rocketpy-drag-curves.json"
3766 ))
3767 .unwrap();
3768 assert_eq!(fixture.tolerance_rel, 0.10);
3769 let air = hpr_atmos::Ussa76::standard().sample(0.0).unwrap().air;
3770 let band_of = |mach: f64| {
3771 if mach <= SUBSONIC_MACH_LIMIT {
3772 "subsonic"
3773 } else if mach < 1.2 {
3774 "transonic"
3775 } else {
3776 "supersonic"
3777 }
3778 };
3779 let mut within = Vec::new();
3780 let mut curves: std::collections::BTreeMap<&str, Vec<Vec<f64>>> =
3782 std::collections::BTreeMap::new();
3783 for case in &fixture.cases {
3784 let rocket = crate::testing::committed_design(&case.design);
3785 let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
3786 let motor_area: f64 = if case.thrusting {
3787 rocket.configurations[0]
3788 .motors
3789 .iter()
3790 .map(|m| 0.25 * PI * m.diameter_m * m.diameter_m)
3791 .sum()
3792 } else {
3793 0.0
3794 };
3795 let rows = &case.sweep.rows;
3796 for (i, row) in rows.iter().enumerate() {
3798 assert_eq!(row.mach, f64::from(i as u32 + 2) / 20.0, "{}", case.id);
3799 assert!(
3800 row.mach <= case.sweep.usable_to_mach.unwrap_or(2.0),
3801 "{}@{}",
3802 case.id,
3803 row.mach
3804 );
3805 assert_eq!(row.band, band_of(row.mach), "{}@{}", case.id, row.mach);
3806 let reynolds_per_m =
3807 row.mach * air.speed_of_sound_m_s / air.kinematic_viscosity_m2_s();
3808 let conditions = if case.thrusting {
3809 DragConditions::thrusting(reynolds_per_m, motor_area)
3810 } else {
3811 DragConditions::coasting(reynolds_per_m)
3812 };
3813 let drag = model.drag(&Flow::axial(row.mach), &conditions).unwrap();
3814 close(
3816 drag.zero_lift_coefficient,
3817 row.hpr_cd0,
3818 1e-12,
3819 &format!("{}@{}", case.id, row.mach),
3820 );
3821 assert_eq!(
3822 row.within_target,
3823 row.relative_error.abs() <= fixture.tolerance_rel,
3824 "{}@{}",
3825 case.id,
3826 row.mach
3827 );
3828 }
3829 let implied = |r: &Row| r.hpr_cd0 / (1.0 + r.relative_error);
3833 let at_0_3 = rows.iter().find(|r| r.mach == 0.3).unwrap();
3834 assert!(
3835 (implied(at_0_3) / case.curve_cd0 - 1.0).abs() < 1e-12,
3836 "{}: the sweep's error at Mach 0.3 doesn't match the recorded curve",
3837 case.id
3838 );
3839 curves
3840 .entry(case.curve.as_str())
3841 .or_default()
3842 .push(rows.iter().map(implied).collect());
3843 let mut from = 0;
3844 for band in &case.sweep.bands {
3845 let errors: Vec<f64> = rows[from..from + band.rows]
3846 .iter()
3847 .map(|r| {
3848 assert_eq!(r.band, band.band, "{}@{}", case.id, r.mach);
3849 r.relative_error
3850 })
3851 .collect();
3852 from += band.rows;
3853 let count = errors.iter().filter(|e| e.abs() <= 0.10).count();
3854 assert_eq!(band.within_target, count, "{} {}", case.id, band.band);
3855 let min = errors.iter().copied().fold(f64::INFINITY, f64::min);
3856 let max = errors.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3857 let rms = (errors.iter().map(|e| e * e).sum::<f64>() / errors.len() as f64).sqrt();
3858 assert_eq!((band.min_error, band.max_error), (min, max), "{}", case.id);
3859 assert!((band.rms_error - rms).abs() < 1e-15, "{}", case.id);
3860 within.push((case.id.as_str(), band.band.as_str(), count, band.rows));
3861 }
3862 assert_eq!(from, rows.len(), "{}: every row is in a band", case.id);
3863 }
3864 for (curve, cases) in &curves {
3865 for other in &cases[1..] {
3866 assert_eq!(other.len(), cases[0].len(), "{curve}");
3867 for (a, b) in cases[0].iter().zip(other) {
3868 assert!((a / b - 1.0).abs() < 1e-12, "{curve}: {a} against {b}");
3869 }
3870 }
3871 }
3872 assert_eq!(curves.values().filter(|c| c.len() == 2).count(), 1);
3874 assert_eq!(
3881 within,
3882 [
3883 ("calisto-power-off", "subsonic", 15, 15),
3884 ("calisto-power-off", "transonic", 3, 7),
3885 ("calisto-power-off", "supersonic", 8, 17),
3886 ("calisto-getting-started-power-off", "subsonic", 12, 15),
3887 ("calisto-getting-started-power-off", "transonic", 0, 7),
3888 ("calisto-getting-started-power-off", "supersonic", 0, 17),
3889 ("juno-iii-power-off", "subsonic", 15, 15),
3890 ("juno-iii-power-off", "transonic", 0, 2),
3891 ("cavour-power-off", "subsonic", 6, 15),
3892 ("cavour-power-off", "transonic", 0, 1),
3893 ("cavour-power-on", "subsonic", 1, 15),
3894 ("cavour-power-on", "transonic", 0, 2),
3895 ("valetudo-power-off", "subsonic", 0, 15),
3896 ("valetudo-power-off", "transonic", 0, 7),
3897 ("valetudo-power-off", "supersonic", 0, 7),
3898 ("valetudo-power-on", "subsonic", 0, 15),
3899 ("valetudo-power-on", "transonic", 0, 7),
3900 ("valetudo-power-on", "supersonic", 0, 7),
3901 ]
3902 );
3903 }
3904}