1use std::f64::consts::{PI, TAU};
40
41use hpr_design::FinPlanform;
42use serde::{Deserialize, Serialize};
43
44use crate::error::{AeroError, check_dimension, check_mach};
45
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct FinGeometry {
50 pub span_m: f64,
52 pub area_m2: f64,
54 pub midchord_sweep_rad: f64,
56 pub leading_edge_sweep_rad: f64,
59 pub trailing_edge_sweep_rad: f64,
62 pub mac_length_m: f64,
64 pub mac_leading_edge_m: f64,
66 pub mac_span_m: f64,
68}
69
70impl FinGeometry {
71 pub fn from_planform(planform: &FinPlanform) -> Result<Self, AeroError> {
77 planform.validate()?;
78 match *planform {
79 FinPlanform::Trapezoidal {
80 root_chord_m: c_r,
81 tip_chord_m: c_t,
82 span_m: s,
83 sweep_m: x_t,
84 } => {
85 let sum = c_r + c_t;
86 let y_mac = s / 3.0 * (c_r + 2.0 * c_t) / sum;
87 Ok(Self {
88 span_m: s,
89 area_m2: 0.5 * s * sum,
90 midchord_sweep_rad: (x_t + 0.5 * c_t - 0.5 * c_r).atan2(s),
91 leading_edge_sweep_rad: x_t.atan2(s),
92 trailing_edge_sweep_rad: (x_t + c_t - c_r).atan2(s),
93 mac_length_m: 2.0 / 3.0 * (c_r * c_r + c_r * c_t + c_t * c_t) / sum,
94 mac_leading_edge_m: x_t * y_mac / s,
95 mac_span_m: y_mac,
96 })
97 }
98 FinPlanform::Elliptical {
99 root_chord_m: c_r,
100 span_m: s,
101 } => {
102 let mac = 8.0 * c_r / (3.0 * PI);
103 Ok(Self {
104 span_m: s,
105 area_m2: 0.25 * PI * c_r * s,
106 midchord_sweep_rad: 0.0,
107 leading_edge_sweep_rad: elliptical_leading_edge_sweep(0.5 * c_r / s),
108 trailing_edge_sweep_rad: -elliptical_leading_edge_sweep(0.5 * c_r / s),
110 mac_length_m: mac,
111 mac_leading_edge_m: 0.5 * (c_r - mac),
112 mac_span_m: 4.0 * s / (3.0 * PI),
113 })
114 }
115 FinPlanform::Freeform {
116 ref points_m,
117 ref root_m,
118 } => Self::freeform(planform, points_m.iter().chain(root_m)),
119 _ => Err(AeroError::Unsupported("this fin planform".to_owned())),
120 }
121 }
122
123 fn freeform<'a>(
127 planform: &FinPlanform,
128 points: impl Iterator<Item = &'a [f64; 2]>,
129 ) -> Result<Self, AeroError> {
130 let mut heights: Vec<f64> = points.map(|p| p[1]).collect();
131 heights.sort_by(f64::total_cmp);
132 heights.dedup();
133 let span = planform.span_m();
134 let nodes = [
136 (-(0.6f64).sqrt(), 5.0 / 9.0),
137 (0.0, 8.0 / 9.0),
138 ((0.6f64).sqrt(), 5.0 / 9.0),
139 ];
140 let mut chords = Vec::new();
141 let (mut area, mut filled, mut c2, mut yc, mut xc, mut sweep, mut le_sweep, mut te_sweep) =
142 (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
143 for band in heights.windows(2) {
144 let (lo, hi) = (band[0], band[1]);
145 if hi - lo <= 1e-12 * span {
149 continue;
150 }
151 let half = 0.5 * (hi - lo);
152 let mid = 0.5 * (hi + lo);
153 let mut mids = [0.0; 2];
154 let mut leading = [0.0; 2];
155 let mut trailing = [0.0; 2];
156 for (k, &(t, w)) in nodes.iter().enumerate() {
157 let y = mid + half * t;
158 planform.chords_at(y, &mut chords);
159 let (Some(first), Some(last)) = (chords.first(), chords.last()) else {
160 return Err(AeroError::Domain {
162 what: "freeform fin height without a chord",
163 value: y,
164 });
165 };
166 let (le, te) = (first.0, last.1);
167 let c = te - le;
168 let wh = w * half;
169 area += wh * chords.iter().map(|(a, b)| b - a).sum::<f64>();
170 filled += wh * c;
171 c2 += wh * c * c;
172 yc += wh * y * c;
173 xc += wh * le * c;
174 if k != 1 {
175 mids[k / 2] = 0.5 * (le + te);
176 leading[k / 2] = le;
177 trailing[k / 2] = te;
178 }
179 }
180 let dy = 2.0 * half * (0.6f64).sqrt();
182 sweep += (hi - lo) * (mids[1] - mids[0]).atan2(dy);
183 le_sweep += (hi - lo) * (leading[1] - leading[0]).atan2(dy);
184 te_sweep += (hi - lo) * (trailing[1] - trailing[0]).atan2(dy);
185 }
186 if !(area > 0.0 && filled > 0.0 && span > 0.0) {
187 return Err(AeroError::Domain {
188 what: "fin area",
189 value: area,
190 });
191 }
192 Ok(Self {
193 span_m: span,
194 area_m2: area,
195 midchord_sweep_rad: sweep / span,
196 leading_edge_sweep_rad: le_sweep / span,
197 trailing_edge_sweep_rad: te_sweep / span,
198 mac_length_m: c2 / filled,
199 mac_leading_edge_m: xc / filled,
200 mac_span_m: yc / filled,
201 })
202 }
203
204 pub fn center_of_pressure_m(&self) -> f64 {
207 self.mac_leading_edge_m + 0.25 * self.mac_length_m
208 }
209
210 pub fn single_fin_slope(&self, reference_area_m2: f64, mach: f64) -> Result<f64, AeroError> {
218 check_mach(mach, 1.0, "the subsonic fin slope")?;
219 check_dimension("reference area", reference_area_m2, false)?;
220 Ok(self.slope_at((1.0 - mach * mach).sqrt(), reference_area_m2))
221 }
222
223 pub(crate) fn slope_at(&self, beta: f64, reference_area_m2: f64) -> f64 {
225 let s2 = self.span_m * self.span_m;
226 let f = beta * s2 / (self.area_m2 * self.midchord_sweep_rad.cos());
227 TAU * s2 / reference_area_m2 / (1.0 + (1.0 + f * f).sqrt())
228 }
229}
230
231fn elliptical_leading_edge_sweep(k: f64) -> f64 {
238 let d = 1.0 - k * k;
239 let integral = if d.abs() < 1e-6 {
240 1.0 + d / 6.0 + 0.075 * d * d
242 } else if d > 0.0 {
243 k.acos() / d.sqrt()
244 } else {
245 k.acosh() / (-d).sqrt()
246 };
247 std::f64::consts::FRAC_PI_2 - integral
248}
249
250pub fn interference_factor(span_m: f64, body_radius_m: f64) -> Result<f64, AeroError> {
257 check_dimension("fin span", span_m, false)?;
258 check_dimension("body radius at the fins", body_radius_m, true)?;
259 Ok(1.0 + body_radius_m / (span_m + body_radius_m))
260}
261
262pub fn fin_count_factor(count: u32) -> Result<f64, AeroError> {
271 match count {
272 1..=4 => Ok(1.0),
273 5 => Ok(0.948),
274 6 => Ok(0.913),
275 7 => Ok(0.854),
276 8 => Ok(0.810),
277 _ => Err(AeroError::Domain {
278 what: "fin count (1 to 8 have a normal-force model)",
279 value: f64::from(count),
280 }),
281 }
282}
283
284pub fn roll_sum(count: u32, base_angle_rad: f64, flow_roll_rad: f64) -> f64 {
288 if count >= 3 {
289 return 0.5 * f64::from(count);
290 }
291 (0..count)
292 .map(|k| {
293 let lambda = base_angle_rad + TAU * f64::from(k) / f64::from(count) - flow_roll_rad;
294 lambda.sin().powi(2)
295 })
296 .sum()
297}
298
299pub fn side_sum(count: u32, base_angle_rad: f64, flow_roll_rad: f64) -> f64 {
306 if count >= 3 {
307 return 0.0;
308 }
309 (0..count)
310 .map(|k| {
311 let lambda = base_angle_rad + TAU * f64::from(k) / f64::from(count) - flow_roll_rad;
312 -lambda.sin() * lambda.cos()
313 })
314 .sum()
315}
316
317const ELLIPSE_SIDES: u32 = 256;
320
321#[derive(Debug, Clone, PartialEq, Serialize)]
328pub struct FinOutline {
329 points_m: Vec<[f64; 2]>,
330 tip_leading_edge_m: [f64; 2],
331 tip_chord_m: f64,
332 area_m2: f64,
333 centroid_m: f64,
334}
335
336impl FinOutline {
337 pub fn points_m(&self) -> &[[f64; 2]] {
339 &self.points_m
340 }
341
342 pub fn tip_leading_edge_m(&self) -> [f64; 2] {
345 self.tip_leading_edge_m
346 }
347
348 pub fn tip_chord_m(&self) -> f64 {
351 self.tip_chord_m
352 }
353
354 pub fn area_m2(&self) -> f64 {
356 self.area_m2
357 }
358
359 pub fn centroid_m(&self) -> f64 {
361 self.centroid_m
362 }
363
364 pub fn from_planform(planform: &FinPlanform) -> Result<Self, AeroError> {
372 planform.validate()?;
373 let points = match *planform {
374 FinPlanform::Trapezoidal {
375 root_chord_m: c_r,
376 tip_chord_m: c_t,
377 span_m: s,
378 sweep_m: x_t,
379 } => vec![[0.0, 0.0], [x_t, s], [x_t + c_t, s], [c_r, 0.0]],
380 FinPlanform::Elliptical {
381 root_chord_m: c_r,
382 span_m: s,
383 } => (0..=ELLIPSE_SIDES)
384 .map(|i| {
385 let t = PI * f64::from(i) / f64::from(ELLIPSE_SIDES);
386 [0.5 * c_r * (1.0 - t.cos()), s * t.sin()]
387 })
388 .collect(),
389 FinPlanform::Freeform {
390 ref points_m,
391 ref root_m,
392 } => points_m.iter().chain(root_m).copied().collect(),
393 _ => return Err(AeroError::Unsupported("this fin planform".to_owned())),
394 };
395 let span = points.iter().fold(0.0_f64, |m, p| m.max(p[1]));
396 let at_tip = || points.iter().filter(|p| p[1] >= span * (1.0 - 1e-12));
399 let tip_x = at_tip().fold(f64::INFINITY, |m, p| m.min(p[0]));
400 let tip_end = at_tip().fold(f64::NEG_INFINITY, |m, p| m.max(p[0]));
401 let (area, moment) = area_and_moment(&points);
402 if !(area > 0.0 && tip_x.is_finite()) {
403 return Err(AeroError::Domain {
404 what: "fin area",
405 value: area,
406 });
407 }
408 Ok(Self {
409 tip_leading_edge_m: [tip_x, span],
410 tip_chord_m: tip_end - tip_x,
411 area_m2: area,
412 centroid_m: moment / area,
413 points_m: points,
414 })
415 }
416
417 pub fn tip_cone(&self, beta: f64) -> (f64, f64) {
427 debug_assert!(beta > 0.0 && beta.is_finite(), "beta {beta}");
428 let [x_t, s] = self.tip_leading_edge_m;
429 let aft = |p: [f64; 2]| p[0] - x_t - beta * (s - p[1]);
431 let (area, moment) = clipped_area_and_moment(&self.points_m, 1.0, aft);
432 let (area_back, moment_back) = clipped_area_and_moment(&self.points_m, -1.0, aft);
433 let (area, moment) = (area + area_back, moment + moment_back);
434 if area > 0.0 {
435 (area, moment / area)
436 } else {
437 (0.0, x_t)
438 }
439 }
440
441 pub fn supersonic(&self, beta: f64, reference_area_m2: f64) -> (f64, f64) {
453 let (cone, cone_x) = self.tip_cone(beta);
454 let loaded = self.area_m2 - 0.5 * cone;
455 let moment = self.area_m2 * self.centroid_m - 0.5 * cone * cone_x;
456 (4.0 / beta * loaded / reference_area_m2, moment / loaded)
457 }
458}
459
460impl FinOutline {
461 pub fn axis_moments(&self, body_radius_m: f64) -> (f64, f64) {
466 debug_assert!(body_radius_m.is_finite(), "body radius {body_radius_m}");
467 let m = clipped_moments(&self.points_m, 1.0, |_| 0.0);
468 about_axis(m, body_radius_m)
469 }
470
471 pub fn tip_cone_axis_moments(&self, beta: f64, body_radius_m: f64) -> (f64, f64) {
477 debug_assert!(beta > 0.0 && beta.is_finite(), "beta {beta}");
478 debug_assert!(body_radius_m.is_finite(), "body radius {body_radius_m}");
479 let [x_t, s] = self.tip_leading_edge_m;
480 let aft = |p: [f64; 2]| p[0] - x_t - beta * (s - p[1]);
481 let here = about_axis(clipped_moments(&self.points_m, 1.0, aft), body_radius_m);
482 let mut back = clipped_moments(&self.points_m, -1.0, aft);
484 back.y = -back.y;
485 let back = about_axis(back, body_radius_m);
486 (here.0 + back.0, here.1 + back.1)
487 }
488}
489
490fn about_axis(m: Moments, r: f64) -> (f64, f64) {
492 (r * m.area + m.y, r * r * m.area + 2.0 * r * m.y + m.yy)
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
499#[non_exhaustive]
500pub struct FinRoll {
501 pub forcing_per_rad: f64,
504 pub damping: f64,
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
512#[non_exhaustive]
513pub struct FinRollTerms {
514 pub body_radius_m: f64,
517 pub reference_diameter_m: f64,
519 pub first_moment_m3: f64,
521 pub second_moment_m4: f64,
523 pub transonic_start: FinRoll,
525 pub supersonic_start: FinRoll,
527}
528
529pub const TRANSONIC_START_MACH: f64 = 0.8;
532
533pub const SUPERSONIC_START_MACH: f64 = 1.2;
536
537#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
539#[non_exhaustive]
540pub struct FinLoading {
541 pub slope_per_rad: f64,
544 pub cp_m: f64,
546}
547
548#[derive(Debug, Clone, PartialEq, Serialize)]
566pub struct FinAero {
567 geometry: FinGeometry,
568 outline: FinOutline,
569 reference_area_m2: f64,
570 supersonic_mach: f64,
571 transonic_start: FinLoading,
572 supersonic_start: FinLoading,
573}
574
575impl FinAero {
576 pub fn geometry(&self) -> &FinGeometry {
578 &self.geometry
579 }
580
581 pub fn outline(&self) -> &FinOutline {
583 &self.outline
584 }
585
586 pub fn reference_area_m2(&self) -> f64 {
588 self.reference_area_m2
589 }
590
591 pub fn supersonic_mach(&self) -> f64 {
596 self.supersonic_mach
597 }
598
599 pub fn new(planform: &FinPlanform, reference_area_m2: f64) -> Result<Self, AeroError> {
606 check_dimension("reference area", reference_area_m2, false)?;
607 let geometry = FinGeometry::from_planform(planform)?;
608 let outline = FinOutline::from_planform(planform)?;
609 let aspect = 2.0 * geometry.span_m * geometry.span_m / geometry.area_m2;
610 let tip_ratio = outline.tip_chord_m / (2.0 * outline.tip_leading_edge_m[1]);
611 let supersonic_mach = SUPERSONIC_START_MACH
612 .max(1.0 / geometry.leading_edge_sweep_rad.cos())
613 .max(1.0 / geometry.trailing_edge_sweep_rad.cos())
614 .max((1.0 + 1.0 / (aspect * aspect)).sqrt())
615 .max((1.0 + tip_ratio * tip_ratio).sqrt());
616 let beta_sub = (1.0 - TRANSONIC_START_MACH * TRANSONIC_START_MACH).sqrt();
617 let transonic_start = FinLoading {
618 slope_per_rad: geometry.slope_at(beta_sub, reference_area_m2),
619 cp_m: geometry.center_of_pressure_m(),
620 };
621 let (slope_per_rad, cp_m) = outline.supersonic(
622 (supersonic_mach * supersonic_mach - 1.0).sqrt(),
623 reference_area_m2,
624 );
625 Ok(Self {
626 geometry,
627 outline,
628 reference_area_m2,
629 supersonic_mach,
630 transonic_start,
631 supersonic_start: FinLoading {
632 slope_per_rad,
633 cp_m,
634 },
635 })
636 }
637
638 pub fn loading(&self, mach: f64) -> Result<FinLoading, AeroError> {
644 check_mach(
645 mach,
646 crate::model::NORMAL_FORCE_MACH_LIMIT,
647 "the normal force",
648 )?;
649 Ok(self.loading_at(mach))
650 }
651
652 pub(crate) fn loading_at(&self, mach: f64) -> FinLoading {
654 if mach <= TRANSONIC_START_MACH {
655 FinLoading {
656 slope_per_rad: self
657 .geometry
658 .slope_at((1.0 - mach * mach).sqrt(), self.reference_area_m2),
659 cp_m: self.geometry.center_of_pressure_m(),
660 }
661 } else if mach >= self.supersonic_mach {
662 let (slope_per_rad, cp_m) = self
663 .outline
664 .supersonic((mach * mach - 1.0).sqrt(), self.reference_area_m2);
665 FinLoading {
666 slope_per_rad,
667 cp_m,
668 }
669 } else {
670 let t = (mach - TRANSONIC_START_MACH) / (self.supersonic_mach - TRANSONIC_START_MACH);
671 let (a, b) = (self.transonic_start, self.supersonic_start);
672 FinLoading {
673 slope_per_rad: a.slope_per_rad + t * (b.slope_per_rad - a.slope_per_rad),
674 cp_m: a.cp_m + t * (b.cp_m - a.cp_m),
675 }
676 }
677 }
678}
679
680impl FinAero {
681 pub fn roll(
707 &self,
708 mach: f64,
709 body_radius_m: f64,
710 reference_diameter_m: f64,
711 ) -> Result<FinRoll, AeroError> {
712 let terms = self.roll_terms(body_radius_m, reference_diameter_m)?;
713 check_mach(
714 mach,
715 crate::model::NORMAL_FORCE_MACH_LIMIT,
716 "the roll moment",
717 )?;
718 Ok(self.roll_with(&terms, mach))
719 }
720
721 pub fn roll_terms(
728 &self,
729 body_radius_m: f64,
730 reference_diameter_m: f64,
731 ) -> Result<FinRollTerms, AeroError> {
732 check_dimension("body radius at the fins", body_radius_m, true)?;
733 self.roll_terms_about(body_radius_m, reference_diameter_m)
734 }
735
736 pub(crate) fn roll_terms_about(
748 &self,
749 root_offset_m: f64,
750 reference_diameter_m: f64,
751 ) -> Result<FinRollTerms, AeroError> {
752 if !root_offset_m.is_finite() {
753 return Err(AeroError::Domain {
754 what: "fin root offset from the axis",
755 value: root_offset_m,
756 });
757 }
758 let body_radius_m = root_offset_m;
759 check_dimension("reference diameter", reference_diameter_m, false)?;
760 let (first, second) = self.outline.axis_moments(body_radius_m);
761 let mut terms = FinRollTerms {
762 body_radius_m,
763 reference_diameter_m,
764 first_moment_m3: first,
765 second_moment_m4: second,
766 transonic_start: FinRoll {
767 forcing_per_rad: 0.0,
768 damping: 0.0,
769 },
770 supersonic_start: FinRoll {
771 forcing_per_rad: 0.0,
772 damping: 0.0,
773 },
774 };
775 let beta_sub = (1.0 - TRANSONIC_START_MACH * TRANSONIC_START_MACH).sqrt();
776 let m_s = self.supersonic_mach;
777 terms.transonic_start = self.subsonic_roll(beta_sub, &terms);
778 terms.supersonic_start = self.supersonic_roll((m_s * m_s - 1.0).sqrt(), &terms);
779 Ok(terms)
780 }
781
782 pub(crate) fn roll_with(&self, terms: &FinRollTerms, mach: f64) -> FinRoll {
784 if mach <= TRANSONIC_START_MACH {
785 self.subsonic_roll((1.0 - mach * mach).sqrt(), terms)
786 } else if mach >= self.supersonic_mach {
787 self.supersonic_roll((mach * mach - 1.0).sqrt(), terms)
788 } else {
789 let (a, b) = (terms.transonic_start, terms.supersonic_start);
790 let t = (mach - TRANSONIC_START_MACH) / (self.supersonic_mach - TRANSONIC_START_MACH);
791 FinRoll {
792 forcing_per_rad: a.forcing_per_rad + t * (b.forcing_per_rad - a.forcing_per_rad),
793 damping: a.damping + t * (b.damping - a.damping),
794 }
795 }
796 }
797
798 fn subsonic_roll(&self, beta: f64, terms: &FinRollTerms) -> FinRoll {
799 let (r, d) = (terms.body_radius_m, terms.reference_diameter_m);
800 let slope = self.geometry.slope_at(beta, self.reference_area_m2);
801 let per_area = slope * self.reference_area_m2 / self.geometry.area_m2;
802 FinRoll {
803 forcing_per_rad: slope * (r + self.geometry.mac_span_m) / d,
804 damping: -2.0 * per_area * terms.second_moment_m4 / (self.reference_area_m2 * d * d),
805 }
806 }
807
808 fn supersonic_roll(&self, beta: f64, terms: &FinRollTerms) -> FinRoll {
809 let (r, d) = (terms.body_radius_m, terms.reference_diameter_m);
810 let (cone_first, cone_second) = self.outline.tip_cone_axis_moments(beta, r);
811 let load = 4.0 / beta / self.reference_area_m2;
812 FinRoll {
813 forcing_per_rad: load * (terms.first_moment_m3 - 0.5 * cone_first) / d,
814 damping: -2.0 * load * (terms.second_moment_m4 - 0.5 * cone_second) / (d * d),
815 }
816 }
817}
818
819pub fn roll_forcing_interference(span_m: f64, body_radius_m: f64) -> Result<f64, AeroError> {
836 check_dimension("fin span", span_m, false)?;
837 check_dimension("body radius at the fins", body_radius_m, true)?;
838 if body_radius_m == 0.0 {
839 return Ok(1.0);
840 }
841 let t = ((span_m + body_radius_m) / body_radius_m).clamp(1.001, 1e6);
842 let (t2, u) = (t * t, t - 1.0);
843 let a = ((t2 - 1.0) / (t2 + 1.0)).asin();
844 let q = (t2 + 1.0) * (t2 + 1.0) / (t2 * u * u);
845 let p = (t + 1.0) / (t * u);
846 Ok(
847 (PI * PI / 4.0 * (t + 1.0) * (t + 1.0) / t2 + PI * q * a - 2.0 * PI * p + q * a * a
848 - 4.0 * p * a
849 + 8.0 / (u * u) * ((t2 + 1.0) / (2.0 * t)).ln())
850 / (PI * PI),
851 )
852}
853
854pub fn roll_damping_interference(
872 span_m: f64,
873 body_radius_m: f64,
874 taper_ratio: f64,
875) -> Result<f64, AeroError> {
876 check_dimension("fin span", span_m, false)?;
877 check_dimension("body radius at the fins", body_radius_m, true)?;
878 check_dimension("fin taper ratio", taper_ratio, true)?;
879 if body_radius_m == 0.0 {
880 return Ok(1.0);
881 }
882 let (t, l) = (
883 ((span_m + body_radius_m) / body_radius_m).clamp(1.001, 1e6),
884 taper_ratio,
885 );
886 let u = t - 1.0;
887 let log_ratio = u.ln_1p() / u;
888 Ok(1.0
889 + ((t - l) / t - (1.0 - l) * log_ratio)
890 / ((t + 1.0) * (t - l) / 2.0 - (1.0 - l) * (t * t + t + 1.0) / 3.0))
891}
892
893fn area_and_moment(points: &[[f64; 2]]) -> (f64, f64) {
896 clipped_area_and_moment(points, 1.0, |_| 0.0)
897}
898
899fn clipped_area_and_moment(
905 points: &[[f64; 2]],
906 flip: f64,
907 inside: impl Fn([f64; 2]) -> f64,
908) -> (f64, f64) {
909 let m = clipped_moments(points, flip, inside);
910 (m.area, m.x)
911}
912
913#[derive(Debug, Clone, Copy, Default, PartialEq)]
915struct Moments {
916 area: f64,
918 x: f64,
920 y: f64,
922 yy: f64,
924}
925
926fn clipped_moments(points: &[[f64; 2]], flip: f64, inside: impl Fn([f64; 2]) -> f64) -> Moments {
928 let mut sums = Shoelace::default();
929 let n = points.len();
930 for (i, p) in points.iter().enumerate() {
931 let q = points[(i + 1) % n];
932 let (p, q) = ([p[0], flip * p[1]], [q[0], flip * q[1]]);
933 let (dp, dq) = (inside(p), inside(q));
934 if dp >= 0.0 {
935 sums.push(p);
936 }
937 if (dp >= 0.0) != (dq >= 0.0) {
938 let t = dp / (dp - dq);
939 sums.push([p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])]);
940 }
941 }
942 sums.finish()
943}
944
945#[derive(Default)]
947struct Shoelace {
948 first: Option<[f64; 2]>,
949 last: [f64; 2],
950 twice_area: f64,
951 six_moment: f64,
952 six_y_moment: f64,
953 twelve_yy_moment: f64,
954}
955
956impl Shoelace {
957 fn push(&mut self, v: [f64; 2]) {
958 if self.first.is_some() {
959 self.edge(self.last, v);
960 } else {
961 self.first = Some(v);
962 }
963 self.last = v;
964 }
965
966 fn edge(&mut self, p: [f64; 2], q: [f64; 2]) {
967 let cross = p[0] * q[1] - q[0] * p[1];
968 self.twice_area += cross;
969 self.six_moment += (p[0] + q[0]) * cross;
970 self.six_y_moment += (p[1] + q[1]) * cross;
971 self.twelve_yy_moment += (p[1] * p[1] + p[1] * q[1] + q[1] * q[1]) * cross;
972 }
973
974 fn finish(mut self) -> Moments {
976 if let Some(first) = self.first {
977 self.edge(self.last, first);
978 }
979 let sign = self.twice_area.signum();
980 Moments {
981 area: 0.5 * self.twice_area * sign,
982 x: self.six_moment * sign / 6.0,
983 y: self.six_y_moment * sign / 6.0,
984 yy: self.twelve_yy_moment * sign / 12.0,
985 }
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use std::f64::consts::FRAC_1_SQRT_2;
992
993 use super::*;
994
995 fn close(got: f64, want: f64, rel: f64, what: &str) {
996 let err = if want == 0.0 {
997 got.abs()
998 } else {
999 ((got - want) / want).abs()
1000 };
1001 assert!(
1002 err <= rel,
1003 "{what}: got {got}, want {want}, rel err {err:e}"
1004 );
1005 }
1006
1007 fn trapezoid(c_r: f64, c_t: f64, s: f64, x_t: f64) -> FinPlanform {
1008 FinPlanform::Trapezoidal {
1009 root_chord_m: c_r,
1010 tip_chord_m: c_t,
1011 span_m: s,
1012 sweep_m: x_t,
1013 }
1014 }
1015
1016 fn eq57(c_r: f64, c_t: f64, s: f64, x_t: f64, d: f64) -> f64 {
1019 let ell = (s * s + (x_t + 0.5 * c_t - 0.5 * c_r).powi(2)).sqrt();
1020 8.0 * (s / d).powi(2) / (1.0 + (1.0 + (2.0 * ell / (c_r + c_t)).powi(2)).sqrt())
1021 }
1022
1023 #[test]
1026 fn six_fin_cna_applies_fin_count_factor() {
1027 let set = |n: u32| roll_sum(n, 0.3, 1.1) * fin_count_factor(n).unwrap();
1028 close(set(6) / set(4), 1.37, 5e-4, "six over four");
1029 close(set(8) / set(4), 1.62, 1e-12, "eight over four");
1030 close(set(5), 2.37, 1e-12, "five");
1031 close(set(7), 2.99, 5e-4, "seven");
1033 assert!(set(6) < 1.5 * set(4));
1034 for n in [1, 2, 3, 4] {
1035 assert_eq!(fin_count_factor(n).unwrap(), 1.0);
1036 }
1037 assert!(fin_count_factor(0).is_err());
1038 assert!(fin_count_factor(9).is_err());
1039 }
1040
1041 #[test]
1044 fn elliptical_fin_cna_uses_zero_midchord_sweep() {
1045 let (c_r, s, d) = (0.1, 0.06, 0.05);
1046 let a_ref = 0.25 * PI * d * d;
1047 let ellipse = FinGeometry::from_planform(&FinPlanform::Elliptical {
1048 root_chord_m: c_r,
1049 span_m: s,
1050 })
1051 .unwrap();
1052 assert_eq!(ellipse.midchord_sweep_rad, 0.0);
1053 let area = 0.25 * PI * c_r * s;
1054 let f = s * s / area;
1055 let want = TAU * s * s / a_ref / (1.0 + (1.0 + f * f).sqrt());
1056 close(
1057 ellipse.single_fin_slope(a_ref, 0.0).unwrap(),
1058 want,
1059 1e-15,
1060 "slope",
1061 );
1062
1063 let loft =
1066 FinGeometry::from_planform(&trapezoid(c_r, 2.0 * area / s - c_r, s, 0.0)).unwrap();
1067 let ratio = loft.single_fin_slope(a_ref, 0.0).unwrap() / want;
1068 assert!((0.985..0.99).contains(&ratio), "{ratio}");
1069
1070 let n = 2000;
1072 let points: Vec<[f64; 2]> = (0..=n)
1073 .map(|i| {
1074 let t = PI * f64::from(i) / f64::from(n);
1075 let x = 0.5 * c_r * (1.0 - t.cos());
1076 [x, s * t.sin()]
1077 })
1078 .map(|[x, y]| [x, if y.abs() < 1e-15 { 0.0 } else { y }])
1079 .collect();
1080 let polygon = FinGeometry::from_planform(&FinPlanform::Freeform {
1081 points_m: points,
1082 root_m: Vec::new(),
1083 })
1084 .unwrap();
1085 assert!(
1086 polygon.midchord_sweep_rad.abs() < 1e-12,
1087 "{}",
1088 polygon.midchord_sweep_rad
1089 );
1090 close(
1091 polygon.single_fin_slope(a_ref, 0.0).unwrap(),
1092 want,
1093 1e-5,
1094 "polygon slope",
1095 );
1096 close(
1097 polygon.center_of_pressure_m(),
1098 ellipse.center_of_pressure_m(),
1099 1e-5,
1100 "polygon CP",
1101 );
1102 }
1103
1104 #[test]
1108 fn a_root_along_the_body_bounds_the_fin() {
1109 let outline = vec![[0.0, 0.0], [0.01, 0.03], [0.04, 0.03], [0.05, 0.01]];
1110 let root = vec![[0.04, 0.0095], [0.025, 0.0075], [0.01, 0.0035]];
1112 let shoelace = |points: &[[f64; 2]]| {
1113 0.5 * (0..points.len())
1114 .map(|i| {
1115 let (p, q) = (points[i], points[(i + 1) % points.len()]);
1116 q[0] * p[1] - p[0] * q[1]
1117 })
1118 .sum::<f64>()
1119 };
1120 let whole: Vec<[f64; 2]> = outline.iter().chain(&root).copied().collect();
1121 let curved = FinPlanform::Freeform {
1122 points_m: outline.clone(),
1123 root_m: root,
1124 };
1125 let fin = FinGeometry::from_planform(&curved).unwrap();
1126 close(fin.area_m2, shoelace(&whole), 1e-14, "subsonic area");
1127 let supersonic = FinOutline::from_planform(&curved).unwrap();
1128 close(
1129 supersonic.area_m2,
1130 shoelace(&whole),
1131 1e-14,
1132 "supersonic area",
1133 );
1134 assert!(
1135 shoelace(&whole) < shoelace(&outline) - 1e-5,
1136 "the bulge is not the chord"
1137 );
1138 let straight = |root_m: Vec<[f64; 2]>| {
1140 FinGeometry::from_planform(&FinPlanform::Freeform {
1141 points_m: outline.clone(),
1142 root_m,
1143 })
1144 .unwrap()
1145 };
1146 let (bare, drawn) = (
1147 straight(Vec::new()),
1148 straight(vec![[0.04, 0.008], [0.025, 0.005], [0.01, 0.002]]),
1149 );
1150 for (got, want, what) in [
1151 (drawn.area_m2, bare.area_m2, "area"),
1152 (
1153 drawn.center_of_pressure_m(),
1154 bare.center_of_pressure_m(),
1155 "CP",
1156 ),
1157 (drawn.mac_length_m, bare.mac_length_m, "MAC"),
1158 (drawn.midchord_sweep_rad, bare.midchord_sweep_rad, "sweep"),
1159 ] {
1160 close(got, want, 1e-12, what);
1161 }
1162 }
1163
1164 #[test]
1174 fn the_pods_cockpit_reads_above_openrocket_across_the_airflow() {
1175 let nose = hpr_design::Profile::nose(
1176 hpr_design::NoseShape::Ogive { radius_ratio: 1.0 },
1177 0.136525,
1178 0.0168275,
1179 )
1180 .unwrap();
1181 let (chord, fore) = (0.05, 0.136525 - 0.05);
1182 let base = nose.radius_m(fore);
1183 let root_m = (1..64)
1184 .rev()
1185 .map(|i| {
1186 let x = chord * f64::from(i) / 64.0;
1187 [x, nose.radius_m(fore + x) - base]
1188 })
1189 .collect();
1190 let fin = FinGeometry::from_planform(&FinPlanform::Freeform {
1191 points_m: vec![
1192 [0.0, 0.0],
1193 [0.009347826086956524, 0.006956521739130436],
1194 [chord, 0.0022276567072510058],
1195 ],
1196 root_m,
1197 })
1198 .unwrap();
1199 let a_ref = PI * 0.0168275 * 0.0168275;
1200 let slope = fin.single_fin_slope(a_ref, 0.3).unwrap()
1201 * interference_factor(fin.span_m, base).unwrap();
1202 close(slope, 0.2784, 1e-3, "hpr's cockpit");
1203 close(slope / 0.21167, 1.315, 2e-3, "against OpenRocket's");
1204 close(
1205 fore + fin.center_of_pressure_m(),
1206 0.09842,
1207 2e-3,
1208 "OpenRocket's CP from the tip",
1209 );
1210 }
1211
1212 #[test]
1215 fn trapezoid_matches_barrowman_and_its_polygon() {
1216 let (c_r, c_t, s, x_t, d) = (3.0, 2.0, 1.5, 1.5, 0.976);
1217 let a_ref = 0.25 * PI * d * d;
1218 let fin = FinGeometry::from_planform(&trapezoid(c_r, c_t, s, x_t)).unwrap();
1219 close(
1220 fin.single_fin_slope(a_ref, 0.0).unwrap(),
1221 eq57(c_r, c_t, s, x_t, d),
1222 1e-15,
1223 "eq. 57",
1224 );
1225 let eq76a = x_t / 3.0 * (c_r + 2.0 * c_t) / (c_r + c_t)
1226 + (c_r + c_t - c_r * c_t / (c_r + c_t)) / 6.0;
1227 close(fin.center_of_pressure_m(), eq76a, 1e-15, "eq. 76a");
1228 close(fin.center_of_pressure_m(), 1.333, 3e-4, "Testbed II X_F");
1230
1231 let polygon = FinGeometry::from_planform(&FinPlanform::Freeform {
1232 points_m: vec![[0.0, 0.0], [x_t, s], [x_t + c_t, s], [c_r, 0.0]],
1233 root_m: Vec::new(),
1234 })
1235 .unwrap();
1236 for (got, want, what) in [
1237 (polygon.area_m2, fin.area_m2, "area"),
1238 (polygon.midchord_sweep_rad, fin.midchord_sweep_rad, "sweep"),
1239 (polygon.mac_length_m, fin.mac_length_m, "MAC"),
1240 (polygon.mac_leading_edge_m, fin.mac_leading_edge_m, "MAC LE"),
1241 (polygon.mac_span_m, fin.mac_span_m, "MAC span"),
1242 ] {
1243 close(got, want, 1e-13, what);
1244 }
1245 let delta = FinGeometry::from_planform(&trapezoid(0.2, 0.0, 0.1, 0.2)).unwrap();
1247 close(
1248 delta.center_of_pressure_m(),
1249 0.2 / 3.0 * 1.0 + 0.2 / 6.0,
1250 1e-15,
1251 "delta CP",
1252 );
1253 let rectangle = FinGeometry::from_planform(&trapezoid(0.1, 0.1, 0.05, 0.0)).unwrap();
1254 assert_eq!(rectangle.midchord_sweep_rad, 0.0);
1255 close(
1256 rectangle.center_of_pressure_m(),
1257 0.025,
1258 1e-15,
1259 "rectangle CP",
1260 );
1261 }
1262
1263 #[test]
1266 fn jagged_fin_fills_its_gap_for_the_cp_only() {
1267 let points = vec![
1269 [0.0, 0.0],
1270 [0.0, 0.1],
1271 [0.03, 0.1],
1272 [0.03, 0.05],
1273 [0.07, 0.05],
1274 [0.07, 0.1],
1275 [0.1, 0.1],
1276 [0.1, 0.0],
1277 ];
1278 let fin = FinGeometry::from_planform(&FinPlanform::Freeform {
1279 points_m: points,
1280 root_m: Vec::new(),
1281 })
1282 .unwrap();
1283 close(
1284 fin.area_m2,
1285 0.01 - 0.04 * 0.05,
1286 1e-14,
1287 "area without the slot",
1288 );
1289 close(fin.mac_length_m, 0.1, 1e-14, "filled chord");
1290 close(fin.mac_leading_edge_m, 0.0, 1e-14, "LE");
1291 close(fin.mac_span_m, 0.05, 1e-14, "filled centroid span");
1292 assert_eq!(fin.midchord_sweep_rad, 0.0);
1293 }
1294
1295 #[test]
1298 fn fin_slope_follows_prandtl_glauert() {
1299 let fin = FinGeometry::from_planform(&trapezoid(0.12, 0.06, 0.08, 0.05)).unwrap();
1300 let a_ref = 0.25 * PI * 0.1 * 0.1;
1301 let mut last = 0.0;
1302 for mach in [0.0, 0.2, 0.5, 0.8, 0.95, 0.999] {
1303 let slope = fin.single_fin_slope(a_ref, mach).unwrap();
1304 assert!(slope > last, "{mach}");
1305 last = slope;
1306 let beta = (1.0 - mach * mach).sqrt();
1307 let ar = 2.0 * fin.span_m * fin.span_m / fin.area_m2;
1308 let eq36 = TAU * ar * (fin.area_m2 / a_ref)
1309 / (2.0 + (4.0 + (beta * ar / fin.midchord_sweep_rad.cos()).powi(2)).sqrt());
1310 close(slope, eq36, 1e-14, "eq. 3-6");
1311 }
1312 let near_one = fin.single_fin_slope(a_ref, 1.0 - 1e-12).unwrap();
1313 close(
1314 near_one,
1315 PI * fin.span_m * fin.span_m / a_ref,
1316 1e-5,
1317 "M → 1",
1318 );
1319 assert!(fin.single_fin_slope(a_ref, 1.0).is_err());
1320 assert!(fin.single_fin_slope(a_ref, -0.1).is_err());
1321 assert!(fin.single_fin_slope(a_ref, f64::NAN).is_err());
1322 }
1323
1324 #[test]
1327 fn interference_and_roll_limits() {
1328 assert_eq!(interference_factor(0.1, 0.0).unwrap(), 1.0);
1329 close(
1330 interference_factor(1e-12, 0.05).unwrap(),
1331 2.0,
1332 1e-10,
1333 "tiny span",
1334 );
1335 close(
1336 interference_factor(1.5, 0.368).unwrap(),
1337 1.197,
1338 1e-3,
1339 "Testbed II K",
1340 );
1341 assert!(interference_factor(0.0, 0.05).is_err());
1342 assert!(interference_factor(0.1, -0.01).is_err());
1343
1344 for n in 3..=8 {
1345 for roll in [0.0, 0.1, 0.7, 2.0, -3.0] {
1346 let direct: f64 = (0..n)
1347 .map(|k| {
1348 (0.2 + TAU * f64::from(k) / f64::from(n) - roll)
1349 .sin()
1350 .powi(2)
1351 })
1352 .sum();
1353 close(roll_sum(n, 0.2, roll), direct, 1e-14, "direct sum");
1354 }
1355 }
1356 for n in 3..=8 {
1357 assert_eq!(side_sum(n, 0.2, 0.9), 0.0);
1358 }
1359 close(side_sum(2, 0.0, PI / 4.0), 1.0, 1e-15, "two fins, side");
1361 close(roll_sum(2, 0.0, PI / 4.0), 1.0, 1e-15, "two fins, in plane");
1362 assert!(side_sum(2, 0.0, 0.0).abs() < 1e-15 && side_sum(2, 0.0, PI / 2.0).abs() < 1e-15);
1363 assert_eq!(roll_sum(1, 0.0, 0.0), 0.0);
1364 close(
1365 roll_sum(1, 0.0, PI / 2.0),
1366 1.0,
1367 1e-15,
1368 "one fin across the flow",
1369 );
1370 close(
1371 roll_sum(2, 0.0, PI / 2.0),
1372 2.0,
1373 1e-15,
1374 "two fins across the flow",
1375 );
1376 close(roll_sum(2, 0.0, PI / 4.0), 1.0, 1e-15, "two fins at 45°");
1377 }
1378
1379 #[test]
1382 fn nearly_level_tip_vertices_are_one_tip() {
1383 let tip = 3.5 * 0.0254;
1384 assert_ne!(tip, 0.0889);
1385 let nudged = FinPlanform::Freeform {
1386 points_m: vec![[0.0, 0.0], [0.03, 0.0889], [0.06, tip], [0.1, 0.0]],
1387 root_m: Vec::new(),
1388 };
1389 let level = FinPlanform::Freeform {
1390 points_m: vec![[0.0, 0.0], [0.03, 0.0889], [0.06, 0.0889], [0.1, 0.0]],
1391 root_m: Vec::new(),
1392 };
1393 let (a, b) = (
1394 FinGeometry::from_planform(&nudged).unwrap(),
1395 FinGeometry::from_planform(&level).unwrap(),
1396 );
1397 close(a.area_m2, b.area_m2, 1e-12, "area");
1398 close(
1399 a.center_of_pressure_m(),
1400 b.center_of_pressure_m(),
1401 1e-12,
1402 "CP",
1403 );
1404 close(a.midchord_sweep_rad, b.midchord_sweep_rad, 1e-12, "sweep");
1405 }
1406
1407 proptest::proptest! {
1408 #[test]
1411 fn freeform_integrals_match_the_design_quadrature(
1412 x1 in -0.05f64..0.15,
1413 tip in 0.0f64..0.1,
1414 y1 in 0.01f64..0.2,
1415 y2_ratio in 0.5f64..1.5,
1416 root in 0.02f64..0.3,
1417 steps in -3i32..=3,
1418 level in proptest::bool::ANY,
1419 ) {
1420 let mut y2 = if level { y1 } else { y1 * y2_ratio };
1421 for _ in 0..steps.unsigned_abs() {
1422 y2 = if steps > 0 { y2.next_up() } else { y2.next_down() };
1423 }
1424 let planform = FinPlanform::Freeform {
1425 points_m: vec![[0.0, 0.0], [x1, y1], [x1 + tip, y2], [root, 0.0]],
1426 root_m: Vec::new(),
1427 };
1428 proptest::prop_assume!(planform.validate().is_ok());
1429 let reference = planform.geometry().unwrap();
1430 let fin = FinGeometry::from_planform(&planform).unwrap();
1431 proptest::prop_assert!((fin.area_m2 / reference.area_m2 - 1.0).abs() < 1e-9);
1432 proptest::prop_assert!(
1433 (fin.mac_span_m / reference.centroid_span_m - 1.0).abs() < 1e-9
1434 );
1435 }
1436 }
1437
1438 #[test]
1443 fn roll_and_side_sums_rebuild_the_per_fin_vector() {
1444 for n in 1..=8u32 {
1445 for base in [0.0, 0.7, 1.0] {
1446 for roll in [0.0, 0.4, PI / 4.0, 2.5, -1.2] {
1447 let direct = (0..n).fold([0.0, 0.0], |acc, k| {
1448 let theta = base + TAU * f64::from(k) / f64::from(n);
1449 let push = (roll - theta).sin();
1450 [acc[0] - push * theta.sin(), acc[1] + push * theta.cos()]
1451 });
1452 let (c_n, c_y) = (roll_sum(n, base, roll), side_sum(n, base, roll));
1453 let rebuilt = [
1454 c_n * roll.cos() - c_y * roll.sin(),
1455 c_n * roll.sin() + c_y * roll.cos(),
1456 ];
1457 for (got, want) in rebuilt.iter().zip(direct) {
1458 assert!(
1459 (got - want).abs() < 1e-14,
1460 "{n} {base} {roll}: {rebuilt:?} {direct:?}"
1461 );
1462 }
1463 }
1464 }
1465 }
1466 let (c_n, c_y) = (roll_sum(2, 0.0, PI / 4.0), side_sum(2, 0.0, PI / 4.0));
1467 let push = [
1468 c_n * FRAC_1_SQRT_2 - c_y * FRAC_1_SQRT_2,
1469 c_n * FRAC_1_SQRT_2 + c_y * FRAC_1_SQRT_2,
1470 ];
1471 assert!(
1472 push[0].abs() < 1e-15 && (push[1] - 2.0 * FRAC_1_SQRT_2).abs() < 1e-15,
1473 "{push:?}"
1474 );
1475 }
1476
1477 #[test]
1482 fn fin_cna_compressibility_reduces_to_barrowman_at_m0() {
1483 let (c_r, c_t, s, x_t, d) = (0.12, 0.04, 0.1, 0.08, 0.127);
1484 let a_ref = 0.25 * PI * d * d;
1485 let fin = FinAero::new(&trapezoid(c_r, c_t, s, x_t), a_ref).unwrap();
1486 let at = |mach: f64| fin.loading(mach).unwrap();
1487 close(
1489 at(0.0).slope_per_rad,
1490 eq57(c_r, c_t, s, x_t, d),
1491 1e-15,
1492 "M0 slope",
1493 );
1494 let eq76a = x_t / 3.0 * (c_r + 2.0 * c_t) / (c_r + c_t)
1495 + (c_r + c_t - c_r * c_t / (c_r + c_t)) / 6.0;
1496 close(at(0.0).cp_m, eq76a, 1e-15, "M0 CP");
1497 close(
1499 at(0.6).slope_per_rad,
1500 fin.geometry().single_fin_slope(a_ref, 0.6).unwrap(),
1501 1e-15,
1502 "M0.6 slope",
1503 );
1504 assert!(at(0.6).slope_per_rad > 1.05 * at(0.0).slope_per_rad);
1505 assert_eq!(at(0.6).cp_m, at(0.0).cp_m);
1506 let m_s = fin.supersonic_mach();
1508 close(m_s, (1.0 + (x_t / s).powi(2)).sqrt(), 1e-15, "M_s");
1509 for m in [TRANSONIC_START_MACH, m_s] {
1511 let (below, above) = (at(m - 1e-9), at(m + 1e-9));
1512 close(below.slope_per_rad, above.slope_per_rad, 1e-7, "slope join");
1513 close(below.cp_m, above.cp_m, 1e-7, "CP join");
1514 }
1515 assert!(at(1.0).cp_m > at(0.8).cp_m && at(m_s).cp_m > at(1.0).cp_m);
1516 let beta = 3.0_f64.sqrt();
1520 let (area, centroid) = (0.5 * s * (c_r + c_t), fins_centroid(c_r, c_t, s, x_t));
1521 let cone = c_t * c_t / (2.0 * beta);
1522 let loaded = area - 0.5 * cone;
1523 close(
1524 at(2.0).slope_per_rad,
1525 4.0 / beta * loaded / a_ref,
1526 1e-13,
1527 "Mach 2 slope",
1528 );
1529 close(
1530 at(2.0).cp_m,
1531 (area * centroid - 0.5 * cone * (x_t + 2.0 * c_t / 3.0)) / loaded,
1532 1e-13,
1533 "Mach 2 CP",
1534 );
1535 assert!(at(2.0).slope_per_rad < at(m_s).slope_per_rad);
1536 assert!(fin.loading(5.0).is_err() && fin.loading(-0.1).is_err());
1537 }
1538
1539 #[test]
1543 fn the_guide_s_worked_example() {
1544 let a_ref = 0.25 * PI * 0.127 * 0.127;
1545 let fin = FinAero::new(&trapezoid(0.12, 0.04, 0.1, 0.08), a_ref).unwrap();
1546 let round = |x: f64, digits: i32| (x * 10f64.powi(digits)).round() / 10f64.powi(digits);
1547 assert_eq!(round(a_ref, 6), 0.012668);
1548 assert_eq!(
1549 round(fin.geometry().leading_edge_sweep_rad.to_degrees(), 2),
1550 38.66
1551 );
1552 assert_eq!(round(fin.supersonic_mach(), 4), 1.2806);
1553 let beta = 3.0_f64.sqrt();
1554 let (cone, _) = fin.outline().tip_cone(beta);
1555 assert_eq!(round(cone, 6), 0.000462);
1556 assert_eq!(round(0.04 / beta, 4), 0.0231);
1557 let table = [
1558 (0.0, 1.853, 0.0550),
1559 (0.8, 2.170, 0.0550),
1560 (1.0, 2.499, 0.0632),
1561 (fin.supersonic_mach(), 2.960, 0.0747),
1562 (1.5, 2.158, 0.0753),
1563 (2.0, 1.416, 0.0758),
1564 (3.0, 0.877, 0.0761),
1565 ];
1566 for (mach, slope, cp) in table {
1567 let loading = fin.loading(mach).unwrap();
1568 assert_eq!(round(loading.slope_per_rad, 3), slope, "Mach {mach}");
1569 assert_eq!(round(loading.cp_m, 4), cp, "Mach {mach}");
1570 }
1571 }
1572
1573 fn fins_centroid(c_r: f64, c_t: f64, s: f64, x_t: f64) -> f64 {
1575 let nodes = [
1577 (-(0.6f64).sqrt(), 5.0 / 9.0),
1578 (0.0, 8.0 / 9.0),
1579 ((0.6f64).sqrt(), 5.0 / 9.0),
1580 ];
1581 let (mut first, mut area) = (0.0, 0.0);
1582 for (t, w) in nodes {
1583 let y = 0.5 * s * (1.0 + t);
1584 let c = c_r + (c_t - c_r) * y / s;
1585 first += w * (x_t * y / s + 0.5 * c) * c;
1586 area += w * c;
1587 }
1588 first / area
1589 }
1590
1591 #[test]
1596 fn an_inverse_taper_waits_for_its_tip_cones_to_part() {
1597 let fin = FinAero::new(
1598 &FinPlanform::Freeform {
1599 points_m: vec![[0.0, 0.0], [-0.05, 0.08], [0.15, 0.08], [0.05, 0.0]],
1600 root_m: Vec::new(),
1601 },
1602 0.01,
1603 )
1604 .unwrap();
1605 close(fin.outline().tip_chord_m(), 0.2, 1e-15, "tip chord");
1606 close(
1607 fin.supersonic_mach(),
1608 (1.0 + 1.25_f64 * 1.25).sqrt(),
1609 1e-15,
1610 "M_s",
1611 );
1612 let mut last = fin.loading(fin.supersonic_mach()).unwrap().slope_per_rad;
1613 for i in 1..=30 {
1614 let mach = fin.supersonic_mach() + 0.1 * f64::from(i);
1615 if mach >= 5.0 {
1616 break;
1617 }
1618 let slope = fin.loading(mach).unwrap().slope_per_rad;
1619 assert!(slope < last, "Mach {mach}: {slope} after {last}");
1620 last = slope;
1621 let beta = (mach * mach - 1.0).sqrt();
1622 assert!(fin.outline().tip_cone(beta).0 <= 2.0 * fin.outline().area_m2());
1623 }
1624 }
1625
1626 #[test]
1629 fn a_strake_never_reaches_linear_theory() {
1630 let fin = FinAero::new(&trapezoid(0.5, 0.5, 0.02, 0.0), 0.01).unwrap();
1631 close(
1632 fin.supersonic_mach(),
1633 (1.0 + (0.5_f64 / 0.04).powi(2)).sqrt(),
1634 1e-15,
1635 "M_s",
1636 );
1637 assert!(fin.supersonic_mach() > 12.0);
1638 let mut last = fin.loading(0.8).unwrap();
1639 for i in 1..=419 {
1640 let loading = fin.loading(0.8 + 0.01 * f64::from(i)).unwrap();
1641 assert!(loading.slope_per_rad.is_finite() && loading.cp_m.is_finite());
1642 assert!((loading.slope_per_rad - last.slope_per_rad).abs() < 1e-3 * last.slope_per_rad);
1643 last = loading;
1644 }
1645 assert!(fin.loading(5.0).is_err());
1646 }
1647
1648 #[test]
1653 fn a_concave_fin_clips_exactly() {
1654 let outline = FinOutline::from_planform(&FinPlanform::Freeform {
1655 points_m: vec![
1656 [0.0, 0.0],
1657 [0.0, 0.1],
1658 [0.03, 0.1],
1659 [0.03, 0.05],
1660 [0.07, 0.05],
1661 [0.07, 0.1],
1662 [0.1, 0.1],
1663 [0.1, 0.0],
1664 ],
1665 root_m: Vec::new(),
1666 })
1667 .unwrap();
1668 assert_eq!(outline.tip_leading_edge_m(), [0.0, 0.1]);
1669 let (area, centroid) = outline.tip_cone(2.0);
1670 close(area, 0.0015, 1e-13, "cone area");
1671 let moment = 0.0025 * 0.2 / 3.0 - (0.07_f64.powi(3) - 0.03_f64.powi(3)) / 6.0;
1673 close(centroid, moment / 0.0015, 1e-12, "cone centroid");
1674 }
1675
1676 proptest::proptest! {
1677 #[test]
1684 fn supersonic_loading_stays_inside_the_fin(
1685 c_r in 0.02..0.4_f64,
1686 taper in 0.0..2.0_f64,
1687 s in 0.02..0.3_f64,
1688 sweep_deg in 0.0..65.0_f64,
1689 extra in 0.0..3.0_f64,
1690 ) {
1691 let c_t = taper * c_r;
1692 let sweep = s * sweep_deg.to_radians().tan();
1693 let fin = FinAero::new(&trapezoid(c_r, c_t, s, sweep), 0.01).unwrap();
1694 let mach = fin.supersonic_mach() + extra;
1695 if mach >= 5.0 {
1696 return Ok(());
1698 }
1699 let beta = (mach * mach - 1.0).sqrt();
1700 let (cone, _) = fin.outline().tip_cone(beta);
1701 let area = fin.outline().area_m2();
1702 proptest::prop_assert!((0.0..=2.0 * area * (1.0 + 1e-12)).contains(&cone));
1703 let loading = fin.loading(mach).unwrap();
1704 proptest::prop_assert!(loading.slope_per_rad > 0.0);
1705 if mach + 0.01 < 5.0 {
1706 let faster = fin.loading(mach + 0.01).unwrap();
1707 proptest::prop_assert!(faster.slope_per_rad < loading.slope_per_rad);
1708 }
1709 let xs: Vec<f64> = fin.outline().points_m().iter().map(|p| p[0]).collect();
1710 let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &x| (a.min(x), b.max(x)));
1711 proptest::prop_assert!(loading.cp_m >= lo - 1e-12 && loading.cp_m <= hi + 1e-12);
1712 }
1713 }
1714
1715 #[test]
1718 fn supersonic_start_follows_the_leading_edge_and_the_aspect_ratio() {
1719 let a_ref = 0.01;
1720 let start = |p: FinPlanform| FinAero::new(&p, a_ref).unwrap().supersonic_mach();
1721 assert_eq!(
1722 start(trapezoid(0.05, 0.05, 0.1, 0.0)),
1723 SUPERSONIC_START_MACH
1724 );
1725 close(
1726 start(trapezoid(0.1, 0.05, 0.1, 0.1)),
1727 2.0_f64.sqrt(),
1728 1e-15,
1729 "45° leading edge",
1730 );
1731 close(
1733 start(trapezoid(0.2, 0.2, 0.05, 0.0)),
1734 5.0_f64.sqrt(),
1735 1e-15,
1736 "stubby",
1737 );
1738 }
1739
1740 #[test]
1746 fn supersonic_rectangle_matches_linear_theory() {
1747 let (c, s) = (0.1, 0.08);
1748 let outline = FinOutline::from_planform(&trapezoid(c, c, s, 0.0)).unwrap();
1749 assert_eq!(outline.tip_leading_edge_m(), [0.0, s]);
1750 let a_ref = 0.25 * PI * 0.05 * 0.05;
1751 for mach in [1.217_f64, 1.6, 2.0, 3.0, 4.5] {
1753 let beta = (mach * mach - 1.0).sqrt();
1754 let aspect = 2.0 * s / c;
1755 let (slope, cp) = outline.supersonic(beta, a_ref);
1756 let exact = 4.0 / beta * (1.0 - 1.0 / (2.0 * beta * aspect)) * c * s / a_ref;
1757 close(slope, exact, 1e-14, "slope");
1758 let cone = c * c / (2.0 * beta);
1760 let want = (c * s * 0.5 * c - 0.5 * cone * 2.0 * c / 3.0) / (c * s - 0.5 * cone);
1761 close(cp, want, 1e-14, "CP");
1762 assert!(cp < 0.5 * c);
1763 }
1764 let delta = FinOutline::from_planform(&trapezoid(0.1, 0.0, 0.05, 0.1)).unwrap();
1766 assert_eq!(delta.tip_cone(2.0), (0.0, 0.1));
1767 }
1768
1769 #[test]
1772 fn outlines_of_each_planform_agree() {
1773 let (c_r, c_t, s, x_t) = (0.12, 0.04, 0.1, 0.08);
1774 let trapezoid_outline = FinOutline::from_planform(&trapezoid(c_r, c_t, s, x_t)).unwrap();
1775 let polygon = FinOutline::from_planform(&FinPlanform::Freeform {
1776 points_m: vec![[0.0, 0.0], [x_t, s], [x_t + c_t, s], [c_r, 0.0]],
1777 root_m: Vec::new(),
1778 })
1779 .unwrap();
1780 assert_eq!(trapezoid_outline, polygon);
1781 close(polygon.area_m2(), 0.5 * s * (c_r + c_t), 1e-15, "area");
1782 for beta in [0.8, 1.2, 2.5] {
1783 let (a, x) = polygon.tip_cone(beta);
1784 close(a, 0.5 * c_t * c_t / beta, 1e-13, "cone area");
1786 close(x, x_t + 2.0 * c_t / 3.0, 1e-13, "cone centroid");
1787 }
1788 let ellipse = FinOutline::from_planform(&FinPlanform::Elliptical {
1789 root_chord_m: 0.1,
1790 span_m: 0.06,
1791 })
1792 .unwrap();
1793 let area = 0.25 * PI * 0.1 * 0.06;
1794 assert!((1.0 - ellipse.area_m2() / area - 2.5e-5).abs() < 1e-6);
1795 close(ellipse.centroid_m(), 0.05, 1e-12, "ellipse centroid");
1796 close(ellipse.tip_leading_edge_m()[0], 0.05, 1e-12, "ellipse tip");
1797 }
1798
1799 #[test]
1803 fn roll_moments_match_the_planform_integrals() {
1804 let (c_r, c_t, s, x_t, r) = (0.15, 0.05, 0.1, 0.09, 0.04);
1805 let outline = FinOutline::from_planform(&trapezoid(c_r, c_t, s, x_t)).unwrap();
1806 let (first, second) = outline.axis_moments(r);
1807 close(
1808 first,
1809 r * 0.5 * s * (c_r + c_t) + s * s * (c_r + 2.0 * c_t) / 6.0,
1810 1e-14,
1811 "first",
1812 );
1813 let sigma = 0.5 * (c_r + c_t) * r * r * s
1814 + (c_r + 2.0 * c_t) / 3.0 * r * s * s
1815 + (c_r + 3.0 * c_t) / 12.0 * s * s * s;
1816 close(second, sigma, 1e-14, "trapezoid Σ");
1817 let ellipse = FinOutline::from_planform(&FinPlanform::Elliptical {
1818 root_chord_m: c_r,
1819 span_m: s,
1820 })
1821 .unwrap();
1822 let sigma = c_r * (PI / 4.0 * r * r * s + 2.0 / 3.0 * r * s * s + PI / 16.0 * s * s * s);
1823 close(ellipse.axis_moments(r).1, sigma, 2e-4, "ellipse Σ");
1825 }
1826
1827 #[test]
1831 fn supersonic_roll_matches_its_strips() {
1832 let (c_r, c_t, s, x_t) = (0.085852, 0.054991, 0.0534162, 0.030861);
1833 let (r, d) = (0.028575, 0.05715);
1834 let a_ref = 0.25 * PI * d * d;
1835 let fin = FinAero::new(&trapezoid(c_r, c_t, s, x_t), a_ref).unwrap();
1836 for mach in [1.5, 1.8, 2.3, 2.96, 3.96, 4.63_f64] {
1837 let beta = (mach * mach - 1.0).sqrt();
1838 let strips = 20_000;
1839 let (mut forcing, mut damping) = (0.0, 0.0);
1840 for i in 0..strips {
1841 let y = s * (f64::from(i) + 0.5) / f64::from(strips);
1842 let (le, te) = (x_t * y / s, c_r + (x_t + c_t - c_r) * y / s);
1843 let aft_of = |x: f64| (te - x.max(le)).max(0.0);
1845 let load = (te - le)
1846 - 0.5 * aft_of(x_t + beta * (s - y))
1847 - 0.5 * aft_of(x_t + beta * (s + y));
1848 let xi = r + y;
1849 let dy = s / f64::from(strips);
1850 forcing += 4.0 / beta / a_ref * xi * load * dy / d;
1851 damping -= 8.0 / beta / a_ref * xi * xi * load * dy / (d * d);
1852 }
1853 let roll = fin.roll(mach, r, d).unwrap();
1854 let what = format!("Mach {mach}");
1855 close(roll.forcing_per_rad, forcing, 1e-7, &what);
1856 close(roll.damping, damping, 1e-7, &what);
1857 }
1858 }
1859
1860 #[test]
1864 fn subsonic_roll_is_barrowman_s_and_joins_linear_theory() {
1865 let (c_r, c_t, s, x_t) = (0.058, 0.018, 0.077, 0.04);
1866 let (r, d) = (0.035, 0.07);
1867 let a_ref = 0.25 * PI * d * d;
1868 let fin = FinAero::new(&trapezoid(c_r, c_t, s, x_t), a_ref).unwrap();
1869 let area = 0.5 * s * (c_r + c_t);
1870 let y_mac = s * (c_r + 2.0 * c_t) / (3.0 * (c_r + c_t));
1871 let sigma = 0.5 * (c_r + c_t) * r * r * s
1872 + (c_r + 2.0 * c_t) / 3.0 * r * s * s
1873 + (c_r + 3.0 * c_t) / 12.0 * s * s * s;
1874 for mach in [0.0, 0.3, 0.6, 0.8] {
1875 let slope = fin.geometry().single_fin_slope(a_ref, mach).unwrap();
1876 let roll = fin.roll(mach, r, d).unwrap();
1877 let what = format!("Mach {mach}");
1878 close(roll.forcing_per_rad, slope * (r + y_mac) / d, 1e-14, &what);
1879 let per_area = slope * a_ref / area;
1880 close(
1881 roll.damping,
1882 -2.0 * per_area * sigma / (a_ref * d * d),
1883 1e-13,
1884 &what,
1885 );
1886 }
1887 for edge in [TRANSONIC_START_MACH, fin.supersonic_mach()] {
1888 let (a, b) = (
1889 fin.roll(edge - 1e-9, r, d).unwrap(),
1890 fin.roll(edge + 1e-9, r, d).unwrap(),
1891 );
1892 close(a.forcing_per_rad, b.forcing_per_rad, 1e-7, "forcing");
1893 close(a.damping, b.damping, 1e-7, "damping");
1894 }
1895 assert!(fin.roll(5.0, r, d).is_err() && fin.roll(0.5, -r, d).is_err());
1896 }
1897
1898 #[test]
1902 fn roll_interference_follows_barrowman() {
1903 for (t, l) in [(1.2, 0.3), (2.0, 1.0), (2.87, 0.64), (4.0, 0.0), (7.0, 1.4)] {
1904 let (r, s) = (1.0, t - 1.0);
1905 let chord = |xi: f64| 1.0 - (1.0 - l) * (xi - r) / s;
1906 let simpson = |f: &dyn Fn(f64) -> f64| {
1907 let n = 2000;
1908 let h = s / f64::from(n);
1909 (0..=n)
1910 .map(|i| {
1911 let w = if i == 0 || i == n {
1912 1.0
1913 } else if i % 2 == 1 {
1914 4.0
1915 } else {
1916 2.0
1917 };
1918 w * f(r + h * f64::from(i))
1919 })
1920 .sum::<f64>()
1921 * h
1922 / 3.0
1923 };
1924 let integral =
1925 1.0 + simpson(&|xi| chord(xi) / (xi * xi)) / simpson(&|xi| xi * chord(xi));
1926 close(
1927 roll_damping_interference(s, r, l).unwrap(),
1928 integral,
1929 1e-10,
1930 &format!("τ {t}, λ {l}"),
1931 );
1932 }
1933 assert_eq!(roll_damping_interference(0.1, 0.0, 0.5).unwrap(), 1.0);
1934 assert_eq!(roll_forcing_interference(0.1, 0.0).unwrap(), 1.0);
1935 close(
1936 roll_damping_interference(1e-6, 1.0, 0.5).unwrap(),
1937 2.0,
1938 2e-3,
1939 "no span",
1940 );
1941 close(
1942 roll_forcing_interference(1e3, 1.0).unwrap(),
1943 1.0,
1944 2e-3,
1945 "no body",
1946 );
1947 for t in [1.001, 1.5, 2.0, 2.87, 5.0, 20.0] {
1948 let k = roll_forcing_interference(t - 1.0, 1.0).unwrap();
1949 assert!(k > 0.9 && k <= 1.0, "τ {t}: {k}");
1950 }
1951 assert!(roll_damping_interference(0.1, 0.05, -0.1).is_err());
1952 }
1953
1954 #[test]
1959 fn the_basic_finner_damps_as_barrowman_computed() {
1960 let d = 1.0;
1961 let a_ref = 0.25 * PI * d * d;
1962 let fin = FinAero::new(&trapezoid(d, d, d, 0.0), a_ref).unwrap();
1963 let k_r = roll_damping_interference(d, 0.5 * d, 1.0).unwrap();
1964 let clp = 4.0 * fin.roll(0.07, 0.5 * d, d).unwrap().damping * k_r;
1965 close(clp, -34.21, 0.03, "C_lp at Mach 0.07");
1966 }
1967}