1use std::f64::consts::PI;
68
69use hpr_core::quadrature::{Tolerance, integrate};
70use hpr_core::{DMat3, DQuat, DVec3};
71use serde::{Deserialize, Serialize};
72
73use crate::error::DesignError;
74use crate::mass::MassProperties;
75use crate::material::Material;
76use crate::shapes::{Profile, check_dimension};
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
81#[non_exhaustive]
82pub enum FinPlanform {
83 Trapezoidal {
85 root_chord_m: f64,
87 tip_chord_m: f64,
89 span_m: f64,
91 sweep_m: f64,
93 },
94 Elliptical {
96 root_chord_m: f64,
98 span_m: f64,
100 },
101 Freeform {
106 points_m: Vec<[f64; 2]>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 root_m: Vec<[f64; 2]>,
112 },
113}
114
115#[derive(
117 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, schemars::JsonSchema,
118)]
119#[serde(rename_all = "snake_case")]
120#[non_exhaustive]
121pub enum FinCrossSection {
122 #[default]
124 Square,
125 Rounded,
127 Airfoil,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
133#[serde(deny_unknown_fields)]
134pub struct FinTab {
135 pub height_m: f64,
137 pub length_m: f64,
139 pub offset_m: f64,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
145#[serde(deny_unknown_fields)]
146pub struct FinFillet {
147 pub radius_m: f64,
149 pub material: Material,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
155#[serde(deny_unknown_fields)]
156pub struct FinSet {
157 pub count: u32,
159 pub planform: FinPlanform,
161 pub thickness_m: f64,
163 #[serde(default)]
165 pub cross_section: FinCrossSection,
166 #[serde(default)]
168 pub tab: Option<FinTab>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub fillet: Option<FinFillet>,
172 #[serde(default)]
174 pub cant_rad: f64,
175 #[serde(default)]
177 pub base_angle_rad: f64,
178 pub material: Material,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
184pub struct PlanformGeometry {
185 pub area_m2: f64,
187 pub centroid_x_m: f64,
189 pub centroid_span_m: f64,
191}
192
193#[cfg(test)]
196fn naca_polynomial(xi: f64) -> f64 {
197 0.2969 * xi.sqrt() - 0.1260 * xi - 0.3516 * xi * xi + 0.2843 * xi.powi(3) - 0.1015 * xi.powi(4)
198}
199
200const AIRFOIL_PHI: [f64; 3] = [
202 10.0 * (0.2969 * 2.0 / 3.0 - 0.1260 / 2.0 - 0.3516 / 3.0 + 0.2843 / 4.0 - 0.1015 / 5.0),
203 10.0 * (0.2969 * 2.0 / 5.0 - 0.1260 / 3.0 - 0.3516 / 4.0 + 0.2843 / 5.0 - 0.1015 / 6.0),
204 10.0 * (0.2969 * 2.0 / 7.0 - 0.1260 / 4.0 - 0.3516 / 5.0 + 0.2843 / 6.0 - 0.1015 / 7.0),
205];
206
207const AIRFOIL_PSI: f64 = 0.472_889_488_894_451_523_708_489_652_762;
210
211fn chord_moments(section: FinCrossSection, a: f64, b: f64, t: f64) -> [f64; 4] {
213 let c = b - a;
214 if c <= 0.0 {
215 return [0.0; 4];
216 }
217 match section {
218 FinCrossSection::Square => [
219 t * c,
220 0.5 * t * (b * b - a * a),
221 t * (b.powi(3) - a.powi(3)) / 3.0,
222 t.powi(3) * c / 12.0,
223 ],
224 FinCrossSection::Airfoil => {
225 let [p0, p1, p2] = AIRFOIL_PHI;
226 [
227 t * c * p0,
228 t * (a * c * p0 + c * c * p1),
229 t * (a * a * c * p0 + 2.0 * a * c * c * p1 + c.powi(3) * p2),
230 t.powi(3) * c * AIRFOIL_PSI / 12.0,
231 ]
232 }
233 FinCrossSection::Rounded => {
234 let mid = t.min(c);
238 let r = 0.5 * mid;
239 let d0 = r * r * (2.0 - PI / 2.0);
240 let d1 = r * d0 - r.powi(3) / 3.0;
241 let d2 = r * r * d0 - PI * r.powi(4) / 8.0;
242 let e0 = 8.0 * r.powi(4) - 1.5 * PI * r.powi(4);
243 let m0 = mid * c - 2.0 * d0;
244 [
245 m0,
246 m0 * 0.5 * (a + b),
247 mid * (b.powi(3) - a.powi(3)) / 3.0
248 - (a * a * d0 + 2.0 * a * d1 + d2)
249 - (b * b * d0 - 2.0 * b * d1 + d2),
250 (mid.powi(3) * c - 2.0 * e0) / 12.0,
251 ]
252 }
253 }
254}
255
256impl FinPlanform {
257 pub fn root_chord_m(&self) -> f64 {
259 match self {
260 Self::Trapezoidal { root_chord_m, .. } | Self::Elliptical { root_chord_m, .. } => {
261 *root_chord_m
262 }
263 Self::Freeform { points_m, .. } => points_m.last().map_or(0.0, |p| p[0]),
264 }
265 }
266
267 pub fn span_m(&self) -> f64 {
269 match self {
270 Self::Trapezoidal { span_m, .. } | Self::Elliptical { span_m, .. } => *span_m,
271 Self::Freeform { points_m, root_m } => {
272 points_m.iter().chain(root_m).fold(0.0, |m, p| m.max(p[1]))
273 }
274 }
275 }
276
277 pub fn validate(&self) -> Result<(), DesignError> {
284 match self {
285 Self::Trapezoidal {
286 root_chord_m,
287 tip_chord_m,
288 span_m,
289 sweep_m,
290 } => {
291 check_dimension("fin root chord", *root_chord_m, false)?;
292 check_dimension("fin tip chord", *tip_chord_m, true)?;
293 check_dimension("fin span", *span_m, false)?;
294 if !sweep_m.is_finite() {
295 return Err(DesignError::Domain {
296 what: "fin sweep",
297 value: *sweep_m,
298 });
299 }
300 }
301 Self::Elliptical {
302 root_chord_m,
303 span_m,
304 } => {
305 check_dimension("fin root chord", *root_chord_m, false)?;
306 check_dimension("fin span", *span_m, false)?;
307 }
308 Self::Freeform { points_m, root_m } => validate_outline(points_m, root_m)?,
309 }
310 Ok(())
311 }
312
313 pub fn chords_at(&self, h: f64, out: &mut Vec<(f64, f64)>) {
317 out.clear();
318 match self {
319 Self::Trapezoidal {
320 root_chord_m,
321 tip_chord_m,
322 span_m,
323 sweep_m,
324 } => {
325 if (0.0..*span_m).contains(&h) {
326 let f = h / span_m;
327 let lead = sweep_m * f;
328 out.push((lead, lead + root_chord_m + (tip_chord_m - root_chord_m) * f));
329 }
330 }
331 Self::Elliptical {
332 root_chord_m,
333 span_m,
334 } => {
335 if (0.0..*span_m).contains(&h) {
336 let f = h / span_m;
337 let chord = root_chord_m * (1.0 - f * f).max(0.0).sqrt();
338 let lead = 0.5 * (root_chord_m - chord);
339 out.push((lead, lead + chord));
340 }
341 }
342 Self::Freeform { points_m, root_m } => {
343 let mut crossings: Vec<f64> = Vec::new();
344 let polygon = || points_m.iter().chain(root_m);
345 for (&p, &q) in polygon().zip(polygon().cycle().skip(1)) {
346 let (lo, hi) = (p[1].min(q[1]), p[1].max(q[1]));
347 if p[1] != q[1] && h >= lo && h < hi {
348 crossings.push(p[0] + (h - p[1]) * (q[0] - p[0]) / (q[1] - p[1]));
349 }
350 }
351 crossings.sort_by(f64::total_cmp);
352 let (pairs, _) = crossings.as_chunks::<2>();
353 out.extend(pairs.iter().map(|&[a, b]| (a, b)));
354 }
355 }
356 }
357
358 fn breakpoints(&self) -> Vec<f64> {
360 let mut points = vec![0.0, self.span_m()];
361 if let Self::Freeform { points_m, root_m } = self {
362 points.extend(points_m.iter().chain(root_m).map(|p| p[1]));
363 }
364 points.sort_by(f64::total_cmp);
365 points.dedup();
366 points
367 }
368
369 pub fn geometry(&self) -> Result<PlanformGeometry, DesignError> {
375 self.validate()?;
376 let scale = self.root_chord_m() + self.span_m();
377 let mut chords = Vec::new();
378 let mut total = [0.0; 3];
379 for pair in self.breakpoints().windows(2) {
380 let piece = integrate(
381 |h| {
382 self.chords_at(h * scale, &mut chords);
383 chords.iter().fold([0.0; 3], |acc, &(a, b)| {
384 let (a, b) = (a / scale, b / scale);
385 [
386 acc[0] + (b - a),
387 acc[1] + 0.5 * (b * b - a * a),
388 acc[2] + h * (b - a),
389 ]
390 })
391 },
392 pair[0] / scale,
393 pair[1] / scale,
394 PLATE,
395 )?;
396 for (sum, value) in total.iter_mut().zip(piece.value) {
397 *sum += value;
398 }
399 }
400 let area = total[0] * scale * scale;
401 if area <= 0.0 {
402 return Err(DesignError::Geometry("the fin has no area".to_owned()));
403 }
404 Ok(PlanformGeometry {
405 area_m2: area,
406 centroid_x_m: scale * total[1] / total[0],
407 centroid_span_m: scale * total[2] / total[0],
408 })
409 }
410}
411
412const PLATE: Tolerance = Tolerance {
414 relative: 1e-12,
415 absolute: 1e-16,
416 max_intervals: 4000,
417};
418
419fn validate_outline(outline: &[[f64; 2]], root: &[[f64; 2]]) -> Result<(), DesignError> {
423 if outline.len() < 3 {
424 return Err(DesignError::Geometry(format!(
425 "a freeform fin needs at least 3 points, got {}",
426 outline.len()
427 )));
428 }
429 let points: Vec<[f64; 2]> = outline.iter().chain(root).copied().collect();
430 for p in &points {
431 for (what, value) in [("freeform fin x", p[0]), ("freeform fin span", p[1])] {
432 if !value.is_finite() {
433 return Err(DesignError::Domain { what, value });
434 }
435 }
436 if p[1] < 0.0 {
437 return Err(DesignError::Domain {
438 what: "freeform fin span",
439 value: p[1],
440 });
441 }
442 }
443 let (first, last) = (outline[0], outline[outline.len() - 1]);
444 if first != [0.0, 0.0] || last[0] <= 0.0 {
445 return Err(DesignError::Geometry(
446 "a freeform fin must start at the root leading edge [0, 0] and end at the root \
447 trailing edge [c, h] with c > 0"
448 .to_owned(),
449 ));
450 }
451 let mut aft = last[0];
453 for p in root {
454 if !(p[0] < aft && p[0] > 0.0) {
455 return Err(DesignError::Geometry(format!(
456 "a freeform fin's root points must run fore, strictly between its trailing edge \
457 at x = {} m and its leading edge; one is at x = {} m",
458 last[0], p[0]
459 )));
460 }
461 aft = p[0];
462 }
463 let points = points.as_slice();
464 let n = points.len();
465 let edge = |i: usize| (points[i], points[(i + 1) % n]);
466 for i in 0..n {
467 for j in (i + 1)..n {
468 if j == i + 1 || (i == 0 && j == n - 1) {
470 continue;
471 }
472 let (p, q) = edge(i);
473 let (r, s) = edge(j);
474 if segments_touch(p, q, r, s) {
475 return Err(DesignError::Geometry(format!(
476 "the freeform fin outline crosses itself (edges {i} and {j})"
477 )));
478 }
479 }
480 }
481 let twice_area: f64 = (0..n)
482 .map(|i| {
483 let (p, q) = edge(i);
484 p[0] * q[1] - q[0] * p[1]
485 })
486 .sum();
487 if twice_area == 0.0 {
488 return Err(DesignError::Geometry("the fin has no area".to_owned()));
489 }
490 if twice_area > 0.0 {
494 return Err(DesignError::Geometry(
495 "the freeform fin outline runs below its root".to_owned(),
496 ));
497 }
498 Ok(())
499}
500
501fn segments_touch(p: [f64; 2], q: [f64; 2], r: [f64; 2], s: [f64; 2]) -> bool {
503 let cross = |o: [f64; 2], a: [f64; 2], b: [f64; 2]| {
504 (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
505 };
506 let within = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
507 c[0] >= a[0].min(b[0])
508 && c[0] <= a[0].max(b[0])
509 && c[1] >= a[1].min(b[1])
510 && c[1] <= a[1].max(b[1])
511 };
512 let d1 = cross(r, s, p);
513 let d2 = cross(r, s, q);
514 let d3 = cross(p, q, r);
515 let d4 = cross(p, q, s);
516 if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
517 && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
518 {
519 return true;
520 }
521 (d1 == 0.0 && within(r, s, p))
522 || (d2 == 0.0 && within(r, s, q))
523 || (d3 == 0.0 && within(p, q, r))
524 || (d4 == 0.0 && within(p, q, s))
525}
526
527type PlateIntegrals = [f64; 7];
530
531impl FinSet {
532 pub const ROOT_ON_SURFACE_M: f64 = 1e-6;
536
537 pub const ROOT_SLIVER_SHARE: f64 = 1e-3;
543
544 fn root_points(&self) -> Vec<[f64; 2]> {
547 let mut points = vec![[0.0, 0.0]];
548 match &self.planform {
549 FinPlanform::Freeform { points_m, root_m } => {
550 points.extend(root_m.iter().rev());
551 points.extend(points_m.last());
552 }
553 other => points.push([other.root_chord_m(), 0.0]),
554 }
555 points
556 }
557
558 pub fn check_level_root(&self) -> Result<(), DesignError> {
565 if self.root_points().iter().any(|p| p[1] != 0.0) {
566 return Err(DesignError::Geometry(
567 "a fin set on a body tube needs a level root: a freeform outline that ends at \
568 h = 0, with every root point there too"
569 .to_owned(),
570 ));
571 }
572 Ok(())
573 }
574
575 pub fn root_on_surface(&self, profile: &Profile, fore_m: f64) -> (f64, f64) {
582 let base = profile.radius_m(fore_m);
583 let miss = |[x, h]: [f64; 2]| h - (profile.radius_m(fore_m + x) - base);
584 let points = self.root_points();
585 let off = points
587 .iter()
588 .map(|&p| miss(p).abs())
589 .fold(0.0, |a: f64, m| {
590 if a.is_nan() || m.is_nan() {
591 f64::NAN
592 } else {
593 a.max(m)
594 }
595 });
596 let sliver = points
597 .windows(2)
598 .map(|pair| {
599 let middle = [
600 0.5 * (pair[0][0] + pair[1][0]),
601 0.5 * (pair[0][1] + pair[1][1]),
602 ];
603 2.0 / 3.0 * miss(middle).abs() * (pair[1][0] - pair[0][0])
604 })
605 .sum();
606 (off, sliver)
607 }
608 pub fn root_radius_on(&self, profile: &Profile, fore_m: f64) -> Result<f64, DesignError> {
619 let length = profile.length_m();
620 let chord = self.planform.root_chord_m();
621 let slack = Self::ROOT_ON_SURFACE_M;
622 if !(fore_m >= -slack && fore_m + chord <= length + slack) {
623 return Err(DesignError::Geometry(format!(
624 "a fin set's root runs from {fore_m} m to {} m along a body {length} m long; on a \
625 nose cone or a transition it must stay on it",
626 fore_m + chord
627 )));
628 }
629 if self.tab.is_some() || self.fillet.is_some() {
630 return Err(DesignError::Geometry(
631 "a fin set on a nose cone or a transition with a tab or a fillet: their models \
632 take a body tube"
633 .to_owned(),
634 ));
635 }
636 let (off, sliver) = self.root_on_surface(profile, fore_m);
637 if off.is_nan() || off > slack {
639 return Err(DesignError::Geometry(format!(
640 "a fin set's root stands {off} m off the surface of the nose cone or transition it \
641 sits on, at one of its points"
642 )));
643 }
644 let share = sliver / self.planform.geometry()?.area_m2;
645 if share.is_nan() || share > Self::ROOT_SLIVER_SHARE {
646 return Err(DesignError::Geometry(format!(
647 "a fin set's root cuts across the curved surface it sits on between its points, by \
648 {:.3}% of the fin's area, more than {}%: draw it through more points on the \
649 surface",
650 100.0 * share,
651 100.0 * Self::ROOT_SLIVER_SHARE
652 )));
653 }
654 Ok(profile.radius_m(fore_m))
655 }
656
657 pub const MAX_COUNT: u32 = 64;
669
670 pub fn validate(&self) -> Result<(), DesignError> {
678 if self.count == 0 || self.count > Self::MAX_COUNT {
679 return Err(DesignError::Domain {
680 what: "fin count (1 to 64)",
681 value: f64::from(self.count),
682 });
683 }
684 self.planform.validate()?;
685 check_dimension("fin thickness", self.thickness_m, false)?;
686 for (what, value) in [
687 ("fin cant", self.cant_rad),
688 ("fin base angle", self.base_angle_rad),
689 ] {
690 if !value.is_finite() {
691 return Err(DesignError::Domain { what, value });
692 }
693 }
694 if let Some(fillet) = &self.fillet {
695 check_dimension("fin fillet radius", fillet.radius_m, true)?;
696 }
697 if let Some(tab) = self.tab {
698 check_dimension("fin tab height", tab.height_m, false)?;
699 check_dimension("fin tab length", tab.length_m, false)?;
700 if !tab.offset_m.is_finite() {
701 return Err(DesignError::Domain {
702 what: "fin tab offset",
703 value: tab.offset_m,
704 });
705 }
706 let root = self.planform.root_chord_m();
707 if tab.offset_m < 0.0 || tab.offset_m + tab.length_m > root * (1.0 + 1e-12) {
708 return Err(DesignError::Geometry(format!(
709 "a fin tab must lie along the root chord ({root} m)"
710 )));
711 }
712 }
713 Ok(())
714 }
715
716 fn fin_integrals(&self, body_radius_m: f64) -> Result<PlateIntegrals, DesignError> {
718 let scale = self.planform.root_chord_m() + self.planform.span_m();
719 let rb = body_radius_m / scale;
720 let t = self.thickness_m / scale;
721 let mut chords = Vec::new();
722 let mut total = [0.0; 7];
723 for pair in self.planform.breakpoints().windows(2) {
724 let piece = integrate(
725 |h| {
726 self.planform.chords_at(h * scale, &mut chords);
727 let mut m = [0.0; 4];
728 for &(a, b) in &chords {
729 let c = chord_moments(self.cross_section, a / scale, b / scale, t);
730 for k in 0..4 {
731 m[k] += c[k];
732 }
733 }
734 let r = rb + h;
735 [m[0], r * m[0], r * r * m[0], m[1], m[2], r * m[1], m[3]]
736 },
737 pair[0] / scale,
738 pair[1] / scale,
739 PLATE,
740 )?;
741 for (sum, value) in total.iter_mut().zip(piece.value) {
742 *sum += value;
743 }
744 }
745 let powers = [3, 4, 5, 4, 5, 5, 5];
747 Ok(std::array::from_fn(|k| total[k] * scale.powi(powers[k])))
748 }
749
750 fn tab_integrals(&self, body_radius_m: f64) -> PlateIntegrals {
752 let Some(tab) = self.tab else {
753 return [0.0; 7];
754 };
755 let t = self.thickness_m;
756 let (x0, x1) = (tab.offset_m, tab.offset_m + tab.length_m);
757 let (r0, r1) = (body_radius_m - tab.height_m, body_radius_m);
758 let (h, l) = (tab.height_m, tab.length_m);
759 let r_first = 0.5 * (r1 * r1 - r0 * r0);
760 let x_first = 0.5 * (x1 * x1 - x0 * x0);
761 [
762 t * l * h,
763 t * l * r_first,
764 t * l * (r1.powi(3) - r0.powi(3)) / 3.0,
765 t * h * x_first,
766 t * h * (x1.powi(3) - x0.powi(3)) / 3.0,
767 t * x_first * r_first,
768 t.powi(3) / 12.0 * l * h,
769 ]
770 }
771
772 pub fn single_fin(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
781 self.validate()?;
782 check_dimension("fin body radius", body_radius_m, true)?;
783 if let Some(tab) = self.tab
784 && tab.height_m > body_radius_m
785 {
786 return Err(DesignError::Geometry(format!(
787 "a fin tab must reach no deeper than the body radius ({body_radius_m} m)"
788 )));
789 }
790 let density = self.material.bulk_kg_m3("fin set")?;
791 let fin = self.fin_integrals(body_radius_m)?;
792 let tab = self.tab_integrals(body_radius_m);
793 let [v, r1, r2, x1, x2, rx, tau2] = std::array::from_fn(|k| fin[k] + tab[k]);
794 if v <= 0.0 {
795 return Err(DesignError::Geometry("the fin has no volume".to_owned()));
796 }
797 let mass = density * v;
798 let about_origin = DMat3::from_cols(
800 DVec3::new(tau2 + x2, 0.0, rx),
801 DVec3::new(0.0, r2 + x2, 0.0),
802 DVec3::new(rx, 0.0, r2 + tau2),
803 ) * density;
804 let cg = DVec3::new(r1 / v, 0.0, -x1 / v);
805 let at_origin = MassProperties {
806 mass_kg: mass,
807 cg_m: cg,
808 inertia_kg_m2: DMat3::ZERO,
809 };
810 let inertia = about_origin - at_origin.inertia_about(DVec3::ZERO);
812 let plate = MassProperties {
813 inertia_kg_m2: inertia,
814 ..at_origin
815 };
816 let fin = match self.fillets(body_radius_m)? {
817 Some(fillets) => MassProperties::combine([&plate, &fillets]),
818 None => plate,
819 };
820 if self.cant_rad == 0.0 {
821 return Ok(fin);
822 }
823 let pivot = DVec3::new(body_radius_m, 0.0, -0.5 * self.planform.root_chord_m());
824 Ok(fin
825 .translated(-pivot)
826 .rotated(DQuat::from_rotation_x(self.cant_rad))
827 .translated(pivot))
828 }
829
830 fn fillets(&self, body_radius_m: f64) -> Result<Option<MassProperties>, DesignError> {
842 let Some(fillet) = self.fillet.as_ref().filter(|f| f.radius_m > 0.0) else {
843 return Ok(None);
844 };
845 let density = fillet.material.bulk_kg_m3("fin fillet")?;
846 if body_radius_m == 0.0 {
848 return Ok(None);
849 }
850 let ratio = fillet.radius_m / body_radius_m;
856 if ratio < 1e-6 {
857 return Ok(None);
858 }
859 if ratio > FILLET_RATIO_MAX {
860 return Err(DesignError::Domain {
861 what: "fin fillet radius over the body radius (at most 1000)",
862 value: ratio,
863 });
864 }
865 let [area, sx, sxx, syy] = fillet_section(body_radius_m, fillet.radius_m);
866 if !(area > 0.0 && [sx, sxx, syy].iter().all(|v| v.is_finite())) {
868 return Err(DesignError::Domain {
869 what: "fin fillet section area",
870 value: area,
871 });
872 }
873 let l = self.planform.root_chord_m();
874 let mass = 2.0 * density * area * l;
875 if mass == 0.0 {
876 return Ok(None);
877 }
878 let ixz = density * sx * l * l;
879 let about_origin = DMat3::from_cols(
880 DVec3::new(2.0 * density * (syy * l + area * l.powi(3) / 3.0), 0.0, ixz),
881 DVec3::new(0.0, 2.0 * density * (sxx * l + area * l.powi(3) / 3.0), 0.0),
882 DVec3::new(ixz, 0.0, 2.0 * density * (sxx + syy) * l),
883 );
884 let at_origin = MassProperties {
885 mass_kg: mass,
886 cg_m: DVec3::new(sx / area, 0.0, -0.5 * l),
887 inertia_kg_m2: DMat3::ZERO,
888 };
889 Ok(Some(MassProperties {
890 inertia_kg_m2: about_origin - at_origin.inertia_about(DVec3::ZERO),
891 ..at_origin
892 }))
893 }
894
895 pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
901 let fin = self.single_fin(body_radius_m)?;
902 let fins: Vec<MassProperties> = (0..self.count)
903 .map(|k| {
904 fin.rolled(self.base_angle_rad + 2.0 * PI * f64::from(k) / f64::from(self.count))
905 })
906 .collect();
907 Ok(MassProperties::combine(&fins))
908 }
909}
910
911pub const FILLET_RATIO_MAX: f64 = 1e3;
913
914pub(crate) fn fillet_section(body_radius_m: f64, radius_m: f64) -> [f64; 4] {
921 let (rb, r) = (body_radius_m, radius_m);
922 let c = (rb * rb + 2.0 * rb * r).sqrt();
923 let theta = r.atan2(c);
924 let triangle_area = 0.5 * c * r;
926 let triangle = [
927 triangle_area,
928 triangle_area * 2.0 * c / 3.0,
929 triangle_area * c * c / 2.0,
930 triangle_area * r * r / 6.0,
931 ];
932 let body = sector([0.0, 0.0], rb, 0.0, theta);
933 let joint = sector([c, r], r, theta - PI, -0.5 * PI);
934 std::array::from_fn(|k| triangle[k] - body[k] - joint[k])
935}
936
937fn sector([x0, y0]: [f64; 2], rho: f64, from: f64, to: f64) -> [f64; 4] {
943 let (span, sum) = (to - from, to + from);
944 let area = 0.5 * rho * rho * span;
945 let chord = 2.0 * (0.5 * span).sin() * rho.powi(3) / 3.0;
946 let (sx, sy) = (chord * (0.5 * sum).cos(), chord * (0.5 * sum).sin());
947 let (lean, sine) = (span_less_sine(span), span.sin());
948 let (sxx, syy) = (
949 rho.powi(4) / 8.0 * (lean + sine * 2.0 * (0.5 * sum).cos().powi(2)),
950 rho.powi(4) / 8.0 * (lean + sine * 2.0 * (0.5 * sum).sin().powi(2)),
951 );
952 [
953 area,
954 sx + x0 * area,
955 sxx + 2.0 * x0 * sx + x0 * x0 * area,
956 syy + 2.0 * y0 * sy + y0 * y0 * area,
957 ]
958}
959
960fn span_less_sine(x: f64) -> f64 {
963 if x.abs() >= 0.25 {
964 return x - x.sin();
965 }
966 let mut term = x.powi(3) / 6.0;
968 let mut sum = 0.0;
969 for k in 2..=8 {
970 sum += term;
971 term *= -x * x / f64::from((2 * k) * (2 * k + 1));
972 }
973 sum
974}
975
976#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
978#[serde(deny_unknown_fields)]
979pub struct TubeFinSet {
980 pub count: u32,
982 pub length_m: f64,
984 pub outer_radius_m: f64,
986 pub thickness_m: f64,
988 #[serde(default)]
990 pub base_angle_rad: f64,
991 pub material: Material,
993}
994
995impl TubeFinSet {
996 pub const MAX_COUNT: u32 = 64;
1000
1001 pub fn closing_radius_m(body_radius_m: f64, count: u32) -> f64 {
1017 if count < 3 {
1018 return body_radius_m;
1019 }
1020 let s = (PI / f64::from(count)).sin();
1021 body_radius_m * s / (1.0 - s)
1022 }
1023
1024 pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
1034 if self.count == 0 || self.count > Self::MAX_COUNT {
1035 return Err(DesignError::Domain {
1036 what: "tube fin count (1 to 64)",
1037 value: f64::from(self.count),
1038 });
1039 }
1040 check_dimension("body radius", body_radius_m, true)?;
1041 if !self.base_angle_rad.is_finite() {
1042 return Err(DesignError::Domain {
1043 what: "tube fin base angle",
1044 value: self.base_angle_rad,
1045 });
1046 }
1047 let density = self.material.bulk_kg_m3("tube fin set")?;
1048 let tube = crate::parts::hollow_cylinder(
1049 "tube fin",
1050 density,
1051 self.length_m,
1052 self.outer_radius_m,
1053 self.thickness_m,
1054 )?;
1055 let one = tube.translated(DVec3::new(body_radius_m + self.outer_radius_m, 0.0, 0.0));
1056 let tubes: Vec<MassProperties> = (0..self.count)
1057 .map(|k| {
1058 one.rolled(self.base_angle_rad + 2.0 * PI * f64::from(k) / f64::from(self.count))
1059 })
1060 .collect();
1061 Ok(MassProperties::combine(&tubes))
1062 }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068 use hpr_core::quadrature::integrate_scalar;
1069
1070 fn close(got: f64, want: f64, rel: f64, what: &str) {
1071 let err = if want == 0.0 {
1072 got.abs()
1073 } else {
1074 ((got - want) / want).abs()
1075 };
1076 assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
1077 }
1078
1079 fn mat_close(a: DMat3, b: DMat3, rel: f64, what: &str) {
1080 let scale = b.to_cols_array().iter().fold(0.0f64, |m, v| m.max(v.abs()));
1081 let diff = (a - b)
1082 .to_cols_array()
1083 .iter()
1084 .fold(0.0f64, |m, v| m.max(v.abs()));
1085 assert!(diff <= rel * scale, "{what}: {a:?}\nvs\n{b:?}");
1086 }
1087
1088 fn ply() -> Material {
1089 Material::bulk("plywood", 630.0)
1090 }
1091
1092 fn rectangle(chord: f64, span: f64) -> FinPlanform {
1093 FinPlanform::Trapezoidal {
1094 root_chord_m: chord,
1095 tip_chord_m: chord,
1096 span_m: span,
1097 sweep_m: 0.0,
1098 }
1099 }
1100
1101 fn set(count: u32, planform: FinPlanform, thickness: f64) -> FinSet {
1102 FinSet {
1103 count,
1104 planform,
1105 thickness_m: thickness,
1106 cross_section: FinCrossSection::Square,
1107 tab: None,
1108 fillet: None,
1109 cant_rad: 0.0,
1110 base_angle_rad: 0.0,
1111 material: ply(),
1112 }
1113 }
1114
1115 #[test]
1116 fn airfoil_constants_are_the_integrals() {
1117 let tol = Tolerance::default();
1119 let p = naca_polynomial;
1120 for (k, want) in AIRFOIL_PHI.iter().enumerate() {
1121 let power = i32::try_from(2 * k).unwrap();
1122 let got = 10.0
1123 * integrate_scalar(|u| u.powi(power) * p(u * u) * 2.0 * u, 0.0, 1.0, tol).unwrap();
1124 close(got, *want, 1e-14, "phi");
1125 }
1126 let psi = 1000.0 * integrate_scalar(|u| p(u * u).powi(3) * 2.0 * u, 0.0, 1.0, tol).unwrap();
1127 close(psi, AIRFOIL_PSI, 1e-14, "psi");
1128 }
1129
1130 #[test]
1131 fn chord_moments_match_quadrature_of_each_section() {
1132 let tol = Tolerance {
1134 relative: 0.0,
1135 absolute: 0.0,
1136 max_intervals: 4000,
1137 };
1138 let t: f64 = 0.004;
1139 for (a, b) in [(0.02, 0.17), (0.05, 0.053)] {
1141 let c: f64 = b - a;
1142 let rounded = |x: f64| {
1143 let mid = t.min(c);
1144 let r = 0.5 * mid;
1145 let u = (x - a).min(b - x);
1146 if u >= r {
1147 mid
1148 } else {
1149 2.0 * (r * r - (r - u).powi(2)).max(0.0).sqrt()
1150 }
1151 };
1152 let airfoil = |x: f64| 10.0 * t * naca_polynomial((x - a) / c);
1153 let square = |_x: f64| t;
1154 let sections: [(FinCrossSection, &dyn Fn(f64) -> f64); 3] = [
1155 (FinCrossSection::Square, &square),
1156 (FinCrossSection::Rounded, &rounded),
1157 (FinCrossSection::Airfoil, &airfoil),
1158 ];
1159 let edge = 0.5 * t.min(c);
1162 let reference = |f: &dyn Fn(f64) -> f64| -> f64 {
1163 let fore = integrate_scalar(|v| f(a + v * v) * 2.0 * v, 0.0, edge.sqrt(), tol);
1164 let aft = integrate_scalar(|v| f(b - v * v) * 2.0 * v, 0.0, edge.sqrt(), tol);
1165 let middle = integrate_scalar(f, a + edge, b - edge, tol);
1166 fore.unwrap() + aft.unwrap() + middle.unwrap()
1167 };
1168 for (section, thickness) in sections {
1169 let got = chord_moments(section, a, b, t);
1170 for (k, &moment) in got.iter().take(3).enumerate() {
1171 let power = i32::try_from(k).unwrap();
1172 let want = reference(&|x: f64| x.powi(power) * thickness(x));
1173 close(
1174 moment,
1175 want,
1176 1e-13,
1177 &format!("{section:?} M{k} on [{a}, {b}]"),
1178 );
1179 }
1180 let want = reference(&|x: f64| thickness(x).powi(3) / 12.0);
1181 close(got[3], want, 1e-13, &format!("{section:?} T on [{a}, {b}]"));
1182 }
1183 }
1184 let m = chord_moments(FinCrossSection::Rounded, 0.0, 0.1, t);
1186 close(
1187 m[0],
1188 0.1 * t - (1.0 - PI / 4.0) * t * t,
1189 1e-14,
1190 "rounded area",
1191 );
1192 }
1193
1194 #[test]
1195 fn planform_areas_and_centroids_are_the_closed_forms() {
1196 let (cr, ct, s, xt) = (0.15, 0.06, 0.1, 0.07);
1197 let trapezoid = FinPlanform::Trapezoidal {
1198 root_chord_m: cr,
1199 tip_chord_m: ct,
1200 span_m: s,
1201 sweep_m: xt,
1202 };
1203 let g = trapezoid.geometry().unwrap();
1204 close(g.area_m2, 0.5 * s * (cr + ct), 1e-13, "trapezoid area");
1205 close(
1206 g.centroid_x_m,
1207 (xt * (cr + 2.0 * ct) + cr * cr + cr * ct + ct * ct) / (3.0 * (cr + ct)),
1208 1e-13,
1209 "trapezoid centroid x",
1210 );
1211 close(
1212 g.centroid_span_m,
1213 s * (cr + 2.0 * ct) / (3.0 * (cr + ct)),
1214 1e-13,
1215 "trapezoid centroid h",
1216 );
1217 let freeform = FinPlanform::Freeform {
1219 points_m: vec![[0.0, 0.0], [xt, s], [xt + ct, s], [cr, 0.0]],
1220 root_m: Vec::new(),
1221 };
1222 let f = freeform.geometry().unwrap();
1223 close(f.area_m2, g.area_m2, 1e-13, "freeform area");
1224 close(f.centroid_x_m, g.centroid_x_m, 1e-13, "freeform centroid x");
1225 close(
1226 f.centroid_span_m,
1227 g.centroid_span_m,
1228 1e-13,
1229 "freeform centroid h",
1230 );
1231 let e = FinPlanform::Elliptical {
1233 root_chord_m: cr,
1234 span_m: s,
1235 }
1236 .geometry()
1237 .unwrap();
1238 close(e.area_m2, PI * cr * s / 4.0, 1e-12, "ellipse area");
1239 close(e.centroid_x_m, cr / 2.0, 1e-12, "ellipse centroid x");
1240 close(
1241 e.centroid_span_m,
1242 4.0 * s / (3.0 * PI),
1243 1e-12,
1244 "ellipse centroid h",
1245 );
1246 }
1247
1248 #[test]
1249 fn a_rectangular_fin_set_matches_box_inertia_by_hand() {
1250 let (c, s, t, rb, rho) = (0.12, 0.08, 0.003, 0.04, 630.0);
1251 let m = rho * c * s * t;
1252 let d = rb + s / 2.0;
1253 let one = set(1, rectangle(c, s), t).single_fin(rb).unwrap();
1255 close(one.mass_kg, m, 1e-13, "mass");
1256 assert!((one.cg_m - DVec3::new(d, 0.0, -c / 2.0)).length() < 1e-15);
1257 let box_inertia = DMat3::from_diagonal(DVec3::new(
1258 m * (t * t + c * c) / 12.0,
1259 m * (s * s + c * c) / 12.0,
1260 m * (s * s + t * t) / 12.0,
1261 ));
1262 mat_close(one.inertia_kg_m2, box_inertia, 1e-12, "one fin");
1263 let four = set(4, rectangle(c, s), t).mass_properties(rb).unwrap();
1265 close(four.mass_kg, 4.0 * m, 1e-13, "four fins");
1266 assert!(four.cg_m.truncate().length() < 1e-15);
1267 let transverse =
1268 2.0 * m * (t * t + c * c) / 12.0 + 2.0 * (m * (s * s + c * c) / 12.0 + m * d * d);
1269 let axial = 4.0 * (m * (s * s + t * t) / 12.0 + m * d * d);
1270 mat_close(
1271 four.inertia_kg_m2,
1272 DMat3::from_diagonal(DVec3::new(transverse, transverse, axial)),
1273 1e-12,
1274 "four fins",
1275 );
1276 let three = set(3, rectangle(c, s), t)
1278 .mass_properties(rb)
1279 .unwrap()
1280 .inertia_kg_m2;
1281 close(
1282 three.x_axis.x,
1283 three.y_axis.y,
1284 1e-12,
1285 "three fins isotropic",
1286 );
1287 assert!(three.y_axis.x.abs() < 1e-12 * three.x_axis.x);
1288 let two = set(2, rectangle(c, s), t)
1289 .mass_properties(rb)
1290 .unwrap()
1291 .inertia_kg_m2;
1292 assert!((two.x_axis.x - two.y_axis.y).abs() > 1e-4 * two.y_axis.y);
1293
1294 let d = s / 2.0;
1296 let on_axis = set(4, rectangle(c, s), t).mass_properties(0.0).unwrap();
1297 let transverse =
1298 2.0 * m * (t * t + c * c) / 12.0 + 2.0 * (m * (s * s + c * c) / 12.0 + m * d * d);
1299 let axial = 4.0 * (m * (s * s + t * t) / 12.0 + m * d * d);
1300 mat_close(
1301 on_axis.inertia_kg_m2,
1302 DMat3::from_diagonal(DVec3::new(transverse, transverse, axial)),
1303 1e-12,
1304 "four fins from the axis",
1305 );
1306 }
1307
1308 #[test]
1309 fn cant_turns_the_fin_about_its_span_axis() {
1310 let (c, s, t, rb, rho) = (0.12, 0.08, 0.003, 0.04, 630.0);
1311 let m = rho * c * s * t;
1312 let mut fins = set(1, rectangle(c, s), t);
1315 fins.cant_rad = PI / 2.0;
1316 let turned = fins.single_fin(rb).unwrap();
1317 assert!((turned.cg_m - DVec3::new(rb + s / 2.0, 0.0, -c / 2.0)).length() < 1e-15);
1318 let expected = DMat3::from_diagonal(DVec3::new(
1319 m * (c * c + t * t) / 12.0,
1320 m * (s * s + t * t) / 12.0,
1321 m * (s * s + c * c) / 12.0,
1322 ));
1323 mat_close(turned.inertia_kg_m2, expected, 1e-12, "canted 90°");
1324 fins.cant_rad = 0.05;
1326 let small = fins.single_fin(rb).unwrap();
1327 let flat = set(1, rectangle(c, s), t).single_fin(rb).unwrap();
1328 close(small.mass_kg, flat.mass_kg, 1e-15, "mass");
1329 let trace = |i: DMat3| i.x_axis.x + i.y_axis.y + i.z_axis.z;
1330 close(
1331 trace(small.inertia_kg_m2),
1332 trace(flat.inertia_kg_m2),
1333 1e-12,
1334 "trace",
1335 );
1336 }
1337
1338 #[test]
1339 fn positive_cant_turns_the_leading_edge_toward_negative_y() {
1340 let (cr, ct, s, xt) = (0.15, 0.05, 0.1, 0.09);
1344 let planform = FinPlanform::Trapezoidal {
1345 root_chord_m: cr,
1346 tip_chord_m: ct,
1347 span_m: s,
1348 sweep_m: xt,
1349 };
1350 let centroid = planform.geometry().unwrap().centroid_x_m;
1351 assert!(centroid > cr / 2.0);
1352 let mut fins = set(1, planform, 0.003);
1353 let delta: f64 = 0.2;
1354 fins.cant_rad = delta;
1355 let fin = fins.single_fin(0.04).unwrap();
1356 close(
1357 fin.cg_m.y,
1358 (centroid - cr / 2.0) * delta.sin(),
1359 1e-12,
1360 "centroid y",
1361 );
1362 close(
1363 fin.cg_m.z,
1364 -cr / 2.0 - (centroid - cr / 2.0) * delta.cos(),
1365 1e-12,
1366 "centroid z",
1367 );
1368 assert!(fin.cg_m.y > 0.0);
1369 let leading_edge = DQuat::from_rotation_x(delta) * DVec3::new(0.04, 0.0, cr / 2.0);
1370 assert!(leading_edge.y < 0.0);
1371 }
1372
1373 #[test]
1374 fn tabs_must_fit_the_root_and_the_body() {
1375 let mut fins = set(3, rectangle(0.1, 0.05), 0.003);
1376 fins.tab = Some(FinTab {
1378 height_m: 0.01,
1379 length_m: 0.08,
1380 offset_m: 0.03,
1381 });
1382 assert!(matches!(fins.validate(), Err(DesignError::Geometry(_))));
1383 for tab in [
1384 FinTab {
1385 height_m: 0.05,
1386 length_m: 0.02,
1387 offset_m: 0.0,
1388 },
1389 FinTab {
1390 height_m: 0.01,
1391 length_m: 0.02,
1392 offset_m: -0.01,
1393 },
1394 FinTab {
1395 height_m: 0.01,
1396 length_m: 0.08,
1397 offset_m: 0.03,
1398 },
1399 ] {
1400 fins.tab = Some(tab);
1401 assert!(
1402 matches!(fins.mass_properties(0.03), Err(DesignError::Geometry(_))),
1403 "{tab:?}"
1404 );
1405 }
1406 fins.tab = Some(FinTab {
1407 height_m: 0.03,
1408 length_m: 0.1,
1409 offset_m: 0.0,
1410 });
1411 fins.mass_properties(0.03).unwrap();
1412 }
1413
1414 #[test]
1415 fn a_swept_fin_has_the_parallel_axis_product_of_inertia() {
1416 let (cr, ct, s, xt, t, rb, rho) = (0.15, 0.05, 0.1, 0.09, 0.004, 0.05, 1800.0);
1420 let planform = FinPlanform::Trapezoidal {
1421 root_chord_m: cr,
1422 tip_chord_m: ct,
1423 span_m: s,
1424 sweep_m: xt,
1425 };
1426 let mut fins = set(1, planform, t);
1427 fins.material = Material::bulk("G10", rho);
1428 let fin = fins.single_fin(rb).unwrap();
1429 let tol = Tolerance::default();
1430 let lead = |h: f64| xt * h / s;
1431 let chord = |h: f64| cr + (ct - cr) * h / s;
1432 let area = integrate_scalar(chord, 0.0, s, tol).unwrap();
1433 let mx = integrate_scalar(|h| chord(h) * (lead(h) + chord(h) / 2.0), 0.0, s, tol).unwrap();
1434 let mr = integrate_scalar(|h| chord(h) * (rb + h), 0.0, s, tol).unwrap();
1435 let mrx = integrate_scalar(
1436 |h| (rb + h) * chord(h) * (lead(h) + chord(h) / 2.0),
1437 0.0,
1438 s,
1439 tol,
1440 )
1441 .unwrap();
1442 let m = rho * t * area;
1443 close(fin.mass_kg, m, 1e-13, "mass");
1444 let product = rho * t * mrx - m * (mr / area) * (mx / area);
1445 close(fin.inertia_kg_m2.z_axis.x, product, 1e-11, "I_xz");
1446 close(fin.inertia_kg_m2.x_axis.z, product, 1e-11, "I_zx");
1447 }
1448
1449 #[test]
1450 fn a_closing_ring_of_tubes_touches_the_body_and_its_neighbours() {
1451 let body = 0.05;
1452 for count in 3..=12 {
1453 let r = TubeFinSet::closing_radius_m(body, count);
1454 assert!(r > 0.0 && r.is_finite(), "{count}: {r}");
1455 let step = 2.0 * PI / f64::from(count);
1457 let (a, b) = (
1458 DVec3::new(body + r, 0.0, 0.0),
1459 DVec3::new((body + r) * step.cos(), (body + r) * step.sin(), 0.0),
1460 );
1461 close(a.distance(b), 2.0 * r, 1e-14, "neighbours touch");
1462 }
1463 close(TubeFinSet::closing_radius_m(body, 6), body, 1e-15, "six");
1465 close(
1466 TubeFinSet::closing_radius_m(body, 4),
1467 body * (1.0 + 2f64.sqrt()),
1468 1e-15,
1469 "four",
1470 );
1471 assert_eq!(TubeFinSet::closing_radius_m(body, 1), body);
1473 assert_eq!(TubeFinSet::closing_radius_m(body, 2), body);
1474 }
1475
1476 #[test]
1477 fn tube_fins_are_hollow_cylinders_around_the_body() {
1478 let tubes = TubeFinSet {
1479 count: 6,
1480 length_m: 0.1,
1481 outer_radius_m: 0.012,
1482 thickness_m: 0.001,
1483 base_angle_rad: 0.0,
1484 material: Material::bulk("cardboard", 790.0),
1485 };
1486 let rb = 0.02;
1487 let g = tubes.mass_properties(rb).unwrap();
1488 let (ro, ri) = (0.012, 0.011);
1489 let m1 = 790.0 * PI * (ro * ro - ri * ri) * 0.1;
1490 close(g.mass_kg, 6.0 * m1, 1e-13, "mass");
1491 let d = rb + ro;
1492 let axial = 6.0 * (0.5 * m1 * (ro * ro + ri * ri) + m1 * d * d);
1493 close(g.inertia_kg_m2.z_axis.z, axial, 1e-13, "axial");
1494 let transverse = 6.0 * m1 * ((ro * ro + ri * ri) / 4.0 + 0.01 / 12.0) + 3.0 * m1 * d * d;
1495 close(g.inertia_kg_m2.x_axis.x, transverse, 1e-12, "transverse");
1496 close(g.cg_m.z, -0.05, 1e-15, "center");
1497 }
1498
1499 fn cockpit(pieces: u32) -> (FinSet, Profile, f64) {
1503 let nose = Profile::nose(
1504 crate::NoseShape::Ogive { radius_ratio: 1.0 },
1505 0.136525,
1506 0.0168275,
1507 )
1508 .unwrap();
1509 let (chord, fore) = (0.05, 0.136525 - 0.05);
1510 let base = nose.radius_m(fore);
1511 let rise = |x: f64| nose.radius_m(fore + x) - base;
1512 let root_m = (1..pieces)
1513 .rev()
1514 .map(|i| {
1515 let x = chord * f64::from(i) / f64::from(pieces);
1516 [x, rise(x)]
1517 })
1518 .collect();
1519 let planform = FinPlanform::Freeform {
1520 points_m: vec![
1521 [0.0, 0.0],
1522 [0.009347826086956524, 0.006956521739130436],
1523 [chord, 0.0022276567072510058],
1524 ],
1525 root_m,
1526 };
1527 let mut set = set(1, planform, 0.006985);
1528 set.material = Material::bulk("balsa", 170.0);
1529 (set, nose, fore)
1530 }
1531
1532 #[test]
1539 fn a_root_along_a_nose_cone_is_openrockets() {
1540 let (set, nose, fore) = cockpit(20);
1541 let radius = set.root_radius_on(&nose, fore).unwrap();
1542 close(
1543 radius,
1544 0.0145998,
1545 1e-5,
1546 "body radius at the root leading edge",
1547 );
1548 let g = set.planform.geometry().unwrap();
1549 close(g.area_m2, 1.449544e-4, 1e-6, "OpenRocket's planform area");
1550 let m = set.mass_properties(radius).unwrap();
1551 close(m.mass_kg, 1.72126e-4, 1e-5, "OpenRocket's mass");
1552 close(
1554 -m.cg_m.z,
1555 0.0191163,
1556 1e-5,
1557 "OpenRocket's center of mass, aft",
1558 );
1559 close(m.cg_m.x, 0.017882, 1e-5, "OpenRocket's center of mass, out");
1560 let fine = |pieces| cockpit(pieces).0.planform.geometry().unwrap().area_m2;
1561 let (ours, curve) = (fine(64), fine(1024));
1562 assert!(
1563 ours < g.area_m2 && curve < ours,
1564 "{curve} < {ours} < {}",
1565 g.area_m2
1566 );
1567 close(ours, g.area_m2, 3.0e-4, "64 pieces against OpenRocket's 20");
1568 close(ours, curve, 3.5e-5, "64 pieces against the curve");
1569 let share = |pieces| {
1571 let (set, nose, fore) = cockpit(pieces);
1572 set.root_on_surface(&nose, fore).1 / set.planform.geometry().unwrap().area_m2
1573 };
1574 for (pieces, want) in [(20, 3.2e-4), (64, 3.1e-5), (1, 0.114)] {
1575 close(share(pieces), want, 0.1, "sliver share");
1576 }
1577 let (chord, nose, fore) = cockpit(1);
1578 let err = chord.root_radius_on(&nose, fore).unwrap_err();
1579 assert!(
1580 matches!(&err, DesignError::Geometry(m) if m.contains("cuts across")),
1581 "{err}"
1582 );
1583 close(set.planform.span_m(), 0.006956521739130436, 1e-15, "span");
1585 }
1586
1587 #[test]
1590 fn a_root_off_the_surface_is_refused_by_name() {
1591 let cone =
1593 Profile::transition(crate::NoseShape::Conical {}, 0.2, 0.02, 0.04, false).unwrap();
1594 let rise = 0.02 / 0.2 * 0.05;
1595 let on_cone = |end_h: f64| {
1596 set(
1597 3,
1598 FinPlanform::Freeform {
1599 points_m: vec![[0.0, 0.0], [0.03, 0.04], [0.05, end_h]],
1600 root_m: Vec::new(),
1601 },
1602 0.003,
1603 )
1604 };
1605 close(
1606 on_cone(rise).root_radius_on(&cone, 0.1).unwrap(),
1607 0.03,
1608 1e-14,
1609 "radius at x = 0.1 m",
1610 );
1611 let says = |set: FinSet, fore: f64, what: &str| {
1612 let err = set.root_radius_on(&cone, fore).unwrap_err();
1613 assert!(
1614 matches!(&err, DesignError::Geometry(m) if m.contains(what)),
1615 "{what}: {err}"
1616 );
1617 };
1618 assert!(on_cone(rise + 0.9e-6).root_radius_on(&cone, 0.1).is_ok());
1620 says(on_cone(rise + 1.1e-6), 0.1, "stands");
1621 says(on_cone(rise - 1.1e-6), 0.1, "stands");
1622 says(set(3, rectangle(0.05, 0.04), 0.003), 0.1, "stands");
1624 says(on_cone(rise), -2e-6, "must stay on it");
1626 says(on_cone(rise), 0.15 + 2e-6, "must stay on it");
1627 assert!(on_cone(rise).root_radius_on(&cone, 0.15).is_ok());
1628 let mut tabbed = on_cone(rise);
1629 tabbed.tab = Some(FinTab {
1630 height_m: 0.005,
1631 length_m: 0.02,
1632 offset_m: 0.01,
1633 });
1634 says(tabbed, 0.1, "tab or a fillet");
1635 let mut filleted = on_cone(rise);
1636 filleted.fillet = Some(FinFillet {
1637 radius_m: 0.005,
1638 material: ply(),
1639 });
1640 says(filleted, 0.1, "tab or a fillet");
1641 }
1642
1643 #[test]
1644 fn bad_outlines_and_dimensions_are_rejected() {
1645 let bowtie = FinPlanform::Freeform {
1646 points_m: vec![[0.0, 0.0], [0.1, 0.1], [0.0, 0.1], [0.1, 0.0]],
1647 root_m: Vec::new(),
1648 };
1649 assert!(matches!(bowtie.validate(), Err(DesignError::Geometry(_))));
1650 let below = FinPlanform::Freeform {
1651 points_m: vec![[0.0, 0.0], [0.05, -0.01], [0.1, 0.0]],
1652 root_m: Vec::new(),
1653 };
1654 assert!(below.validate().is_err());
1655 let open = FinPlanform::Freeform {
1658 points_m: vec![[0.0, 0.0], [0.05, 0.05], [0.1, 0.02]],
1659 root_m: Vec::new(),
1660 };
1661 assert!(open.validate().is_ok());
1662 let err = set(1, open, 0.003).check_level_root().unwrap_err();
1663 assert!(
1664 matches!(&err, DesignError::Geometry(m) if m.contains("needs a level root")),
1665 "{err}"
1666 );
1667 let outline = vec![[0.0, 0.0], [0.05, 0.05], [0.1, 0.02]];
1670 let with_root = |root_m: Vec<[f64; 2]>| FinPlanform::Freeform {
1671 points_m: outline.clone(),
1672 root_m,
1673 };
1674 assert!(
1675 with_root(vec![[0.07, 0.012], [0.03, 0.004]])
1676 .validate()
1677 .is_ok()
1678 );
1679 for (root, says) in [
1680 (vec![[0.03, 0.004], [0.07, 0.012]], "must run fore"),
1681 (vec![[0.1, 0.02]], "must run fore"),
1682 (vec![[0.12, 0.02]], "must run fore"),
1683 (vec![[0.0, 0.0]], "must run fore"),
1684 (vec![[0.05, 0.06]], "runs below its root"),
1685 (vec![[0.07, 0.04], [0.03, 0.004]], "crosses itself"),
1686 ] {
1687 let err = with_root(root.clone()).validate().unwrap_err();
1688 assert!(
1689 matches!(&err, DesignError::Geometry(m) if m.contains(says)),
1690 "{root:?}: {err}"
1691 );
1692 }
1693 assert!(matches!(
1694 with_root(vec![[0.05, -0.001]]).validate(),
1695 Err(DesignError::Domain {
1696 what: "freeform fin span",
1697 ..
1698 })
1699 ));
1700 let shifted = FinPlanform::Freeform {
1702 points_m: vec![[0.03, 0.0], [0.08, 0.05], [0.13, 0.0]],
1703 root_m: Vec::new(),
1704 };
1705 assert!(shifted.validate().is_err());
1706 let backwards = FinPlanform::Freeform {
1707 points_m: vec![[0.0, 0.0], [-0.05, 0.05], [-0.1, 0.0]],
1708 root_m: Vec::new(),
1709 };
1710 assert!(backwards.validate().is_err());
1711 assert!(
1712 set(0, rectangle(0.1, 0.1), 0.003)
1713 .mass_properties(0.03)
1714 .is_err()
1715 );
1716 assert!(
1717 set(3, rectangle(0.1, 0.1), 0.0)
1718 .mass_properties(0.03)
1719 .is_err()
1720 );
1721 let mut fabric = set(3, rectangle(0.1, 0.1), 0.003);
1722 fabric.material = Material::surface("ripstop", 0.04);
1723 assert!(matches!(
1724 fabric.mass_properties(0.03),
1725 Err(DesignError::MaterialKind { .. })
1726 ));
1727 }
1728
1729 fn fillet_section_by_quadrature(rb: f64, r: f64) -> [f64; 4] {
1734 let tol = Tolerance {
1735 relative: 1e-12,
1736 absolute: 1e-24,
1737 ..Tolerance::default()
1738 };
1739 let c = (rb * rb + 2.0 * rb * r).sqrt();
1740 let joint = |x: f64| r - (r * r - (x - c) * (x - c)).max(0.0).sqrt();
1741 let body = |x: f64| (rb * rb - x * x).max(0.0).sqrt();
1742 let moments = |bottom: f64, top: f64, x: f64| {
1743 [
1744 top - bottom,
1745 x * (top - bottom),
1746 x * x * (top - bottom),
1747 (top.powi(3) - bottom.powi(3)) / 3.0,
1748 ]
1749 };
1750 let near = integrate(
1751 |v| {
1752 let x = rb - v * v;
1753 moments(body(x), joint(x), x).map(|m| m * 2.0 * v)
1754 },
1755 0.0,
1756 (rb - c * rb / (rb + r)).sqrt(),
1757 tol,
1758 )
1759 .unwrap();
1760 let far = integrate(|x| moments(0.0, joint(x), x), rb, c, tol).unwrap();
1761 std::array::from_fn(|k| near.value[k] + far.value[k])
1762 }
1763
1764 #[test]
1765 fn fillet_section_is_its_region_by_quadrature() {
1766 for (rb, r) in [
1767 (0.05, 0.005),
1768 (0.05, 0.01),
1769 (0.05, 0.03),
1770 (0.1, 0.001),
1771 (0.02, 0.05),
1772 ] {
1773 let got = fillet_section(rb, r);
1774 let want = fillet_section_by_quadrature(rb, r);
1775 for (k, what) in ["A", "S_x", "S_xx", "S_yy"].into_iter().enumerate() {
1776 close(got[k], want[k], 1e-11, what);
1777 }
1778 }
1779 assert_eq!(fillet_section(0.0, 0.01)[0], 0.0);
1781 let x: f64 = 0.25;
1783 close(
1784 span_less_sine(x.next_down()),
1785 x - x.sin(),
1786 1e-13,
1787 "x − sin x",
1788 );
1789 close(
1790 span_less_sine(1e-3),
1791 1e-9 / 6.0 - 1e-15 / 120.0 + 1e-21 / 5040.0,
1792 1e-15,
1793 "x − sin x",
1794 );
1795 }
1796
1797 #[test]
1798 fn a_fillet_on_a_flat_body_is_a_square_less_a_quarter_circle() {
1799 let r = 0.01;
1801 close(
1802 fillet_section(1e6, r)[0],
1803 r * r * (1.0 - PI / 4.0),
1804 1e-6,
1805 "flat",
1806 );
1807 }
1808
1809 #[test]
1813 fn the_worked_fillet_example_is_the_docs() {
1814 let [a, ..] = fillet_section(0.030, 0.005);
1815 assert!((a * 1e6 - 4.253).abs() < 5e-4, "{}", a * 1e6);
1816 let planform = FinPlanform::Trapezoidal {
1817 root_chord_m: 0.1,
1818 tip_chord_m: 0.05,
1819 span_m: 0.05,
1820 sweep_m: 0.05,
1821 };
1822 let bare = set(3, planform.clone(), 0.003);
1823 let mut filleted = set(3, planform, 0.003);
1824 filleted.fillet = Some(FinFillet {
1825 radius_m: 0.005,
1826 material: Material::bulk("cardboard", 680.0),
1827 });
1828 let mass_g = (filleted.mass_properties(0.030).unwrap().mass_kg
1829 - bare.mass_properties(0.030).unwrap().mass_kg)
1830 * 1e3;
1831 assert!((mass_g - 1.735).abs() < 5e-4, "{mass_g}");
1832 }
1833
1834 #[test]
1838 fn a_fillet_far_from_the_body_s_size_is_refused_or_none() {
1839 let planform = FinPlanform::Trapezoidal {
1840 root_chord_m: 0.1,
1841 tip_chord_m: 0.05,
1842 span_m: 0.05,
1843 sweep_m: 0.05,
1844 };
1845 let filleted = |radius_m: f64| {
1846 let mut fins = set(3, planform.clone(), 0.003);
1847 fins.fillet = Some(FinFillet {
1848 radius_m,
1849 material: Material::bulk("epoxy", 1200.0),
1850 });
1851 fins
1852 };
1853 let bare = set(3, planform.clone(), 0.003);
1854 let rb = 0.05;
1855 let widest = FILLET_RATIO_MAX * rb;
1856 for radius_m in [widest * (1.0 + 1e-12), 1e10, 1e12, 1e100] {
1857 let error = filleted(radius_m).single_fin(rb).unwrap_err();
1858 assert!(
1859 matches!(error, DesignError::Domain { what, value }
1860 if what.starts_with("fin fillet radius") && value == radius_m / rb),
1861 "{radius_m}: {error:?}"
1862 );
1863 }
1864 let mass = |fins: &FinSet, rb: f64| fins.single_fin(rb).unwrap().mass_kg;
1865 assert!(mass(&filleted(widest), rb) > mass(&bare, rb));
1866 assert!(mass(&filleted(1e-6 * rb), rb) > mass(&bare, rb));
1867 for (radius_m, body_m) in [(1e-6 * rb * (1.0 - 1e-12), rb), (1e-20, rb), (0.005, 0.0)] {
1868 assert_eq!(
1869 mass(&filleted(radius_m), body_m),
1870 mass(&bare, body_m),
1871 "{radius_m} on {body_m}"
1872 );
1873 }
1874 let mut fins = set(3, planform, 0.003);
1876 fins.fillet = Some(FinFillet {
1877 radius_m: 0.005,
1878 material: Material::bulk("air", 0.0),
1879 });
1880 assert_eq!(
1881 fins.single_fin(0.05).unwrap().mass_kg,
1882 set(3, fins.planform.clone(), 0.003)
1883 .single_fin(0.05)
1884 .unwrap()
1885 .mass_kg
1886 );
1887 }
1888
1889 #[test]
1890 fn fillets_are_a_prism_of_their_section_along_the_root() {
1891 let (rb, r, rho) = (0.05, 0.005, 1200.0);
1893 let planform = FinPlanform::Trapezoidal {
1894 root_chord_m: 0.1,
1895 tip_chord_m: 0.05,
1896 span_m: 0.05,
1897 sweep_m: 0.05,
1898 };
1899 let mut fins = set(3, planform, 0.003);
1900 let bare = fins.single_fin(rb).unwrap();
1901 fins.fillet = Some(FinFillet {
1902 radius_m: r,
1903 material: Material::bulk("epoxy", rho),
1904 });
1905 let with = fins.single_fin(rb).unwrap();
1906 let pair = with.without_part(&bare);
1907 let [a, sx, sxx, syy] = fillet_section_by_quadrature(rb, r);
1908 let l = 0.1;
1909 close(pair.mass_kg, 2.0 * rho * a * l, 1e-10, "mass");
1910 assert!((pair.cg_m - DVec3::new(sx / a, 0.0, -0.5 * l)).length() < 1e-12);
1911 let about_origin = with.inertia_about(DVec3::ZERO) - bare.inertia_about(DVec3::ZERO);
1912 let want = DMat3::from_cols(
1913 DVec3::new(
1914 2.0 * rho * (syy * l + a * l.powi(3) / 3.0),
1915 0.0,
1916 rho * sx * l * l,
1917 ),
1918 DVec3::new(0.0, 2.0 * rho * (sxx * l + a * l.powi(3) / 3.0), 0.0),
1919 DVec3::new(rho * sx * l * l, 0.0, 2.0 * rho * (sxx + syy) * l),
1920 );
1921 mat_close(about_origin, want, 1e-9, "fillets about the origin");
1922 fins.fillet = Some(FinFillet {
1924 radius_m: 0.0,
1925 material: Material::bulk("epoxy", rho),
1926 });
1927 assert_eq!(fins.single_fin(rb).unwrap(), bare);
1928 fins.fillet = Some(FinFillet {
1929 radius_m: -0.001,
1930 material: Material::bulk("epoxy", rho),
1931 });
1932 assert!(matches!(
1933 fins.single_fin(rb),
1934 Err(DesignError::Domain {
1935 what: "fin fillet radius",
1936 ..
1937 })
1938 ));
1939 }
1940
1941 #[test]
1945 fn a_fin_count_over_the_bound_is_refused_and_the_bound_is_weighed() {
1946 let rb = 0.028;
1947 let planform = FinPlanform::Trapezoidal {
1948 root_chord_m: 0.05,
1949 tip_chord_m: 0.02,
1950 span_m: 0.03,
1951 sweep_m: 0.02,
1952 };
1953 for count in [0, FinSet::MAX_COUNT + 1, 4_000_000_000, u32::MAX] {
1954 let fins = set(count, planform.clone(), 0.003);
1955 for result in [fins.validate(), fins.mass_properties(rb).map(|_| ())] {
1956 match result {
1957 Err(DesignError::Domain { what, value }) => {
1958 assert_eq!(what, "fin count (1 to 64)", "{count}");
1959 assert_eq!(value, f64::from(count), "{count}");
1960 }
1961 other => panic!("{count} fins: {other:?}"),
1962 }
1963 }
1964 }
1965 let most = set(FinSet::MAX_COUNT, planform.clone(), 0.003);
1966 most.validate().unwrap();
1967 let one = set(1, planform, 0.003).mass_properties(rb).unwrap();
1968 let all = most.mass_properties(rb).unwrap();
1969 close(all.mass_kg, 64.0 * one.mass_kg, 1e-13, "64 fins' mass");
1970 assert!(
1972 all.cg_m.x.abs() < 1e-15 && all.cg_m.y.abs() < 1e-15,
1973 "{:?}",
1974 all.cg_m
1975 );
1976 }
1977
1978 #[test]
1981 fn a_tube_fin_count_over_the_bound_is_refused_and_the_bound_is_weighed() {
1982 let tubes = |count| TubeFinSet {
1983 count,
1984 length_m: 0.1,
1985 outer_radius_m: 0.012,
1986 thickness_m: 0.001,
1987 base_angle_rad: 0.0,
1988 material: Material::bulk("cardboard", 790.0),
1989 };
1990 for count in [0, TubeFinSet::MAX_COUNT + 1, 4_000_000_000, u32::MAX] {
1991 match tubes(count).mass_properties(0.02) {
1992 Err(DesignError::Domain { what, value }) => {
1993 assert_eq!(what, "tube fin count (1 to 64)", "{count}");
1994 assert_eq!(value, f64::from(count), "{count}");
1995 }
1996 other => panic!("{count} tubes: {other:?}"),
1997 }
1998 }
1999 let one = tubes(1).mass_properties(0.02).unwrap();
2000 let all = tubes(TubeFinSet::MAX_COUNT).mass_properties(0.02).unwrap();
2001 close(all.mass_kg, 64.0 * one.mass_kg, 1e-13, "64 tubes' mass");
2002 }
2003}