1use hpr_design::NoseShape;
49use serde::Serialize;
50
51use crate::drag::{
52 SUBSONIC_MACH_LIMIT, check_mach_any, joint_pressure_drag_coefficient, stagnation_ratio,
53};
54use crate::error::{AeroError, check_dimension};
55
56pub const CONE_SUPERSONIC_MACH: f64 = 1.3;
59
60const GAMMA: f64 = 1.4;
62
63const LN_4: f64 = std::f64::consts::LN_2 * 2.0;
65
66fn stagnation_drag_slope(mach: f64) -> f64 {
69 0.85 * if mach < 1.0 {
70 0.5 * mach + 0.1 * mach * mach * mach
71 } else {
72 let i = 1.0 / mach;
73 let i3 = i * i * i;
74 1.52 * i3 - 0.664 * i3 * i * i - 0.21 * i3 * i3 * i
75 }
76}
77
78fn hermite(x0: f64, y0: f64, m0: f64, x1: f64, y1: f64, m1: f64, x: f64) -> (f64, f64) {
81 let h = x1 - x0;
82 let t = (x - x0) / h;
83 let (t2, t3) = (t * t, t * t * t);
84 let value = (2.0 * t3 - 3.0 * t2 + 1.0) * y0
85 + (t3 - 2.0 * t2 + t) * h * m0
86 + (3.0 * t2 - 2.0 * t3) * y1
87 + (t3 - t2) * h * m1;
88 let slope = ((6.0 * t2 - 6.0 * t) * y0 + (6.0 * t - 6.0 * t2) * y1) / h
89 + (3.0 * t2 - 4.0 * t + 1.0) * m0
90 + (3.0 * t2 - 2.0 * t) * m1;
91 (value, slope)
92}
93
94fn cone_transonic(s: f64, mach: f64) -> (f64, f64) {
108 let supersonic = |m: f64| {
109 let root = (m * m - 1.0).sqrt();
110 (
111 2.1 * s * s + 0.5 * s / root,
112 -0.5 * s * m / (root * root * root),
113 )
114 };
115 if mach >= CONE_SUPERSONIC_MACH {
116 return supersonic(mach);
117 }
118 let slope_at_1 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * s);
119 let (c13, slope13) = supersonic(CONE_SUPERSONIC_MACH);
120 hermite(1.0, s, slope_at_1, CONE_SUPERSONIC_MACH, c13, slope13, mach)
121}
122
123#[derive(Debug, Clone, Copy, PartialEq)]
126enum Fit {
127 Power {
130 delta: f64,
132 b: f64,
134 },
135 Quadratic {
137 delta: f64,
139 },
140}
141
142impl Fit {
143 fn new(delta: f64, slope: f64, mach_low: f64) -> Self {
148 let b = slope * mach_low / delta;
149 if delta > 0.0 && b > 1.0 {
150 Self::Power { delta, b }
151 } else {
152 Self::Quadratic { delta }
153 }
154 }
155
156 fn eval(self, mach: f64, mach_low: f64) -> (f64, f64) {
158 match self {
159 Self::Power { delta, b } => {
160 if mach == 0.0 {
161 (0.0, 0.0)
163 } else {
164 let power = delta * (mach / mach_low).powf(b);
165 (power, b * power / mach)
166 }
167 }
168 Self::Quadratic { delta } => {
169 let t = mach / mach_low;
170 (delta * t * t, 2.0 * delta * t / mach_low)
171 }
172 }
173 }
174}
175
176pub fn subsonic_pressure_drag_coefficient(
197 c_rest: f64,
198 c_low: f64,
199 slope_low: f64,
200 mach_low: f64,
201 mach: f64,
202) -> Result<f64, AeroError> {
203 check_dimension("transonic lower bound", mach_low, false)?;
204 for (what, value) in [
205 ("pressure drag at rest", c_rest),
206 ("pressure drag at the lower bound", c_low),
207 ("pressure drag slope at the lower bound", slope_low),
208 ] {
209 if !value.is_finite() {
210 return Err(AeroError::Domain { what, value });
211 }
212 }
213 if !(0.0..=mach_low).contains(&mach) {
214 return Err(AeroError::Domain {
215 what: "Mach number below the transonic lower bound",
216 value: mach,
217 });
218 }
219 Ok(c_rest
220 + Fit::new(c_low - c_rest, slope_low, mach_low)
221 .eval(mach, mach_low)
222 .0)
223}
224
225#[must_use]
232pub fn takes_cone_formula(shape: NoseShape) -> bool {
233 match shape {
234 NoseShape::Conical {} | NoseShape::Ogive { .. } => true,
235 NoseShape::PowerSeries { exponent } => exponent.is_nan() || exponent > 0.75,
237 NoseShape::ParabolicSeries { parameter } => parameter.is_nan() || parameter < 0.5,
238 _ => false,
239 }
240}
241
242pub fn cone_pressure_drag_coefficient(fineness_ratio: f64, mach: f64) -> Result<f64, AeroError> {
264 check_dimension("cone fineness ratio", fineness_ratio, false)?;
265 PressureDragCurve::new(
266 NoseShape::Conical {},
267 fineness_ratio,
268 (0.5 / fineness_ratio).atan(),
269 )?
270 .coefficient(mach)
271}
272
273pub fn ogive_pressure_drag_factor(kappa: f64) -> Result<f64, AeroError> {
284 if !(0.0..=1.0).contains(&kappa) {
285 return Err(AeroError::Domain {
286 what: "ogive κ (tangent-ogive radius over arc radius)",
287 value: kappa,
288 });
289 }
290 let d = kappa - 0.5;
291 Ok(0.72 * d * d + 0.82)
292}
293
294pub fn fineness_scaled_pressure_drag(
309 c3: f64,
310 c0: f64,
311 fineness_ratio: f64,
312) -> Result<f64, AeroError> {
313 check_dimension("fineness-3 pressure drag", c3, true)?;
314 check_dimension("blunt-cylinder pressure drag", c0, false)?;
315 check_dimension("fineness ratio", fineness_ratio, true)?;
316 Ok(c0 * (c3 / c0).powf((fineness_ratio + 1.0).ln() / LN_4))
317}
318
319pub const HEMISPHERE_FOREBODY_PRESSURE_DRAG: f64 = 0.01;
323
324pub const ROUND_HEAD_FOREBODY_PRESSURE_DRAG: f64 = -0.05;
328
329const HEMISPHERE_FINENESS: f64 = 0.5;
331
332const ROUND_HEAD_FINENESS: f64 = 1.0;
334
335pub fn ellipsoid_subsonic_pressure_drag(fineness_ratio: f64, mach: f64) -> Result<f64, AeroError> {
360 check_dimension("ellipsoid fineness ratio", fineness_ratio, false)?;
361 if !(0.0..SUBSONIC_MACH_LIMIT).contains(&mach) {
362 return Err(AeroError::Domain {
363 what: "Mach number below 0.8 for an ellipsoid's measured pressure drag",
364 value: mach,
365 });
366 }
367 Ok(ellipsoid_low_speed(fineness_ratio, mach).0)
368}
369
370fn ellipsoid_low_speed(fineness_ratio: f64, mach: f64) -> (f64, f64) {
373 if fineness_ratio >= HEMISPHERE_FINENESS {
374 let along =
375 (fineness_ratio - HEMISPHERE_FINENESS) / (ROUND_HEAD_FINENESS - HEMISPHERE_FINENESS);
376 let line = HEMISPHERE_FOREBODY_PRESSURE_DRAG
377 + along * (ROUND_HEAD_FOREBODY_PRESSURE_DRAG - HEMISPHERE_FOREBODY_PRESSURE_DRAG);
378 return (line.max(0.0), 0.0);
379 }
380 let (c0, c0_slope) = Reference::Blunt.value_and_slope(mach);
381 let exponent = (fineness_ratio + 1.0).ln() / (1.0 + HEMISPHERE_FINENESS).ln();
382 let c = c0 * (HEMISPHERE_FOREBODY_PRESSURE_DRAG / c0).powf(exponent);
383 (c, c * (1.0 - exponent) * c0_slope / c0)
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
392#[non_exhaustive]
393pub enum StoneyNose {
394 PowerQuarter,
396 PowerHalf,
398 PowerThreeQuarters,
400 ParabolaHalf,
402 ParabolaThreeQuarters,
404 Parabola,
406 Ellipsoid,
408 LvHaack,
410 VonKarman,
412}
413
414impl StoneyNose {
415 pub const ALL: &'static [Self] = &[
417 Self::PowerQuarter,
418 Self::PowerHalf,
419 Self::PowerThreeQuarters,
420 Self::ParabolaHalf,
421 Self::ParabolaThreeQuarters,
422 Self::Parabola,
423 Self::Ellipsoid,
424 Self::LvHaack,
425 Self::VonKarman,
426 ];
427
428 pub fn points(self) -> &'static [(f64, f64)] {
431 match self {
432 Self::PowerQuarter => stoney::POWER_QUARTER,
433 Self::PowerHalf => stoney::POWER_HALF,
434 Self::PowerThreeQuarters => stoney::POWER_THREE_QUARTERS,
435 Self::ParabolaHalf => stoney::PARABOLA_HALF,
436 Self::ParabolaThreeQuarters => stoney::PARABOLA_THREE_QUARTERS,
437 Self::Parabola => stoney::PARABOLA,
438 Self::Ellipsoid => stoney::ELLIPSOID,
439 Self::LvHaack => stoney::LV_HAACK,
440 Self::VonKarman => stoney::VON_KARMAN,
441 }
442 }
443
444 pub fn source(self) -> &'static str {
446 match self {
447 Self::PowerQuarter => stoney::POWER_QUARTER_SOURCE,
448 Self::PowerHalf => stoney::POWER_HALF_SOURCE,
449 Self::PowerThreeQuarters => stoney::POWER_THREE_QUARTERS_SOURCE,
450 Self::ParabolaHalf => stoney::PARABOLA_HALF_SOURCE,
451 Self::ParabolaThreeQuarters => stoney::PARABOLA_THREE_QUARTERS_SOURCE,
452 Self::Parabola => stoney::PARABOLA_SOURCE,
453 Self::Ellipsoid => stoney::ELLIPSOID_SOURCE,
454 Self::LvHaack => stoney::LV_HAACK_SOURCE,
455 Self::VonKarman => stoney::VON_KARMAN_SOURCE,
456 }
457 }
458
459 pub fn first_mach(self) -> f64 {
461 self.points()[0].0
462 }
463
464 fn value_and_slope(self, mach: f64) -> (f64, f64) {
472 let points = self.points();
473 let (first, last) = (points[0], points[points.len() - 1]);
474 if mach < first.0 {
475 if first.0 > SUBSONIC_MACH_LIMIT && mach >= SUBSONIC_MACH_LIMIT {
476 let slope = first.1 / (first.0 - SUBSONIC_MACH_LIMIT);
477 return (slope * (mach - SUBSONIC_MACH_LIMIT), slope);
478 }
479 return (
480 if first.0 > SUBSONIC_MACH_LIMIT {
481 0.0
482 } else {
483 first.1
484 },
485 0.0,
486 );
487 }
488 if mach >= last.0 {
489 return (last.1, 0.0);
490 }
491 let i = points.partition_point(|p| p.0 <= mach) - 1;
495 let ((m0, c0), (m1, c1)) = (points[i], points[i + 1]);
496 let slope = (c1 - c0) / (m1 - m0);
497 (c0 + slope * (mach - m0), slope)
498 }
499}
500
501#[derive(Debug, Clone, Copy, PartialEq)]
503enum Reference {
504 Blunt,
506 Cone {
509 fineness_ratio: f64,
511 factor: f64,
513 rest: f64,
515 },
516 Stoney(StoneyNose),
518}
519
520impl Reference {
521 const CONE_3: Self = Self::Cone {
524 fineness_ratio: 3.0,
525 factor: 1.0,
526 rest: 0.8 / 37.0,
527 };
528
529 fn value_and_slope(self, mach: f64) -> (f64, f64) {
530 match self {
531 Self::Blunt => (0.85 * stagnation_ratio(mach), stagnation_drag_slope(mach)),
532 Self::Cone {
533 fineness_ratio,
534 factor,
535 rest,
536 } => PressureDragCurve::from_transonic(
537 rest,
538 Transonic::cone(fineness_ratio, factor),
539 1.0,
540 )
541 .value_and_slope(mach),
542 Self::Stoney(nose) => nose.value_and_slope(mach),
543 }
544 }
545}
546
547#[derive(Debug, Clone, Copy, PartialEq)]
549enum Transonic {
550 Blunt,
552 Cone {
554 sin_half_angle: f64,
556 factor: f64,
558 },
559 Scaled {
562 lower: Reference,
564 upper: Reference,
566 weight: f64,
568 exponent: f64,
570 },
571 Ellipsoid {
575 fineness_ratio: f64,
577 rest: f64,
579 },
580}
581
582impl Transonic {
583 fn cone(fineness_ratio: f64, factor: f64) -> Self {
585 let tan = 0.5 / fineness_ratio;
586 Self::Cone {
587 sin_half_angle: tan / (1.0 + tan * tan).sqrt(),
588 factor,
589 }
590 }
591
592 fn value_and_slope(self, mach: f64) -> (f64, f64) {
593 match self {
594 Self::Blunt => Reference::Blunt.value_and_slope(mach),
595 Self::Cone {
596 sin_half_angle,
597 factor,
598 } => {
599 let (c, slope) = cone_transonic(sin_half_angle, mach);
600 (factor * c, factor * slope)
601 }
602 Self::Scaled {
603 lower,
604 upper,
605 weight,
606 exponent,
607 } => {
608 let (lo, lo_slope) = lower.value_and_slope(mach);
609 let (hi, hi_slope) = upper.value_and_slope(mach);
610 let c3 = lo + weight * (hi - lo);
611 let c3_slope = lo_slope + weight * (hi_slope - lo_slope);
612 let (c0, c0_slope) = Reference::Blunt.value_and_slope(mach);
613 if c3 <= 0.0 {
614 return (0.0, 0.0);
615 }
616 let c = c0 * (c3 / c0).powf(exponent);
617 (
618 c,
619 c * ((1.0 - exponent) * c0_slope / c0 + exponent * c3_slope / c3),
620 )
621 }
622 Self::Ellipsoid {
623 fineness_ratio,
624 rest,
625 } => {
626 let low_speed = |mach: f64| {
627 let (c, slope) = ellipsoid_low_speed(fineness_ratio, mach);
628 if rest > c { (rest, 0.0) } else { (c, slope) }
629 };
630 if mach < SUBSONIC_MACH_LIMIT {
631 return low_speed(mach);
632 }
633 let stoney = Reference::Stoney(StoneyNose::Ellipsoid);
634 let measured = Self::Scaled {
635 lower: stoney,
636 upper: stoney,
637 weight: 0.0,
638 exponent: (fineness_ratio + 1.0).ln() / LN_4,
639 };
640 let first = StoneyNose::Ellipsoid.first_mach();
641 if mach >= first {
642 return measured.value_and_slope(mach);
643 }
644 let low = low_speed(SUBSONIC_MACH_LIMIT).0;
648 let slope =
649 (measured.value_and_slope(first).0 - low) / (first - SUBSONIC_MACH_LIMIT);
650 (low + slope * (mach - SUBSONIC_MACH_LIMIT), slope)
651 }
652 }
653 }
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
660pub struct PressureDragCurve {
661 shape: Option<NoseShape>,
663 fineness_ratio: f64,
665 rest: f64,
668 mach_low: f64,
670 #[serde(skip)]
672 fit: Fit,
673 #[serde(skip)]
675 transonic: Transonic,
676}
677
678impl PressureDragCurve {
679 fn from_transonic(rest: f64, transonic: Transonic, mach_low: f64) -> Self {
682 let (c_low, slope_low) = transonic.value_and_slope(mach_low);
683 let rest = if mach_low == 0.0 { c_low } else { rest };
684 Self {
685 shape: None,
686 fineness_ratio: 0.0,
687 rest,
688 mach_low,
689 fit: Fit::new(c_low - rest, slope_low, mach_low),
690 transonic,
691 }
692 }
693
694 pub fn step() -> Self {
699 Self::from_transonic(0.0, Transonic::Blunt, 0.0)
700 }
701
702 pub fn new(
734 shape: NoseShape,
735 fineness_ratio: f64,
736 joint_angle_rad: f64,
737 ) -> Result<Self, AeroError> {
738 let mut curve = Self::build(shape, fineness_ratio, joint_angle_rad)?;
739 if fineness_ratio > 0.0 {
740 curve.shape = Some(shape);
741 curve.fineness_ratio = fineness_ratio;
742 }
743 Ok(curve)
744 }
745
746 fn build(
748 shape: NoseShape,
749 fineness_ratio: f64,
750 joint_angle_rad: f64,
751 ) -> Result<Self, AeroError> {
752 check_dimension("fineness ratio", fineness_ratio, true)?;
753 let rest = joint_pressure_drag_coefficient(joint_angle_rad)?;
754 if fineness_ratio == 0.0 {
755 return Ok(Self::step());
756 }
757 let interpolated = |lower: Reference, upper: Reference, weight: f64| {
758 let transonic = Transonic::Scaled {
759 lower,
760 upper,
761 weight,
762 exponent: (fineness_ratio + 1.0).ln() / LN_4,
763 };
764 Ok(Self::from_transonic(rest, transonic, SUBSONIC_MACH_LIMIT))
765 };
766 let cone = |factor: f64| {
770 if fineness_ratio >= 1.0 {
771 Ok(Self::from_transonic(
772 rest,
773 Transonic::cone(fineness_ratio, factor),
774 1.0,
775 ))
776 } else {
777 let transonic = Transonic::Scaled {
778 lower: Reference::Blunt,
779 upper: Reference::Cone {
780 fineness_ratio: 1.0,
781 factor,
782 rest,
783 },
784 weight: 1.0,
785 exponent: (fineness_ratio + 1.0).ln() / std::f64::consts::LN_2,
786 };
787 Ok(Self::from_transonic(rest, transonic, 0.0))
788 }
789 };
790 use Reference::{Blunt, Stoney};
791 let cone_3 = Reference::CONE_3;
792 match shape {
793 NoseShape::Conical {} => cone(1.0),
794 NoseShape::Ogive { radius_ratio } => {
795 if !(radius_ratio.is_finite() && radius_ratio >= 1.0) {
796 return Err(AeroError::Unsupported(format!(
797 "an ogive of radius ratio {radius_ratio}, not at least 1 (below 1 is a \
798 bulged secant ogive): Niskanen's \
799 eq. B.8 runs only from the cone to the tangent ogive"
800 )));
801 }
802 cone(ogive_pressure_drag_factor(1.0 / radius_ratio)?)
803 }
804 NoseShape::Elliptical {} => Ok(Self::from_transonic(
805 rest,
806 Transonic::Ellipsoid {
807 fineness_ratio,
808 rest,
809 },
810 0.0,
811 )),
812 NoseShape::PowerSeries { exponent: n } => {
813 let (lower, upper, low, high) = if n < 0.25 {
814 (Blunt, Stoney(StoneyNose::PowerQuarter), 0.0, 0.25)
815 } else if n < 0.5 {
816 (
817 Stoney(StoneyNose::PowerQuarter),
818 Stoney(StoneyNose::PowerHalf),
819 0.25,
820 0.5,
821 )
822 } else if n < 0.75 {
823 (
824 Stoney(StoneyNose::PowerHalf),
825 Stoney(StoneyNose::PowerThreeQuarters),
826 0.5,
827 0.75,
828 )
829 } else {
830 (Stoney(StoneyNose::PowerThreeQuarters), cone_3, 0.75, 1.0)
831 };
832 check_parameter("power series exponent", n, 0.0, 1.0)?;
833 interpolated(lower, upper, (n - low) / (high - low))
834 }
835 NoseShape::ParabolicSeries { parameter: k } => {
836 let (lower, upper, low, high) = if k < 0.5 {
837 (cone_3, Stoney(StoneyNose::ParabolaHalf), 0.0, 0.5)
838 } else if k < 0.75 {
839 (
840 Stoney(StoneyNose::ParabolaHalf),
841 Stoney(StoneyNose::ParabolaThreeQuarters),
842 0.5,
843 0.75,
844 )
845 } else {
846 (
847 Stoney(StoneyNose::ParabolaThreeQuarters),
848 Stoney(StoneyNose::Parabola),
849 0.75,
850 1.0,
851 )
852 };
853 check_parameter("parabolic series parameter", k, 0.0, 1.0)?;
854 interpolated(lower, upper, (k - low) / (high - low))
855 }
856 NoseShape::Haack { parameter: c } => {
857 if c > 1.0 / 3.0 {
858 return Err(AeroError::Unsupported(format!(
859 "a Haack series nose of C = {c}, above the L-V Haack's 1/3: Stoney's data \
860 stops there (Niskanen 2009 p. 103)"
861 )));
862 }
863 check_parameter("Haack series parameter", c, 0.0, 1.0 / 3.0)?;
864 interpolated(
865 Stoney(StoneyNose::VonKarman),
866 Stoney(StoneyNose::LvHaack),
867 3.0 * c,
868 )
869 }
870 _ => Err(AeroError::Unsupported(
872 "this nose shape (no transonic pressure-drag method)".to_owned(),
873 )),
874 }
875 }
876
877 pub fn rest_coefficient(&self) -> f64 {
882 self.rest
883 }
884
885 pub fn transonic_lower_bound(&self) -> f64 {
888 self.mach_low
889 }
890
891 pub fn coefficient(&self, mach: f64) -> Result<f64, AeroError> {
897 check_mach_any(mach)?;
898 Ok(self.value_and_slope(mach).0)
899 }
900
901 fn value_and_slope(&self, mach: f64) -> (f64, f64) {
903 if mach < self.mach_low {
904 let (value, slope) = self.fit.eval(mach, self.mach_low);
905 (self.rest + value, slope)
906 } else {
907 self.transonic.value_and_slope(mach)
908 }
909 }
910}
911
912fn check_parameter(what: &'static str, value: f64, low: f64, high: f64) -> Result<(), AeroError> {
914 if (low..=high).contains(&value) {
915 Ok(())
916 } else {
917 Err(AeroError::Domain { what, value })
918 }
919}
920
921#[rustfmt::skip]
929mod stoney {
930 pub(super) const POWER_THREE_QUARTERS: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.87, 0.0), (0.9, 0.0131), (0.95, 0.0375), (1.0, 0.0848), (1.05, 0.1185), (1.057, 0.1185), (1.1, 0.1095), (1.15, 0.1089), (1.2, 0.109), (1.25, 0.1032), (1.3, 0.1001), (1.4, 0.097), (1.5, 0.0932), (1.6, 0.09), (1.8, 0.0847), (1.967, 0.0789)];
932 pub(super) const POWER_THREE_QUARTERS_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: x^3/4, configuration 61, the faired line from Mach 0.80 to its end at 1.967; leaving zero at Mach 0.870, peak 0.1185 at 1.057; read to +-0.0015 (at most +-0.0048)";
933 pub(super) const POWER_HALF: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.934, 0.0), (0.95, 0.0069), (1.0, 0.046), (1.05, 0.0597), (1.1, 0.0573), (1.15, 0.0675), (1.2, 0.08), (1.25, 0.0853), (1.3, 0.0851), (1.4, 0.0847), (1.5, 0.0856), (1.6, 0.086), (1.8, 0.0885), (1.941, 0.0898)];
935 pub(super) const POWER_HALF_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: x^1/2, configuration 63, the faired line from Mach 0.80 to its end at 1.941; leaving zero at Mach 0.934; read to +-0.0014 (at most +-0.0023)";
936 pub(super) const PARABOLA: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.95, 0.0), (0.952, 0.0), (1.0, 0.037), (1.05, 0.0898), (1.1, 0.1073), (1.15, 0.1149), (1.165, 0.1162), (1.2, 0.1161), (1.25, 0.1145), (1.3, 0.1134), (1.4, 0.1103), (1.5, 0.1078), (1.6, 0.1067), (1.8, 0.1062), (1.975, 0.1073)];
938 pub(super) const PARABOLA_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: parabola, configuration 59, the faired line from Mach 0.80 to its end at 1.975; leaving zero at Mach 0.952, peak 0.1162 at 1.165; read to +-0.0016 (at most +-0.0046)";
939 pub(super) const PARABOLA_THREE_QUARTERS: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.902, 0.0), (0.95, 0.0182), (1.0, 0.069), (1.05, 0.0889), (1.1, 0.1049), (1.136, 0.1078), (1.15, 0.1072), (1.2, 0.1036), (1.25, 0.0997), (1.3, 0.0932), (1.4, 0.0861), (1.5, 0.082), (1.6, 0.0817), (1.8, 0.0797), (1.968, 0.0814)];
941 pub(super) const PARABOLA_THREE_QUARTERS_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: 3/4 parabola, configuration 62, the faired line from Mach 0.80 to its end at 1.968; leaving zero at Mach 0.902, peak 0.1078 at 1.136; read to +-0.0016 (at most +-0.0035)";
942 pub(super) const PARABOLA_HALF: &[(f64, f64)] = &[(0.8, 0.0), (0.817, 0.0), (0.85, 0.0057), (0.9, 0.0155), (0.95, 0.0403), (1.0, 0.094), (1.05, 0.1231), (1.081, 0.1247), (1.1, 0.1235), (1.15, 0.1149), (1.2, 0.1139), (1.25, 0.1068), (1.3, 0.1009), (1.4, 0.0936), (1.5, 0.0877), (1.6, 0.0854), (1.8, 0.0854), (1.968, 0.0864)];
944 pub(super) const PARABOLA_HALF_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: 1/2 parabola, configuration 57, the faired line from Mach 0.80 to its end at 1.968; leaving zero at Mach 0.817, peak 0.1247 at 1.081; read to +-0.0016 (at most +-0.003)";
945 pub(super) const VON_KARMAN: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.915, 0.0), (0.95, 0.0077), (1.0, 0.0253), (1.05, 0.0581), (1.1, 0.0694), (1.15, 0.0734), (1.2, 0.0758), (1.25, 0.0779), (1.3, 0.0825), (1.4, 0.0879), (1.5, 0.0893), (1.553, 0.0898), (1.6, 0.0898), (1.8, 0.0869), (1.994, 0.0794)];
947 pub(super) const VON_KARMAN_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: Von Karman, configuration 58, the faired line from Mach 0.80 to its end at 1.994; leaving zero at Mach 0.915, peak 0.0898 at 1.553; read to +-0.0014 (at most +-0.0022)";
948 pub(super) const LV_HAACK: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.915, 0.0), (0.95, 0.0077), (1.0, 0.0253), (1.05, 0.065), (1.1, 0.0847), (1.15, 0.095), (1.2, 0.1002), (1.25, 0.1028), (1.3, 0.107), (1.4, 0.1135), (1.5, 0.1156), (1.6, 0.117), (1.617, 0.1172), (1.8, 0.1155), (1.977, 0.1115)];
950 pub(super) const LV_HAACK_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: L-V Haack, configuration 60, the faired line from Mach 0.80 to its end at 1.977; leaving zero at Mach 0.915, peak 0.1172 at 1.617; read to +-0.0014 (at most +-0.0022)";
951 pub(super) const POWER_QUARTER: &[(f64, f64)] = &[(1.2, 0.141), (1.25, 0.148), (1.3, 0.1558), (1.4, 0.1689), (1.5, 0.1809), (1.6, 0.1894), (1.8, 0.2051), (2.0, 0.2165), (2.4, 0.2331), (2.8, 0.2441), (3.2, 0.248), (3.587, 0.2491)];
953 pub(super) const POWER_QUARTER_SOURCE: &str = "Stoney 1961, Fig. 12(b), wind tunnel (Stoney's ref. 30): x^1/4, the faired line from Mach 1.20 to its end at 3.587; read to +-0.0014 (at most +-0.003)";
954 pub(super) const ELLIPSOID: &[(f64, f64)] = &[(1.2, 0.111), (1.25, 0.1298), (1.3, 0.14), (1.4, 0.1478), (1.5, 0.1509), (1.6, 0.1523), (1.8, 0.1552), (2.0, 0.1576), (2.4, 0.1601), (2.8, 0.1601), (3.2, 0.16), (3.587, 0.158)];
956 pub(super) const ELLIPSOID_SOURCE: &str = "Stoney 1961, Fig. 12(b), wind tunnel (Stoney's ref. 30): ellipsoid, the faired line from Mach 1.20 to its end at 3.587; read to +-0.0014 (at most +-0.004)";
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962
963 fn close(got: f64, want: f64, rel: f64, what: &str) {
964 let err = ((got - want) / want).abs();
965 assert!(
966 err <= rel,
967 "{what}: got {got}, want {want}, rel err {err:e}"
968 );
969 }
970
971 #[test]
975 fn the_cone_formula_reaches_into_the_series_blends() {
976 let at = |shape: NoseShape| PressureDragCurve::new(shape, 3.0, 0.0).unwrap();
977 let cone = at(NoseShape::Conical {}).coefficient(1.1).unwrap();
978 for shape in [
979 NoseShape::PowerSeries { exponent: 1.0 },
980 NoseShape::ParabolicSeries { parameter: 0.0 },
981 ] {
982 assert!(takes_cone_formula(shape));
983 close(
984 at(shape).coefficient(1.1).unwrap(),
985 cone,
986 1e-12,
987 "a cone by another name",
988 );
989 }
990 let above = |x: f64| f64::from_bits(x.to_bits() + 1);
991 let below = |x: f64| f64::from_bits(x.to_bits() - 1);
992 for (shape, takes) in [
993 (NoseShape::PowerSeries { exponent: 0.75 }, false),
994 (
995 NoseShape::PowerSeries {
996 exponent: above(0.75),
997 },
998 true,
999 ),
1000 (NoseShape::ParabolicSeries { parameter: 0.5 }, false),
1001 (
1002 NoseShape::ParabolicSeries {
1003 parameter: below(0.5),
1004 },
1005 true,
1006 ),
1007 (NoseShape::PowerSeries { exponent: f64::NAN }, true),
1008 (NoseShape::Conical {}, true),
1009 (NoseShape::TANGENT_OGIVE, true),
1010 (NoseShape::VON_KARMAN, false),
1011 (
1012 NoseShape::Haack {
1013 parameter: 1.0 / 3.0,
1014 },
1015 false,
1016 ),
1017 (NoseShape::Elliptical {}, false),
1018 ] {
1019 assert_eq!(takes_cone_formula(shape), takes, "{shape:?}");
1020 }
1021 }
1022
1023 #[test]
1026 fn a_cone_follows_appendix_b() {
1027 let s = 1.0 / 37f64.sqrt();
1028 let rest = 0.8 * s * s;
1029 let slope_at_1 = 4.0 / 2.4 * (1.0 - 0.5 * s);
1030 let b4 = |m: f64| 2.1 * s * s + 0.5 * s / (m * m - 1.0).sqrt();
1031 let cone = |m: f64| cone_pressure_drag_coefficient(3.0, m).unwrap();
1032 close(cone(0.0), rest, 1e-15, "at rest, 0.8 sin² ε");
1033 close(rest, 0.021_621_6, 1e-5, "0.0216");
1034 close(cone(1.0), s, 1e-15, "eq. B.6 at Mach 1");
1035 close(cone(1.3), b4(1.3), 1e-15, "eq. B.4 at 1.3");
1036 for m in [1.5, 2.0, 3.0, 4.99] {
1037 close(cone(m), b4(m), 1e-15, "eq. B.4");
1038 }
1039 close(cone(2.0), 0.104_215, 1e-5, "0.1042 at Mach 2");
1040 let b = slope_at_1 / (s - rest);
1042 close(b, 10.714, 1e-4, "b");
1043 for m in [0.3, 0.6, 0.9, 0.99] {
1044 close(
1045 cone(m),
1046 rest + (s - rest) * f64::powf(m, b),
1047 1e-13,
1048 "eq. 3.87",
1049 );
1050 }
1051 let curve =
1053 PressureDragCurve::new(NoseShape::Conical {}, 3.0, (1.0 / 6f64).atan()).unwrap();
1054 let h = 1e-7;
1055 for (m, want) in [
1056 (1.0, slope_at_1),
1057 (1.3, -0.5 * s * 1.3 / (0.69f64).powf(1.5)),
1058 ] {
1059 let c = |m: f64| curve.coefficient(m).unwrap();
1060 close((c(m) - c(m - h)) / h, want, 1e-5, "slope below a join");
1061 close((c(m + h) - c(m)) / h, want, 1e-5, "slope above a join");
1062 let below = curve.coefficient(m - 1e-12).unwrap();
1063 let above = curve.coefficient(m).unwrap();
1064 assert!((below - above).abs() < 1e-11, "value at Mach {m}");
1065 }
1066 assert_eq!(curve.transonic_lower_bound(), 1.0);
1067 close(curve.rest_coefficient(), rest, 1e-15, "rest");
1068 }
1069
1070 #[test]
1073 fn an_ogive_is_the_cone_times_eq_b8() {
1074 assert_eq!(ogive_pressure_drag_factor(0.0).unwrap(), 1.0);
1075 assert_eq!(ogive_pressure_drag_factor(1.0).unwrap(), 1.0);
1076 close(
1077 ogive_pressure_drag_factor(0.5).unwrap(),
1078 0.82,
1079 1e-15,
1080 "κ = ½",
1081 );
1082 for bad in [-0.1, 1.01, f64::NAN] {
1083 assert!(ogive_pressure_drag_factor(bad).is_err(), "{bad}");
1084 }
1085 let cone = PressureDragCurve::new(NoseShape::Conical {}, 4.0, 0.0).unwrap();
1086 let tangent = PressureDragCurve::new(NoseShape::TANGENT_OGIVE, 4.0, 0.0).unwrap();
1087 let secant =
1088 PressureDragCurve::new(NoseShape::Ogive { radius_ratio: 2.0 }, 4.0, 0.0).unwrap();
1089 for m in [1.0, 1.2, 2.0, 4.0] {
1090 close(
1091 tangent.coefficient(m).unwrap(),
1092 cone.coefficient(m).unwrap(),
1093 1e-15,
1094 "tangent",
1095 );
1096 close(
1097 secant.coefficient(m).unwrap(),
1098 0.82 * cone.coefficient(m).unwrap(),
1099 1e-14,
1100 "κ = ½",
1101 );
1102 }
1103 assert_eq!(tangent.coefficient(0.0).unwrap(), 0.0);
1105 let bulged = PressureDragCurve::new(NoseShape::Ogive { radius_ratio: 0.8 }, 4.0, 0.1);
1106 assert!(
1107 matches!(bulged, Err(AeroError::Unsupported(_))),
1108 "{bulged:?}"
1109 );
1110 }
1111
1112 #[test]
1114 fn eq_b9_runs_from_the_blunt_cylinder_to_fineness_3() {
1115 let (c3, c0) = (0.08, 1.4);
1116 close(
1117 fineness_scaled_pressure_drag(c3, c0, 3.0).unwrap(),
1118 c3,
1119 1e-15,
1120 "f = 3",
1121 );
1122 close(
1123 fineness_scaled_pressure_drag(c3, c0, 0.0).unwrap(),
1124 c0,
1125 1e-15,
1126 "f = 0",
1127 );
1128 let mut previous = c0;
1129 for f in [0.5, 1.0, 2.0, 3.0, 5.0, 10.0] {
1130 let c = fineness_scaled_pressure_drag(c3, c0, f).unwrap();
1131 assert!(c < previous, "falls with fineness: {c} at {f}");
1132 previous = c;
1133 }
1134 let b = (c0 / c3).ln() / 4f64.ln();
1136 close(
1137 fineness_scaled_pressure_drag(c3, c0, 5.0).unwrap(),
1138 c0 / 6f64.powf(b),
1139 1e-14,
1140 "eq. B.7",
1141 );
1142 assert_eq!(fineness_scaled_pressure_drag(0.0, c0, 2.0).unwrap(), 0.0);
1143 for (c3, c0, f) in [(-0.01, 1.0, 3.0), (0.1, 0.0, 3.0), (0.1, 1.0, -1.0)] {
1144 assert!(fineness_scaled_pressure_drag(c3, c0, f).is_err());
1145 }
1146 }
1147
1148 #[test]
1151 fn eq_3_87_meets_the_lower_bound() {
1152 let (rest, low, slope, m_l) = (0.02, 0.16, 1.5, 1.0);
1153 let fit = |m: f64| subsonic_pressure_drag_coefficient(rest, low, slope, m_l, m).unwrap();
1154 assert_eq!(fit(0.0), rest);
1155 close(fit(m_l), low, 1e-15, "value at M_L");
1156 close(
1157 (fit(m_l) - fit(m_l - 1e-7)) / 1e-7,
1158 slope,
1159 1e-5,
1160 "slope at M_L",
1161 );
1162 let no_rise =
1164 |m: f64| subsonic_pressure_drag_coefficient(0.01, 0.005, 0.3, 0.8, m).unwrap();
1165 close(no_rise(0.4), 0.01 - 0.005 * 0.25, 1e-15, "quadratic");
1166 close(no_rise(0.8), 0.005, 1e-15, "quadratic at M_L");
1167 let falling = |m: f64| subsonic_pressure_drag_coefficient(0.0, 0.1, -0.2, 0.8, m).unwrap();
1169 close(falling(0.4), 0.025, 1e-15, "falling");
1170 for bad in [-0.1, 0.81, f64::NAN] {
1171 assert!(subsonic_pressure_drag_coefficient(rest, low, slope, 0.8, bad).is_err());
1172 }
1173 assert!(subsonic_pressure_drag_coefficient(rest, f64::NAN, slope, 0.8, 0.5).is_err());
1174 assert!(subsonic_pressure_drag_coefficient(rest, low, slope, 0.0, 0.0).is_err());
1175 }
1176
1177 #[test]
1180 fn a_step_rises_to_the_blunt_cylinder() {
1181 let step = PressureDragCurve::step();
1182 let blunt = |m: f64| stagnation_drag_coefficient_for_test(m);
1183 assert_eq!(step.coefficient(0.0).unwrap(), 0.85);
1184 assert_eq!(step.rest_coefficient(), 0.85);
1185 close(
1186 step.coefficient(0.8).unwrap(),
1187 0.994_704,
1188 1e-12,
1189 "0.85 × 1.17024",
1190 );
1191 close(
1192 step.coefficient(0.3).unwrap(),
1193 0.85 * (1.0 + 0.0225 + 0.0081 / 40.0),
1194 1e-15,
1195 "0.3",
1196 );
1197 for m in [0.1, 0.5, 0.9, 0.999, 1.0, 2.0, 4.9] {
1198 close(
1199 step.coefficient(m).unwrap(),
1200 blunt(m),
1201 1e-15,
1202 "blunt cylinder",
1203 );
1204 }
1205 let mut previous = 0.85;
1206 for m in [0.1, 0.3, 0.5, 0.7, 0.8] {
1207 let c = step.coefficient(m).unwrap();
1208 assert!(c > previous, "rises: {c} at Mach {m}");
1209 previous = c;
1210 }
1211 for shape in [
1213 NoseShape::Conical {},
1214 NoseShape::VON_KARMAN,
1215 NoseShape::Elliptical {},
1216 ] {
1217 assert_eq!(
1218 PressureDragCurve::new(shape, 0.0, std::f64::consts::FRAC_PI_2).unwrap(),
1219 step
1220 );
1221 }
1222 }
1223
1224 fn stagnation_drag_coefficient_for_test(mach: f64) -> f64 {
1225 crate::drag::stagnation_drag_coefficient(mach).unwrap()
1226 }
1227
1228 #[test]
1231 fn short_cones_tend_to_the_step_and_meet_the_closed_form_at_fineness_1() {
1232 let step = PressureDragCurve::step();
1233 for shape in [NoseShape::Conical {}, NoseShape::TANGENT_OGIVE] {
1234 let at = |f: f64| {
1235 let joint = (0.5 / f).atan();
1236 PressureDragCurve::new(shape, f, joint).unwrap()
1237 };
1238 for m in [0.0, 0.3, 0.79, 0.8, 0.95, 1.0, 1.2, 2.0, 4.9] {
1239 let near_zero = at(1e-9).coefficient(m).unwrap();
1240 close(near_zero, step.coefficient(m).unwrap(), 1e-7, "f → 0");
1241 let below = at(1.0 - 1e-10).coefficient(m).unwrap();
1242 let at_1 = at(1.0).coefficient(m).unwrap();
1243 close(below, at_1, 1e-8, "f → 1 from below");
1244 }
1245 }
1246 }
1247
1248 #[test]
1251 fn out_of_range_shapes_are_refused() {
1252 let haack = PressureDragCurve::new(NoseShape::Haack { parameter: 0.5 }, 3.0, 0.0);
1253 assert!(matches!(haack, Err(AeroError::Unsupported(_))), "{haack:?}");
1254 for (shape, f, joint) in [
1255 (NoseShape::Conical {}, -1.0, 0.1),
1256 (NoseShape::Conical {}, f64::NAN, 0.1),
1257 (NoseShape::Conical {}, 3.0, 2.0),
1258 (NoseShape::PowerSeries { exponent: 1.5 }, 3.0, 0.1),
1259 (NoseShape::ParabolicSeries { parameter: -0.1 }, 3.0, 0.1),
1260 (NoseShape::Haack { parameter: -0.1 }, 3.0, 0.0),
1261 ] {
1262 assert!(
1263 PressureDragCurve::new(shape, f, joint).is_err(),
1264 "{shape:?} {f} {joint}"
1265 );
1266 }
1267 let cone = PressureDragCurve::new(NoseShape::Conical {}, 3.0, 0.1).unwrap();
1268 for bad in [-0.1, f64::NAN, f64::INFINITY] {
1269 assert!(cone.coefficient(bad).is_err());
1270 }
1271 assert!(cone_pressure_drag_coefficient(0.0, 1.0).is_err());
1272 }
1273
1274 #[test]
1279 fn stoney_curves_are_read_as_published() {
1280 for nose in StoneyNose::ALL {
1281 let points = nose.points();
1282 assert!(points.windows(2).all(|w| w[0].0 < w[1].0), "{nose:?}");
1283 assert!(points.iter().all(|p| p.1 >= 0.0 && p.1 < 0.3), "{nose:?}");
1284 let first = match nose {
1285 StoneyNose::PowerQuarter | StoneyNose::Ellipsoid => 1.2,
1286 _ => 0.8,
1287 };
1288 assert_eq!(nose.first_mach(), first, "{nose:?}");
1289 assert!(
1290 nose.source().starts_with("Stoney 1961, Fig. 12"),
1291 "{nose:?}"
1292 );
1293 }
1294 let at_3 = |shape: NoseShape| PressureDragCurve::new(shape, 3.0, 0.0).unwrap();
1295 let vk = at_3(NoseShape::VON_KARMAN);
1296 for &(m, c) in StoneyNose::VonKarman.points() {
1297 close(
1298 vk.coefficient(m).unwrap() + 1e-300,
1299 c + 1e-300,
1300 1e-12,
1301 "von Kármán",
1302 );
1303 }
1304 let (last_m, last_c) = *StoneyNose::VonKarman.points().last().unwrap();
1305 assert!(last_m < 2.0);
1306 close(
1307 vk.coefficient(4.0).unwrap(),
1308 last_c,
1309 1e-12,
1310 "held past the end",
1311 );
1312 close(
1313 vk.coefficient(1.5).unwrap(),
1314 0.0893,
1315 1e-12,
1316 "0.0893 at Mach 1.5",
1317 );
1318 let lv = at_3(NoseShape::LV_HAACK);
1319 close(
1320 lv.coefficient(1.5).unwrap(),
1321 0.1156,
1322 1e-12,
1323 "L-V Haack at 1.5",
1324 );
1325 let between = at_3(NoseShape::Haack {
1326 parameter: 1.0 / 6.0,
1327 });
1328 close(
1329 between.coefficient(1.5).unwrap(),
1330 0.5 * (0.0893 + 0.1156),
1331 1e-12,
1332 "C = 1/6",
1333 );
1334 let cone = PressureDragCurve::new(NoseShape::Conical {}, 3.0, (1.0 / 6f64).atan()).unwrap();
1337 for shape in [
1338 NoseShape::PowerSeries { exponent: 1.0 },
1339 NoseShape::ParabolicSeries { parameter: 0.0 },
1340 ] {
1341 for m in [0.8, 0.9, 1.0, 1.2, 2.0, 4.0] {
1342 close(
1343 at_3(shape).coefficient(m).unwrap(),
1344 cone.coefficient(m).unwrap(),
1345 1e-12,
1346 "the 3:1 cone",
1347 );
1348 }
1349 }
1350 let blunt_ish = PressureDragCurve::new(NoseShape::PowerSeries { exponent: 0.05 }, 3.0, 0.0)
1351 .unwrap()
1352 .coefficient(2.0)
1353 .unwrap();
1354 let x_quarter = at_3(NoseShape::PowerSeries { exponent: 0.25 })
1355 .coefficient(2.0)
1356 .unwrap();
1357 close(x_quarter, 0.2165, 1e-12, "x^¼ at Mach 2");
1358 close(
1359 blunt_ish,
1360 0.8 * stagnation_drag_coefficient_for_test(2.0) + 0.2 * 0.2165,
1361 1e-12,
1362 "a fifth of the way from the blunt cylinder",
1363 );
1364 let (at_08, slope) = StoneyNose::PowerQuarter.value_and_slope(0.8);
1368 assert_eq!(at_08, 0.0);
1369 close(slope, 0.141 / 0.4, 1e-12, "the line's slope at Mach 0.8");
1370 let ellipse = at_3(NoseShape::Elliptical {});
1371 assert_eq!(ellipse.transonic_lower_bound(), 0.0);
1373 assert_eq!(vk.transonic_lower_bound(), 0.8);
1374 assert_eq!(ellipse.coefficient(0.6).unwrap(), 0.0);
1375 assert_eq!(ellipse.coefficient(0.8).unwrap(), 0.0);
1376 close(
1377 ellipse.coefficient(1.0).unwrap(),
1378 0.5 * 0.111,
1379 1e-12,
1380 "halfway up the line",
1381 );
1382 close(
1383 ellipse.coefficient(1.2).unwrap(),
1384 0.111,
1385 1e-12,
1386 "its first point",
1387 );
1388 }
1389
1390 #[test]
1395 fn a_blunt_ellipsoid_takes_hoerners_measured_forebody_drag() {
1396 assert_eq!(HEMISPHERE_FOREBODY_PRESSURE_DRAG, 0.01);
1397 assert_eq!(ROUND_HEAD_FOREBODY_PRESSURE_DRAG, -0.05);
1398 let at = |f: f64, m: f64| {
1399 PressureDragCurve::new(NoseShape::Elliptical {}, f, 0.0)
1400 .unwrap()
1401 .coefficient(m)
1402 .unwrap()
1403 };
1404 for m in [0.0, 0.1, 0.3, 0.5, 0.79] {
1405 close(at(0.5, m), 0.01, 1e-12, "hemisphere");
1407 close(
1408 ellipsoid_subsonic_pressure_drag(0.5, m).unwrap(),
1409 0.01,
1410 1e-12,
1411 "hemisphere, by the function",
1412 );
1413 close(at(0.577, m), 0.01 - 0.12 * 0.077, 1e-9, "0.577 calibres");
1415 assert_eq!(at(7.0 / 12.0 + 1e-9, m), 0.0);
1416 assert_eq!(at(1.0, m), 0.0, "the round head's −0.05 is held at 0");
1417 assert_eq!(ellipsoid_subsonic_pressure_drag(1.0, m).unwrap(), 0.0);
1418 assert_eq!(at(3.0, m), 0.0);
1419 let c0 = stagnation_drag_coefficient_for_test(m);
1421 let e = 1.25f64.ln() / 1.5f64.ln();
1422 close(at(0.25, m), c0 * (0.01 / c0).powf(e), 1e-12, "¼ calibre");
1423 }
1424 close(at(0.25, 0.0), 0.0737, 2e-3, "¼ calibre at rest");
1425 close(at(0.25, 0.5), 0.075807, 2e-5, "¼ calibre at Mach 0.5");
1428 let joint = |f: f64, m: f64| {
1431 PressureDragCurve::new(NoseShape::Elliptical {}, f, 0.3)
1432 .unwrap()
1433 .coefficient(m)
1434 .unwrap()
1435 };
1436 let rest_03 = 0.8 * 0.3f64.sin().powi(2);
1437 for m in [0.0, 0.5, 0.79] {
1438 close(
1439 joint(1.0, m),
1440 rest_03,
1441 1e-12,
1442 "eq. 3.86 over nothing measured",
1443 );
1444 close(
1445 joint(0.25, m),
1446 at(0.25, m),
1447 1e-12,
1448 "the measurement over eq. 3.86",
1449 );
1450 }
1451 let rest = PressureDragCurve::new(NoseShape::Elliptical {}, 0.25, 0.0).unwrap();
1452 close(
1453 rest.rest_coefficient(),
1454 at(0.25, 0.0),
1455 1e-15,
1456 "the value at rest",
1457 );
1458 let step = PressureDragCurve::step();
1460 for m in [0.0, 0.4, 0.79] {
1461 let flat = step.coefficient(m).unwrap();
1462 close(at(1e-9, m), flat, 1e-6, "towards a flat face");
1463 close(
1464 at(0.5 - 1e-9, m),
1465 at(0.5 + 1e-9, m),
1466 1e-6,
1467 "at the hemisphere",
1468 );
1469 }
1470 for (f, m) in [
1472 (0.0, 0.3),
1473 (-0.1, 0.3),
1474 (0.5, 0.8),
1475 (0.5, -0.1),
1476 (f64::NAN, 0.3),
1477 ] {
1478 assert!(
1479 ellipsoid_subsonic_pressure_drag(f, m).is_err(),
1480 "{f} at {m}"
1481 );
1482 }
1483 }
1484
1485 #[test]
1489 fn an_ellipsoid_rises_from_mach_08_in_a_straight_line() {
1490 for f in [0.1, 0.25, 0.5, 0.577, 1.0, 2.0, 3.0, 6.0] {
1491 let curve = PressureDragCurve::new(NoseShape::Elliptical {}, f, 0.0).unwrap();
1492 let c = |m: f64| curve.coefficient(m).unwrap();
1493 let (low, high) = (c(0.8), c(1.2));
1494 let scaled =
1495 fineness_scaled_pressure_drag(0.111, stagnation_drag_coefficient_for_test(1.2), f)
1496 .unwrap();
1497 close(high, scaled, 1e-9, "Stoney's first point, scaled");
1498 let near = |got: f64, want: f64, what: &str| {
1499 assert!(
1500 (got - want).abs() <= 1e-9,
1501 "{what} at fineness {f}: {got} {want}"
1502 );
1503 };
1504 near(c(0.8 - 1e-12), low, "continuous at Mach 0.8");
1505 near(c(1.2 - 1e-12), high, "continuous at Mach 1.2");
1506 for t in [0.25, 0.5, 0.75] {
1507 near(c(0.8 + 0.4 * t), low + t * (high - low), "the line");
1508 }
1509 let (_, slope) = curve.value_and_slope(0.8);
1510 close(
1511 slope,
1512 (high - low) / 0.4,
1513 1e-9,
1514 "a finite slope at Mach 0.8",
1515 );
1516 }
1517 let three = PressureDragCurve::new(NoseShape::Elliptical {}, 3.0, 0.0).unwrap();
1518 close(
1519 three.coefficient(1.0).unwrap(),
1520 0.5 * 0.111,
1521 1e-12,
1522 "fineness 3 halfway",
1523 );
1524 }
1525
1526 #[test]
1530 fn the_guides_worked_example() {
1531 let blunt = PressureDragCurve::step().coefficient(1.5).unwrap();
1532 close(blunt, 1.3074, 1e-4, "the blunt cylinder at Mach 1.5");
1533 let exponent = 6f64.ln() / 4f64.ln();
1534 close(exponent, 1.2925, 1e-4, "log₄ 6");
1535 let by_hand = blunt * (0.0893 / blunt).powf(exponent);
1536 let vk = PressureDragCurve::new(NoseShape::VON_KARMAN, 5.0, 0.0).unwrap();
1537 close(
1538 vk.coefficient(1.5).unwrap(),
1539 by_hand,
1540 1e-12,
1541 "5:1 von Kármán",
1542 );
1543 close(by_hand, 0.0407, 1e-3, "0.0407");
1544 let cone = cone_pressure_drag_coefficient(5.0, 1.5).unwrap();
1545 close(cone, 0.0653, 1e-3, "5:1 cone");
1546 }
1547
1548 #[test]
1554 fn niskanens_cone_against_stoneys_measured_cone() {
1555 let stoney = [
1556 (0.8, 0.0186, 0.865),
1557 (0.85, 0.0228, 1.046),
1558 (0.9, 0.0366, 0.852),
1559 (0.95, 0.0673, 0.546),
1560 (1.0, 0.1102, 0.492),
1561 (1.1, 0.1580, 0.483),
1562 (1.2, 0.1378, 0.453),
1563 (1.5, 0.1136, 0.147),
1564 (1.8, 0.1044, 0.070),
1565 (1.937, 0.1022, 0.040),
1566 ];
1567 for (m, measured, error) in stoney {
1568 let hpr = cone_pressure_drag_coefficient(3.0, m).unwrap();
1569 let got = hpr / measured - 1.0;
1570 assert!(
1571 (got - error).abs() < 0.001,
1572 "Mach {m}: {got:+.4}, recorded {error:+.3}"
1573 );
1574 }
1575 }
1576
1577 #[test]
1581 fn eq_3_87_stays_finite_when_the_rise_is_tiny() {
1582 for n in [0.868229375, 0.8675, 0.869, 0.8695] {
1583 let joint = (n / 6.0f64).atan();
1584 let curve =
1585 PressureDragCurve::new(NoseShape::PowerSeries { exponent: n }, 3.0, joint).unwrap();
1586 let rest = curve.rest_coefficient();
1587 let at_l = curve.coefficient(curve.transonic_lower_bound()).unwrap();
1588 for m in [0.0, 0.1, 0.3, 0.5, 0.79, 0.8, 1.0, 1.1, 1.19, 1.5] {
1589 let c = curve.coefficient(m).unwrap();
1590 assert!(c.is_finite(), "n = {n}, Mach {m}: {c}");
1591 if m < curve.transonic_lower_bound() {
1592 assert!(c >= rest.min(at_l) - 1e-15 && c <= rest.max(at_l) + 1e-15);
1593 }
1594 }
1595 }
1596 let tiny = subsonic_pressure_drag_coefficient(0.01, 0.01 + 1e-12, 1.0, 0.8, 0.5).unwrap();
1597 close(tiny, 0.01, 1e-9, "a rise of 1e-12");
1598 }
1599
1600 #[test]
1603 fn a_stubby_cone_takes_the_blend() {
1604 let c = cone_pressure_drag_coefficient(0.5, 1.0).unwrap();
1605 close(c, 0.647, 1e-3, "fineness 0.5 at Mach 1");
1606 close(
1608 cone_pressure_drag_coefficient(0.5, 0.0).unwrap(),
1609 0.547,
1610 1e-3,
1611 "at rest",
1612 );
1613 assert!(
1614 c < std::f64::consts::FRAC_1_SQRT_2 - 0.05,
1615 "below sin ε = sin 45°"
1616 );
1617 }
1618
1619 #[test]
1623 fn a_near_flat_nose_rises_from_rest_without_a_jump() {
1624 let n = 0.05;
1625 let curve = PressureDragCurve::new(
1626 NoseShape::PowerSeries { exponent: n },
1627 3.0,
1628 (n / 6.0).atan(),
1629 )
1630 .unwrap();
1631 let rest = curve.rest_coefficient();
1632 let at_08 = curve.coefficient(0.8).unwrap();
1633 for m in [0.01, 0.1, 0.3, 0.6] {
1634 let want = rest + (at_08 - rest) * (m / 0.8) * (m / 0.8);
1635 close(curve.coefficient(m).unwrap(), want, 1e-12, "quadratic");
1636 }
1637 assert!(curve.coefficient(0.01).unwrap() < 1e-3);
1638 close(
1639 at_08,
1640 0.7958,
1641 1e-3,
1642 "0.80 at Mach 0.8, near the flat face's 0.9947",
1643 );
1644 }
1645
1646 proptest::proptest! {
1647 #[test]
1650 fn every_shape_is_finite_and_non_negative(
1651 which in 0usize..6,
1652 parameter in 0.0f64..=1.0,
1653 fineness in 0.0f64..12.0,
1654 joint in 0.0f64..=std::f64::consts::FRAC_PI_2,
1655 mach in 0.0f64..5.0,
1656 ) {
1657 let shape = match which {
1658 0 => NoseShape::Conical {},
1659 1 => NoseShape::Ogive { radius_ratio: 1.0 + 10.0 * parameter },
1660 2 => NoseShape::Elliptical {},
1661 3 => NoseShape::PowerSeries { exponent: 0.05 + 0.95 * parameter },
1662 4 => NoseShape::ParabolicSeries { parameter },
1663 _ => NoseShape::Haack { parameter: parameter / 3.0 },
1664 };
1665 let curve = PressureDragCurve::new(shape, fineness, joint).unwrap();
1666 let c = curve.coefficient(mach).unwrap();
1667 proptest::prop_assert!(c.is_finite() && c >= 0.0, "{c}");
1668 let m_l = curve.transonic_lower_bound();
1669 let joins: &[f64] = if which == 2 { &[0.8, 1.2] } else { &[m_l] };
1671 for &join in joins {
1672 let below = curve.coefficient(join * (1.0 - 1e-12)).unwrap();
1673 let at = curve.coefficient(join).unwrap();
1674 proptest::prop_assert!((below - at).abs() <= 1e-9 * (1.0 + at), "{below} {at}");
1675 }
1676 }
1677
1678 #[test]
1682 fn continuous_in_the_shape_parameter(
1683 which in 0usize..3,
1684 knot in 0usize..3,
1685 fineness in 0.5f64..8.0,
1686 mach in 0.0f64..5.0,
1687 ) {
1688 let shape = |p: f64| match which {
1689 0 => NoseShape::PowerSeries { exponent: p },
1690 1 => NoseShape::ParabolicSeries { parameter: p },
1691 _ => NoseShape::Haack { parameter: p / 3.0 },
1692 };
1693 let p = [0.25, 0.5, 0.75][knot];
1694 let at = |p: f64| {
1695 PressureDragCurve::new(shape(p), fineness, 0.0)
1696 .unwrap()
1697 .coefficient(mach)
1698 .unwrap()
1699 };
1700 let gap = |step: f64| (at(p - step) - at(p + step)).abs();
1705 let (wide, narrow) = (gap(1e-6), gap(1e-12));
1706 let bound = if fineness >= 1.0 { 1e-4 } else { 0.01 };
1708 proptest::prop_assert!(
1709 narrow <= wide + 1e-12 && narrow <= bound,
1710 "{shape:?} at Mach {mach}: gaps {wide} and {narrow}",
1711 shape = shape(p)
1712 );
1713 }
1714 }
1715}