1use std::f64::consts::PI;
39
40use serde::{Deserialize, Serialize};
41
42use crate::error::DesignError;
43
44#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
46#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
47#[non_exhaustive]
48pub enum NoseShape {
49 Conical {},
51 Ogive {
53 radius_ratio: f64,
56 },
57 Elliptical {},
59 PowerSeries {
61 exponent: f64,
63 },
64 ParabolicSeries {
66 parameter: f64,
68 },
69 Haack {
71 parameter: f64,
73 },
74}
75
76pub const MIN_POWER_EXPONENT: f64 = 0.05;
83
84impl NoseShape {
85 pub const TANGENT_OGIVE: Self = Self::Ogive { radius_ratio: 1.0 };
87 pub const VON_KARMAN: Self = Self::Haack { parameter: 0.0 };
89 pub const LV_HAACK: Self = Self::Haack {
91 parameter: 1.0 / 3.0,
92 };
93
94 fn validate(&self, fineness: f64) -> Result<(), DesignError> {
97 let (what, value, ok) = match *self {
98 Self::Conical {} | Self::Elliptical {} => return Ok(()),
99 Self::Ogive { radius_ratio } => (
100 "ogive radius ratio",
101 radius_ratio,
102 radius_ratio.is_finite() && radius_ratio * fineness >= 1.0 - 1e-12,
104 ),
105 Self::PowerSeries { exponent } => (
106 "power series exponent",
107 exponent,
108 (MIN_POWER_EXPONENT..=1.0).contains(&exponent),
109 ),
110 Self::ParabolicSeries { parameter } => (
111 "parabolic series parameter",
112 parameter,
113 (0.0..=1.0).contains(¶meter),
114 ),
115 Self::Haack { parameter } => (
116 "Haack series parameter",
117 parameter,
118 (0.0..=2.0 / 3.0).contains(¶meter),
119 ),
120 };
121 if ok {
122 Ok(())
123 } else {
124 Err(DesignError::Domain { what, value })
125 }
126 }
127}
128
129fn haack_core(theta: f64) -> f64 {
133 if theta >= 0.1 {
134 return theta - (2.0 * theta).sin() / 2.0;
135 }
136 let t2 = theta * theta;
137 let mut term = theta;
138 let mut sum = 0.0;
139 for k in 1..=5 {
140 let k2 = f64::from(2 * k);
142 term *= 4.0 * t2 / (k2 * (k2 + 1.0));
143 sum += if k % 2 == 1 { term } else { -term };
144 }
145 sum
146}
147
148#[derive(Debug, Clone, Copy, PartialEq)]
150struct Arc {
151 xc: f64,
152 yc: f64,
153 rho: f64,
154}
155
156impl Arc {
157 fn new(lambda: f64, ratio: f64) -> Self {
162 let chord = (lambda * lambda + 1.0).sqrt();
163 let rho = ratio * 0.5 * (lambda * lambda + 1.0);
164 let half = 0.5 * chord;
165 let d = ((rho - half).max(0.0) * (rho + half)).sqrt();
166 Self {
167 xc: 0.5 * lambda + d / chord,
168 yc: (0.5 - d * lambda / chord).min(0.0),
170 rho,
171 }
172 }
173
174 fn eval(&self, x: f64) -> (f64, f64) {
179 let chord_term = x * (2.0 * self.xc - x);
180 let root = (self.yc * self.yc + chord_term).max(0.0).sqrt();
181 let denominator = root - self.yc;
182 let y = if denominator > 0.0 {
183 chord_term / denominator
184 } else {
185 0.0
186 };
187 (y, (self.xc - x) / root)
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq)]
193enum Curve {
194 Conical,
195 Arc { arc: Arc, lambda: f64 },
196 Elliptical,
197 Power(f64),
198 Parabolic(f64),
199 Haack(f64),
200}
201
202impl Curve {
203 fn new(shape: NoseShape, fineness: f64) -> Self {
204 match shape {
205 NoseShape::Conical {} => Self::Conical,
206 NoseShape::Ogive { radius_ratio } => Self::Arc {
207 arc: Arc::new(fineness, radius_ratio),
208 lambda: fineness,
209 },
210 NoseShape::Elliptical {} => Self::Elliptical,
211 NoseShape::PowerSeries { exponent } => Self::Power(exponent),
212 NoseShape::ParabolicSeries { parameter } => Self::Parabolic(parameter),
213 NoseShape::Haack { parameter } => Self::Haack(parameter),
214 }
215 }
216
217 fn eval(&self, xi: f64) -> (f64, f64) {
219 let xi = xi.clamp(0.0, 1.0);
220 match *self {
221 Self::Conical => (xi, 1.0),
222 Self::Arc { arc, lambda } => {
223 let (y, slope) = arc.eval(lambda * xi);
224 (y.max(0.0), slope * lambda)
225 }
226 Self::Elliptical => {
227 let g = (xi * (2.0 - xi)).sqrt();
228 (g, (1.0 - xi) / g)
229 }
230 Self::Power(n) => (xi.powf(n), n * xi.powf(n - 1.0)),
231 Self::Parabolic(k) => (
232 (2.0 * xi - k * xi * xi) / (2.0 - k),
233 (2.0 - 2.0 * k * xi) / (2.0 - k),
234 ),
235 Self::Haack(c) => {
236 let theta = 2.0 * xi.sqrt().asin();
238 let (sin, cos) = (theta.sin(), theta.cos());
239 let g2 = (haack_core(theta) + c * sin * sin * sin) / PI;
240 let g = g2.max(0.0).sqrt();
241 if g == 0.0 {
242 return (0.0, f64::INFINITY);
243 }
244 (g, sin * (2.0 + 3.0 * c * cos) / (PI * g))
246 }
247 }
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
254#[serde(try_from = "ProfileData", into = "ProfileData")]
255pub struct Profile {
256 shape: NoseShape,
257 length_m: f64,
258 fore_radius_m: f64,
259 aft_radius_m: f64,
260 clipped: bool,
261 curve: Curve,
263 offset_m: f64,
265 scale_m: f64,
267 tip_aft: bool,
269 xi0: f64,
272}
273
274#[derive(Serialize, Deserialize)]
276#[serde(deny_unknown_fields)]
277struct ProfileData {
278 shape: NoseShape,
279 length_m: f64,
280 fore_radius_m: f64,
281 aft_radius_m: f64,
282 #[serde(default)]
283 clipped: bool,
284}
285
286impl TryFrom<ProfileData> for Profile {
287 type Error = DesignError;
288
289 fn try_from(data: ProfileData) -> Result<Self, DesignError> {
290 Profile::transition(
291 data.shape,
292 data.length_m,
293 data.fore_radius_m,
294 data.aft_radius_m,
295 data.clipped,
296 )
297 }
298}
299
300impl From<Profile> for ProfileData {
301 fn from(profile: Profile) -> Self {
302 Self {
303 shape: profile.shape,
304 length_m: profile.length_m,
305 fore_radius_m: profile.fore_radius_m,
306 aft_radius_m: profile.aft_radius_m,
307 clipped: profile.clipped,
308 }
309 }
310}
311
312pub(crate) fn check_dimension(
314 what: &'static str,
315 value: f64,
316 allow_zero: bool,
317) -> Result<(), DesignError> {
318 let ok = value.is_finite() && (value > 0.0 || (allow_zero && value == 0.0));
319 if ok {
320 Ok(())
321 } else {
322 Err(DesignError::Domain { what, value })
323 }
324}
325
326impl Profile {
327 pub fn nose(shape: NoseShape, length_m: f64, base_radius_m: f64) -> Result<Self, DesignError> {
334 check_dimension("nose cone base radius", base_radius_m, false)?;
335 Self::transition(shape, length_m, 0.0, base_radius_m, false)
336 }
337
338 pub fn transition(
346 shape: NoseShape,
347 length_m: f64,
348 fore_radius_m: f64,
349 aft_radius_m: f64,
350 clipped: bool,
351 ) -> Result<Self, DesignError> {
352 check_dimension("profile length", length_m, false)?;
353 check_dimension("profile fore radius", fore_radius_m, true)?;
354 check_dimension("profile aft radius", aft_radius_m, true)?;
355 let big = fore_radius_m.max(aft_radius_m);
356 let small = fore_radius_m.min(aft_radius_m);
357 check_dimension("profile largest radius", big, false)?;
358 let tip_aft = aft_radius_m < fore_radius_m;
359 let mut profile = Self {
360 shape,
361 length_m,
362 fore_radius_m,
363 aft_radius_m,
364 clipped,
365 curve: Curve::Conical,
366 offset_m: small,
367 scale_m: big - small,
368 tip_aft,
369 xi0: 0.0,
370 };
371 if big == small {
372 return Ok(profile);
374 }
375 if clipped && small > 0.0 {
376 let (curve, nose_length) = clip(shape, length_m, small, big)?;
377 profile.curve = curve;
378 profile.offset_m = 0.0;
379 profile.scale_m = big;
380 profile.xi0 = 1.0 - length_m / nose_length;
381 } else {
382 let fineness = length_m / (big - small);
383 shape.validate(fineness)?;
384 profile.curve = Curve::new(shape, fineness);
385 }
386 Ok(profile)
387 }
388
389 pub fn shape(&self) -> NoseShape {
391 self.shape
392 }
393
394 pub fn length_m(&self) -> f64 {
396 self.length_m
397 }
398
399 pub fn fore_radius_m(&self) -> f64 {
401 self.fore_radius_m
402 }
403
404 pub fn aft_radius_m(&self) -> f64 {
406 self.aft_radius_m
407 }
408
409 pub fn clipped(&self) -> bool {
411 self.clipped
412 }
413
414 pub fn max_radius_m(&self) -> f64 {
416 let ends = self.fore_radius_m.max(self.aft_radius_m);
417 match self.curve {
418 Curve::Arc { arc, lambda } if self.scale_m > 0.0 => {
419 let xi_peak = arc.xc / lambda;
421 let lo = self.xi0;
422 if xi_peak > lo && xi_peak < 1.0 {
423 ends.max(self.offset_m + self.scale_m * (arc.rho + arc.yc))
424 } else {
425 ends
426 }
427 }
428 _ => ends,
429 }
430 }
431
432 fn xi(&self, distance_m: f64, from_aft: bool) -> (f64, f64) {
436 let fraction = (distance_m / self.length_m).clamp(0.0, 1.0);
437 let span = 1.0 - self.xi0;
438 let toward_tip = if from_aft == self.tip_aft {
439 fraction
440 } else {
441 1.0 - fraction
442 };
443 let sign = if self.tip_aft { -1.0 } else { 1.0 };
444 (self.xi0 + span * toward_tip, sign * span / self.length_m)
445 }
446
447 pub fn radius_m(&self, x_m: f64) -> f64 {
449 self.radius_and_slope(x_m).0
450 }
451
452 pub fn radius_and_slope(&self, x_m: f64) -> (f64, f64) {
454 self.at_distance(x_m, false)
455 }
456
457 pub(crate) fn at_distance(&self, distance_m: f64, from_aft: bool) -> (f64, f64) {
460 if self.scale_m == 0.0 {
461 return (self.offset_m, 0.0);
462 }
463 let (xi, dxi) = self.xi(distance_m, from_aft);
464 let (g, dg) = self.curve.eval(xi);
465 (self.offset_m + self.scale_m * g, self.scale_m * dg * dxi)
466 }
467}
468
469fn clip(shape: NoseShape, length: f64, small: f64, big: f64) -> Result<(Curve, f64), DesignError> {
472 let target = small / big;
473 let invert = |curve: Curve| -> f64 {
475 let (mut lo, mut hi) = (0.0, 1.0);
476 for _ in 0..200 {
477 let mid = 0.5 * (lo + hi);
478 if curve.eval(mid).0 < target {
479 lo = mid;
480 } else {
481 hi = mid;
482 }
483 if hi - lo <= f64::EPSILON {
484 break;
485 }
486 }
487 0.5 * (lo + hi)
488 };
489 match shape {
490 NoseShape::Ogive { radius_ratio } if radius_ratio < 1.0 => {
491 Err(DesignError::Geometry(format!(
492 "a clipped ogive transition needs a monotone profile, so its radius ratio must be at \
493 least 1, not {radius_ratio}"
494 )))
495 }
496 NoseShape::Ogive { radius_ratio } => {
497 let piece = |nose_length: f64| -> Result<f64, DesignError> {
500 let fineness = nose_length / big;
501 shape.validate(fineness)?;
502 let xi0 = invert(Curve::new(shape, fineness));
503 Ok(nose_length * (1.0 - xi0))
504 };
505 let mut lo = length;
506 let mut hi = length;
507 let mut grown = false;
509 for _ in 0..200 {
510 hi *= 2.0;
511 if piece(hi).is_ok_and(|p| p >= length) {
512 grown = true;
513 break;
514 }
515 }
516 if !grown {
517 return Err(DesignError::Domain {
518 what: "ogive radius ratio",
519 value: radius_ratio,
520 });
521 }
522 for _ in 0..200 {
523 let mid = 0.5 * (lo + hi);
524 match piece(mid) {
525 Ok(p) if p >= length => hi = mid,
526 _ => lo = mid,
527 }
528 if hi - lo <= 4.0 * f64::EPSILON * hi {
529 break;
530 }
531 }
532 let fineness = hi / big;
533 shape.validate(fineness)?;
534 Ok((Curve::new(shape, fineness), hi))
535 }
536 _ => {
537 shape.validate(1.0)?;
538 let curve = Curve::new(shape, 1.0);
539 let xi0 = invert(curve);
540 Ok((curve, length / (1.0 - xi0)))
541 }
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 const ALL: [NoseShape; 9] = [
550 NoseShape::Conical {},
551 NoseShape::TANGENT_OGIVE,
552 NoseShape::Ogive { radius_ratio: 2.5 },
553 NoseShape::Ogive { radius_ratio: 0.6 },
554 NoseShape::Elliptical {},
555 NoseShape::PowerSeries { exponent: 0.5 },
556 NoseShape::ParabolicSeries { parameter: 0.75 },
557 NoseShape::VON_KARMAN,
558 NoseShape::LV_HAACK,
559 ];
560
561 #[test]
562 fn every_nose_runs_from_the_tip_to_the_base_radius() {
563 for shape in ALL {
564 let nose = Profile::nose(shape, 0.3, 0.05).unwrap();
565 assert!(nose.radius_m(0.0).abs() < 1e-15, "{shape:?}");
566 assert!((nose.radius_m(0.3) - 0.05).abs() < 1e-15, "{shape:?}");
567 for x in [0.03, 0.11, 0.2, 0.29] {
569 let h = 1e-6;
570 let numeric = (nose.radius_m(x + h) - nose.radius_m(x - h)) / (2.0 * h);
571 let (_, slope) = nose.radius_and_slope(x);
572 assert!(
573 (numeric - slope).abs() < 1e-7 * slope.abs().max(1.0),
574 "{shape:?} at {x}: {numeric} vs {slope}"
575 );
576 }
577 }
578 }
579
580 #[test]
583 fn secant_ogive_and_haack_parameters_change_profile() {
584 let (length, radius) = (0.4, 0.05);
585 let at =
586 |shape: NoseShape, x: f64| Profile::nose(shape, length, radius).unwrap().radius_m(x);
587 let tangent = Profile::nose(NoseShape::TANGENT_OGIVE, length, radius).unwrap();
589 assert!(tangent.radius_and_slope(length).1.abs() < 1e-12);
590 let secant = Profile::nose(NoseShape::Ogive { radius_ratio: 2.0 }, length, radius).unwrap();
591 assert!(secant.radius_and_slope(length).1 > 0.01);
592 let x = 0.2;
594 assert!(at(NoseShape::Conical {}, x) < at(NoseShape::Ogive { radius_ratio: 2.0 }, x));
595 assert!(at(NoseShape::Ogive { radius_ratio: 2.0 }, x) < at(NoseShape::TANGENT_OGIVE, x));
596 let bulged = Profile::nose(NoseShape::Ogive { radius_ratio: 0.5 }, length, radius).unwrap();
597 assert!(bulged.max_radius_m() > radius);
598 assert!(bulged.radius_m(0.35) > radius);
599 let flat = at(NoseShape::Ogive { radius_ratio: 1e8 }, x);
601 assert!((flat - at(NoseShape::Conical {}, x)).abs() < 1e-8);
602 let mid = length / 2.0;
605 let vk = at(NoseShape::VON_KARMAN, mid);
606 let lv = at(NoseShape::LV_HAACK, mid);
607 assert!((vk - radius * 0.5f64.sqrt()).abs() < 1e-15);
608 assert!((lv - radius * ((PI / 2.0 + 1.0 / 3.0) / PI).sqrt()).abs() < 1e-15);
609 assert!(lv > vk);
610 for shape in [
612 NoseShape::PowerSeries { exponent: 0.0 },
613 NoseShape::PowerSeries { exponent: 1.5 },
614 NoseShape::PowerSeries { exponent: 0.049 },
615 NoseShape::ParabolicSeries { parameter: -0.1 },
616 NoseShape::Haack { parameter: 0.7 },
617 NoseShape::Ogive { radius_ratio: 0.1 },
618 NoseShape::Ogive {
619 radius_ratio: f64::NAN,
620 },
621 ] {
622 assert!(Profile::nose(shape, length, radius).is_err(), "{shape:?}");
623 }
624 }
625
626 #[test]
629 fn ogive_transition_hits_both_radii_and_is_monotone() {
630 for shape in ALL {
631 if matches!(shape, NoseShape::Ogive { radius_ratio } if radius_ratio < 1.0) {
632 continue; }
634 for clipped in [false, true] {
635 for (fore, aft) in [(0.03, 0.05), (0.05, 0.03)] {
636 let t = Profile::transition(shape, 0.1, fore, aft, clipped).unwrap();
637 assert!(
638 (t.radius_m(0.0) - fore).abs() < 1e-12,
639 "{shape:?} {clipped}"
640 );
641 assert!((t.radius_m(0.1) - aft).abs() < 1e-12, "{shape:?} {clipped}");
642 let mut last = t.radius_m(0.0);
643 for i in 1..=1000 {
644 let r = t.radius_m(0.1 * f64::from(i) / 1000.0);
645 if fore < aft {
646 assert!(r >= last - 1e-15, "{shape:?} {clipped} grows");
647 } else {
648 assert!(r <= last + 1e-15, "{shape:?} {clipped} shrinks");
649 }
650 last = r;
651 }
652 }
653 }
654 }
655 let grow = Profile::transition(NoseShape::TANGENT_OGIVE, 0.1, 0.03, 0.05, false).unwrap();
657 let shrink = Profile::transition(NoseShape::TANGENT_OGIVE, 0.1, 0.05, 0.03, false).unwrap();
658 for x in [0.0, 0.013, 0.05, 0.08] {
659 assert!((grow.radius_m(x) - shrink.radius_m(0.1 - x)).abs() < 1e-15);
660 }
661 for shape in [NoseShape::Conical {}, NoseShape::TANGENT_OGIVE] {
664 let a = Profile::transition(shape, 0.1, 0.03, 0.05, false).unwrap();
665 let b = Profile::transition(shape, 0.1, 0.03, 0.05, true).unwrap();
666 for x in [0.01, 0.04, 0.07] {
667 assert!(
668 (a.radius_m(x) - b.radius_m(x)).abs() < 1e-12,
669 "{shape:?} at {x}"
670 );
671 }
672 }
673 let shape = NoseShape::PowerSeries { exponent: 0.5 };
674 let a = Profile::transition(shape, 0.1, 0.03, 0.05, false).unwrap();
675 let b = Profile::transition(shape, 0.1, 0.03, 0.05, true).unwrap();
676 assert!((a.radius_m(0.02) - b.radius_m(0.02)).abs() > 1e-3);
677 let n_len = 0.1 / (1.0 - (0.03f64 / 0.05).powi(2));
679 let x0 = n_len - 0.1;
680 assert!((b.radius_m(0.02) - 0.05 * ((x0 + 0.02) / n_len).sqrt()).abs() < 1e-12);
681 }
682
683 fn close(got: f64, want: f64, rel: f64, what: &str) {
684 let err = ((got - want) / want).abs();
685 assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
686 }
687
688 #[test]
693 fn nose_volumes_match_closed_forms() {
694 use crate::solids::{Wall, revolve};
695 let tol = 1e-10;
696 let (r, l): (f64, f64) = (0.05, 0.3);
697 let solid = |shape| revolve(&Profile::nose(shape, l, r).unwrap(), Wall::Filled {}).unwrap();
698
699 let g = solid(NoseShape::Conical {});
701 close(g.volume_m3, PI * r * r * l / 3.0, tol, "cone volume");
702 close(g.centroid_m, 0.75 * l, tol, "cone centroid");
703 close(
704 g.wetted_area_m2,
705 PI * r * (r * r + l * l).sqrt(),
706 tol,
707 "cone area",
708 );
709 close(g.planform_area_m2, r * l, tol, "cone planform");
710 close(
711 g.planform_centroid_m,
712 2.0 * l / 3.0,
713 tol,
714 "cone planform centroid",
715 );
716
717 let loft = revolve(
719 &Profile::nose(NoseShape::TANGENT_OGIVE, 0.25, 0.04).unwrap(),
720 Wall::Filled {},
721 )
722 .unwrap();
723 assert!((loft.volume_m3 - 6.7509e-4).abs() < 5e-9);
724
725 for ratio in [1.0, 2.5, 0.6] {
727 let lam = l / r;
728 let rho = ratio * (r * r + l * l) / (2.0 * r);
729 let alpha = (r / l).atan() - ((l * l + r * r).sqrt() / (2.0 * rho)).acos();
730 let (xc, yc) = (rho * alpha.cos(), rho * alpha.sin());
731 assert!(ratio * lam >= 1.0);
732 let f = |u: f64| 0.5 * (u * (rho * rho - u * u).sqrt() + rho * rho * (u / rho).asin());
734 let g_ = |u: f64| -(rho * rho - u * u).powf(1.5) / 3.0;
735 let (u0, u1) = (-xc, l - xc);
736 let volume = PI
737 * ((rho * rho + yc * yc) * l - (u1.powi(3) - u0.powi(3)) / 3.0
738 + 2.0 * yc * (f(u1) - f(u0)));
739 let moment = PI
741 * ((rho * rho + yc * yc) * l * l / 2.0
742 - ((u1.powi(4) - u0.powi(4)) / 4.0 + xc * (u1.powi(3) - u0.powi(3)) / 3.0)
743 + 2.0 * yc * (g_(u1) - g_(u0) + xc * (f(u1) - f(u0))));
744 let area = 2.0 * PI * rho * (l + yc * ((u1 / rho).asin() - (u0 / rho).asin()));
745 let planform = 2.0 * (yc * l + f(u1) - f(u0));
746 let s = solid(NoseShape::Ogive {
747 radius_ratio: ratio,
748 });
749 close(s.volume_m3, volume, tol, "ogive volume");
750 close(s.centroid_m, moment / volume, tol, "ogive centroid");
751 close(s.wetted_area_m2, area, tol, "ogive area");
752 close(s.planform_area_m2, planform, tol, "ogive planform");
753 }
754
755 let g = solid(NoseShape::Elliptical {});
758 let e = (1.0 - r * r / (l * l)).sqrt();
759 close(
760 g.volume_m3,
761 2.0 * PI * r * r * l / 3.0,
762 tol,
763 "ellipsoid volume",
764 );
765 close(g.centroid_m, 5.0 * l / 8.0, tol, "ellipsoid centroid");
766 close(
767 g.wetted_area_m2,
768 PI * r * r + PI * r * l * e.asin() / e,
769 tol,
770 "ellipsoid area",
771 );
772 close(
773 g.planform_area_m2,
774 PI * r * l / 2.0,
775 tol,
776 "ellipse planform",
777 );
778 close(
779 g.planform_centroid_m,
780 l - 4.0 * l / (3.0 * PI),
781 tol,
782 "ellipse planform centroid",
783 );
784 let oblate = revolve(
786 &Profile::nose(NoseShape::Elliptical {}, 0.03, r).unwrap(),
787 Wall::Filled {},
788 )
789 .unwrap();
790 let e = (1.0 - 0.03f64.powi(2) / (r * r)).sqrt();
791 let area = PI * r * r + PI * 0.03f64.powi(2) / (2.0 * e) * ((1.0 + e) / (1.0 - e)).ln();
792 close(oblate.wetted_area_m2, area, tol, "oblate area");
793
794 for n in [0.3, 0.5, 0.75, 1.0] {
797 let g = solid(NoseShape::PowerSeries { exponent: n });
798 close(
799 g.volume_m3,
800 PI * r * r * l / (2.0 * n + 1.0),
801 tol,
802 "power volume",
803 );
804 close(
805 g.centroid_m,
806 l * (2.0 * n + 1.0) / (2.0 * n + 2.0),
807 tol,
808 "power centroid",
809 );
810 close(
811 g.planform_area_m2,
812 2.0 * r * l / (n + 1.0),
813 tol,
814 "power planform",
815 );
816 close(
817 g.planform_centroid_m,
818 l * (n + 1.0) / (n + 2.0),
819 tol,
820 "power planform centroid",
821 );
822 }
823 let g = solid(NoseShape::PowerSeries { exponent: 0.5 });
824 let area = PI * r / (6.0 * l * l) * ((r * r + 4.0 * l * l).powf(1.5) - r.powi(3));
825 close(g.wetted_area_m2, area, tol, "paraboloid area");
826
827 for k in [0.0, 0.5, 0.75, 1.0] {
829 let g = solid(NoseShape::ParabolicSeries { parameter: k });
830 let v = 4.0 / 3.0 - k + k * k / 5.0;
831 let m = 1.0 - 0.8 * k + k * k / 6.0;
832 close(
833 g.volume_m3,
834 PI * r * r * l * v / (2.0 - k).powi(2),
835 tol,
836 "parabolic volume",
837 );
838 close(g.centroid_m, l * m / v, tol, "parabolic centroid");
839 close(
840 g.planform_area_m2,
841 2.0 * r * l * (1.0 - k / 3.0) / (2.0 - k),
842 tol,
843 "parabolic planform",
844 );
845 close(
846 g.planform_centroid_m,
847 l * (2.0 / 3.0 - k / 4.0) / (1.0 - k / 3.0),
848 tol,
849 "parabolic planform centroid",
850 );
851 }
852
853 for c in [0.0, 1.0 / 3.0, 2.0 / 3.0] {
855 let g = solid(NoseShape::Haack { parameter: c });
856 close(
857 g.volume_m3,
858 PI * r * r * l * (0.5 + 3.0 * c / 16.0),
859 tol,
860 "Haack volume",
861 );
862 close(
863 g.centroid_m,
864 l * (11.0 + 3.0 * c) / (2.0 * (8.0 + 3.0 * c)),
865 tol,
866 "Haack centroid",
867 );
868 }
869 }
870
871 #[test]
872 fn tips_stay_exact_where_the_formulas_cancel() {
873 for theta in [0.1f64, 0.1 - 1e-12, 0.3] {
876 let direct = theta - (2.0 * theta).sin() / 2.0;
877 assert!(
878 (haack_core(theta) - direct).abs() <= 1e-13 * direct,
879 "{theta}"
880 );
881 }
882 let theta: f64 = 1e-3;
883 let series =
884 2.0 / 3.0 * theta.powi(3) - 2.0 / 15.0 * theta.powi(5) + 4.0 / 315.0 * theta.powi(7);
885 assert!((haack_core(theta) - series).abs() <= 1e-15 * series);
886 let t = Profile::transition(NoseShape::VON_KARMAN, 0.02, 0.0508, 0.0785, false).unwrap();
888 assert_eq!(t.radius_and_slope(0.0), (0.0508, f64::INFINITY));
889 let nose = Profile::nose(NoseShape::VON_KARMAN, 0.3, 0.05).unwrap();
890 assert_eq!(nose.radius_and_slope(0.0), (0.0, f64::INFINITY));
891 for xi in [1e-18, 1e-12, 1e-6] {
892 let (r, slope) = nose.radius_and_slope(0.3 * xi);
893 assert!(r > 0.0 && slope.is_finite() && slope > 0.0, "{xi}");
894 }
895 let slender =
898 Profile::transition(NoseShape::TANGENT_OGIVE, 0.05, 0.025, 0.0251, false).unwrap();
899 assert!((slender.radius_m(0.0) - 0.025).abs() < 1e-15);
900 assert!((slender.radius_m(0.05) - 0.0251).abs() < 1e-15);
901 let g = crate::solids::revolve(&slender, crate::solids::Wall::Filled {}).unwrap();
902 let (lo, hi) = (PI * 0.025f64.powi(2) * 0.05, PI * 0.0251f64.powi(2) * 0.05);
903 assert!(g.volume_m3 > lo && g.volume_m3 < hi);
904 let edge = Profile::nose(NoseShape::Ogive { radius_ratio: 0.2 }, 0.25, 0.05).unwrap();
906 assert!((edge.radius_m(0.25) - 0.05).abs() < 1e-15);
907 assert!(edge.radius_m(0.0).abs() < 1e-15);
908 assert!(matches!(
910 Profile::transition(
911 NoseShape::Ogive { radius_ratio: 0.7 },
912 0.05,
913 0.02,
914 0.025,
915 true
916 ),
917 Err(DesignError::Geometry(_))
918 ));
919 }
920
921 #[test]
922 fn unknown_shape_fields_are_rejected() {
923 for bad in [
924 r#"{"kind":"conical","radius_ratio":2.0}"#,
925 r#"{"kind":"elliptical","parameter":0.5}"#,
926 r#"{"kind":"haack","parameter":0.0,"clipped":true}"#,
927 ] {
928 assert!(serde_json::from_str::<NoseShape>(bad).is_err(), "{bad}");
929 }
930 assert_eq!(
931 serde_json::from_str::<NoseShape>(r#"{"kind":"conical"}"#).unwrap(),
932 NoseShape::Conical {}
933 );
934 assert!(
935 serde_json::from_str::<crate::solids::Wall>(r#"{"kind":"filled","thickness_m":0.002}"#)
936 .is_err()
937 );
938 }
939
940 #[test]
941 fn profiles_round_trip_through_serde_and_reject_bad_data() {
942 let t = Profile::transition(NoseShape::LV_HAACK, 0.12, 0.04, 0.02, true).unwrap();
943 let json = serde_json::to_string(&t).unwrap();
944 assert_eq!(
945 json,
946 r#"{"shape":{"kind":"haack","parameter":0.3333333333333333},"length_m":0.12,"fore_radius_m":0.04,"aft_radius_m":0.02,"clipped":true}"#
947 );
948 let back: Profile = serde_json::from_str(&json).unwrap();
949 assert_eq!(back, t);
950 let bad =
951 r#"{"shape":{"kind":"conical"},"length_m":-1,"fore_radius_m":0,"aft_radius_m":0.02}"#;
952 assert!(serde_json::from_str::<Profile>(bad).is_err());
953 assert!(Profile::transition(NoseShape::Conical {}, 0.1, 0.0, 0.0, false).is_err());
954 let cylinder =
955 Profile::transition(NoseShape::Elliptical {}, 0.1, 0.02, 0.02, true).unwrap();
956 assert_eq!(cylinder.radius_and_slope(0.05), (0.02, 0.0));
957 }
958}