Skip to main content

rockets_engine/
geometry.rs

1//! Geometry-to-aerodynamics: a Barrowman subset for slender model rockets
2//! (nose cone + body tube + fin set + optional conical boat-tail),
3//! implemented against the OpenRocket technical documentation and its
4//! `FinSetCalc` and `SymmetricComponentCalc` sources. CP comes from the
5//! component moment sum (with the supersonic aft shift); drag is friction +
6//! base + nose wave drag + rounded-fin form drag. The fin normal-force slope uses Diederich below Mach 1, the Busemann K1 term above Mach 1.5, and OpenRocket's Hermite join between them.
7//!
8//! Induced drag from angle of attack uses the normal-force tilt model
9//! (Cd += Cnα·α²). Fin planforms are trapezoidal (closed form) or
10//! half-ellipse (OpenRocket's 48-strip integration). Not covered
11//! (deliberately): forward shoulders or multi-diameter bodies, free-form
12//! fins, and fin-body interference drag.
13
14use crate::{Error, Result};
15
16/// Nose-cone families supported by the subset.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum NoseShape {
19    /// Straight conical nose.
20    Conical,
21    /// Tangent ogive nose.
22    Ogive,
23    /// Half-ellipsoid nose.
24    Elliptical,
25    /// Power-series parabolic nose (exponent 0.5).
26    Parabolic,
27}
28
29impl std::str::FromStr for NoseShape {
30    type Err = Error;
31
32    fn from_str(s: &str) -> Result<Self> {
33        match s {
34            "conical" | "cone" => Ok(Self::Conical),
35            "ogive" => Ok(Self::Ogive),
36            "elliptical" | "ellipse" => Ok(Self::Elliptical),
37            "parabolic" => Ok(Self::Parabolic),
38            _ => Err(Error::invalid(
39                "nose shape",
40                "conical, ogive, elliptical or parabolic",
41            )),
42        }
43    }
44}
45
46impl NoseShape {
47    /// CP location as a fraction of nose length, from the volume/area relation
48    /// XB = L − V/A_base. Ogive uses the standard tangent-ogive value.
49    fn cp_fraction(self) -> f64 {
50        match self {
51            Self::Conical => 2.0 / 3.0,
52            Self::Ogive => 0.466,
53            Self::Elliptical => 1.0 / 3.0,
54            Self::Parabolic => 0.5,
55        }
56    }
57
58    /// Lateral (wetted) area. Conical uses the exact slant area; the others
59    /// approximate the cylinder at the base diameter.
60    fn lateral_area(self, length_m: f64, diameter_m: f64) -> f64 {
61        let r = diameter_m / 2.0;
62        match self {
63            Self::Conical => std::f64::consts::PI * r * (r * r + length_m * length_m).sqrt(),
64            _ => std::f64::consts::PI * diameter_m * length_m,
65        }
66    }
67}
68
69/// A nose cone with length and base diameter, in metres.
70#[derive(Debug, Clone, Copy)]
71pub struct NoseCone {
72    /// Shape family.
73    pub shape: NoseShape,
74    /// Nose length in metres.
75    pub length_m: f64,
76    /// Base diameter in metres.
77    pub diameter_m: f64,
78}
79
80/// The cylindrical body tube behind the nose.
81#[derive(Debug, Clone, Copy)]
82pub struct BodyTube {
83    /// Tube length in metres.
84    pub length_m: f64,
85    /// Tube diameter in metres.
86    pub diameter_m: f64,
87}
88
89/// Identical trapezoidal fins at the aft end of the tube. `leading_sweep_m` is
90/// the horizontal offset of the tip leading edge from the root leading edge.
91#[derive(Debug, Clone, Copy)]
92pub struct TrapezoidalFins {
93    /// Number of identical fins.
94    pub count: u32,
95    /// Root chord length in metres.
96    pub root_chord_m: f64,
97    /// Tip chord length in metres.
98    pub tip_chord_m: f64,
99    /// Fin semispan in metres (body surface to tip).
100    pub span_m: f64,
101    /// Horizontal offset of the tip leading edge from the root leading edge.
102    pub leading_sweep_m: f64,
103    /// Fin thickness in metres.
104    pub thickness_m: f64,
105}
106
107/// The complete slender geometry.
108/// A conical boat-tail (diameter-reducing transition) at the very aft end
109/// of the body tube.
110#[derive(Debug, Clone, Copy)]
111pub struct BoatTail {
112    /// Transition length in metres.
113    pub length_m: f64,
114    /// Diameter at the forward end (must equal the body tube diameter).
115    pub fore_diameter_m: f64,
116    /// Diameter at the aft end (must be smaller than the forward diameter).
117    pub aft_diameter_m: f64,
118}
119
120/// Identical elliptical fins (half-ellipse planform) at the aft end of the
121/// tube. Geometry follows OpenRocket's EllipticalFinSet: root chord along
122/// the body, tip point mid-chord at the span.
123#[derive(Debug, Clone, Copy)]
124pub struct EllipticalFins {
125    /// Number of identical fins.
126    pub count: u32,
127    /// Fin root chord in metres.
128    pub root_chord_m: f64,
129    /// Fin semispan in metres (body surface to tip).
130    pub span_m: f64,
131    /// Fin thickness in metres.
132    pub thickness_m: f64,
133}
134
135/// The fin-set planform family.
136#[derive(Debug, Clone, Copy)]
137pub enum FinSet {
138    /// Trapezoidal fins (straight edges).
139    Trapezoidal(TrapezoidalFins),
140    /// Elliptical fins (half-ellipse planform).
141    Elliptical(EllipticalFins),
142}
143
144impl From<TrapezoidalFins> for FinSet {
145    fn from(fins: TrapezoidalFins) -> Self {
146        Self::Trapezoidal(fins)
147    }
148}
149
150impl From<EllipticalFins> for FinSet {
151    fn from(fins: EllipticalFins) -> Self {
152        Self::Elliptical(fins)
153    }
154}
155
156/// Single-fin planform geometry shared by the fin models.
157#[derive(Debug, Clone, Copy)]
158struct Planform {
159    count: u32,
160    root_chord_m: f64,
161    span_m: f64,
162    thickness_m: f64,
163    area_m2: f64,
164    mac_m: f64,
165    mac_lead_m: f64,
166    cos_midchord: f64,
167    cos_leading_edge: f64,
168    cg_x_m: f64,
169    cg_y_m: f64,
170}
171
172/// A slender rocket: nose cone, body tube, fin set and an optional conical
173/// boat-tail.
174#[derive(Debug, Clone, Copy)]
175pub struct RocketGeometry {
176    /// Nose cone.
177    pub nose: NoseCone,
178    /// Cylindrical body tube.
179    pub tube: BodyTube,
180    /// Aft fin set.
181    pub fins: FinSet,
182    /// Optional conical boat-tail aft of the fins.
183    pub boat_tail: Option<BoatTail>,
184    /// Roughness height in metres (default 20 µm, painted surface).
185    pub roughness_m: f64,
186}
187
188/// NASA TR-R-100 zero-lift pressure drag for fineness-3 noses (from
189/// OpenRocket's SymmetricComponentCalc), extrapolated to other fineness
190/// ratios by the stagnation-scaled power law.
191const ELLIPSOID_TABLE: &[(f64, f64)] = &[
192    (1.2, 0.110),
193    (1.25, 0.128),
194    (1.3, 0.140),
195    (1.4, 0.148),
196    (1.6, 0.152),
197    (2.0, 0.159),
198    (2.4, 0.162),
199];
200const PARABOLIC_TABLE: &[(f64, f64)] = &[
201    (0.95, 0.0),
202    (0.975, 0.016),
203    (1.0, 0.041),
204    (1.05, 0.092),
205    (1.1, 0.109),
206    (1.2, 0.119),
207    (1.4, 0.113),
208    (1.7, 0.108),
209];
210
211impl RocketGeometry {
212    /// Build with the default painted-surface roughness (20 µm).
213    pub fn new(nose: NoseCone, tube: BodyTube, fins: impl Into<FinSet>) -> Self {
214        Self {
215            nose,
216            tube,
217            fins: fins.into(),
218            boat_tail: None,
219            roughness_m: 20e-6,
220        }
221    }
222
223    /// Set the roughness height in metres.
224    pub fn with_roughness(mut self, roughness_m: f64) -> Self {
225        self.roughness_m = roughness_m;
226        self
227    }
228
229    /// Attach a conical boat-tail at the aft end of the tube. Its forward
230    /// diameter must match the tube and its aft diameter must be smaller.
231    pub fn with_boat_tail(mut self, boat_tail: BoatTail) -> Result<Self> {
232        if boat_tail.length_m <= 0.0 || !boat_tail.length_m.is_finite() {
233            return Err(Error::invalid("boat tail", "length must be positive"));
234        }
235        if (boat_tail.fore_diameter_m - self.tube.diameter_m).abs() > 1e-12 {
236            return Err(Error::invalid(
237                "boat tail",
238                "forward diameter must equal the body tube diameter",
239            ));
240        }
241        if boat_tail.aft_diameter_m <= 0.0 || boat_tail.aft_diameter_m >= boat_tail.fore_diameter_m
242        {
243            return Err(Error::invalid(
244                "boat tail",
245                "aft diameter must be positive and smaller than the forward diameter",
246            ));
247        }
248        self.boat_tail = Some(boat_tail);
249        Ok(self)
250    }
251
252    /// Total aerodynamic length (nose + tube + boat-tail).
253    pub fn total_length_m(&self) -> f64 {
254        self.nose.length_m + self.tube.length_m + self.boat_tail.map(|b| b.length_m).unwrap_or(0.0)
255    }
256
257    /// Largest body diameter (nose base or tube).
258    pub fn max_diameter_m(&self) -> f64 {
259        self.nose.diameter_m.max(self.tube.diameter_m)
260    }
261
262    /// Reference area, π/4·d² of the largest diameter.
263    pub fn ref_area_m2(&self) -> f64 {
264        std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
265    }
266
267    /// Single-fin planform geometry, shared by the fin models. Trapezoids
268    /// use the closed forms; ellipses use OpenRocket's 48-strip integration
269    /// (FinSetCalc.calculateFinGeometry) over the half-ellipse boundary.
270    fn planform(&self) -> Planform {
271        match self.fins {
272            FinSet::Trapezoidal(f) => {
273                let (cr, ct, s, m) = (f.root_chord_m, f.tip_chord_m, f.span_m, f.leading_sweep_m);
274                let area = s * (cr + ct) / 2.0;
275                let mac = (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct));
276                let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
277                let gamma_mid = ((m + ct / 2.0 - cr / 2.0) / s).atan();
278                Planform {
279                    count: f.count,
280                    root_chord_m: cr,
281                    span_m: s,
282                    thickness_m: f.thickness_m,
283                    area_m2: area,
284                    mac_m: mac,
285                    mac_lead_m: m * y_mac / s,
286                    cos_midchord: gamma_mid.cos(),
287                    cos_leading_edge: s / (s * s + m * m).sqrt(),
288                    cg_x_m: (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct)),
289                    cg_y_m: y_mac,
290                }
291            }
292            FinSet::Elliptical(f) => {
293                const DIVISIONS: usize = 48;
294                let (cr, s) = (f.root_chord_m, f.span_m);
295                let dy = s / (DIVISIONS - 1) as f64;
296                let mut area = 0.0;
297                let mut mac_num = 0.0;
298                let mut lead_num = 0.0;
299                let mut cos_mid = 0.0;
300                let mut cos_le = 0.0;
301                let mut previous: Option<(f64, f64)> = None;
302                for i in 0..DIVISIONS {
303                    let y = i as f64 * dy;
304                    let t = (y / s).min(1.0);
305                    let arc = (1.0 - t * t).max(0.0).sqrt();
306                    let lead = cr / 2.0 * (1.0 - arc);
307                    let chord = cr * arc;
308                    let mid = cr / 2.0;
309                    area += chord * dy;
310                    mac_num += chord * chord * dy;
311                    lead_num += lead * chord * dy;
312                    if let Some((prev_mid, prev_lead)) = previous {
313                        let hypot_mid = dy.hypot(mid - prev_mid);
314                        if hypot_mid > 0.0 {
315                            cos_mid += dy / hypot_mid;
316                        }
317                        let hypot_lead = dy.hypot(lead - prev_lead);
318                        if hypot_lead > 0.0 {
319                            cos_le += dy / hypot_lead;
320                        }
321                    }
322                    previous = Some((mid, lead));
323                }
324                cos_mid /= (DIVISIONS - 1) as f64;
325                cos_le /= (DIVISIONS - 1) as f64;
326                Planform {
327                    count: f.count,
328                    root_chord_m: cr,
329                    span_m: s,
330                    thickness_m: f.thickness_m,
331                    area_m2: area,
332                    mac_m: mac_num / area,
333                    mac_lead_m: lead_num / area,
334                    cos_midchord: cos_mid,
335                    cos_leading_edge: cos_le,
336                    cg_x_m: cr / 2.0,
337                    cg_y_m: 4.0 * s / (3.0 * std::f64::consts::PI),
338                }
339            }
340        }
341    }
342
343    #[cfg(test)]
344    fn fin_area(&self) -> f64 {
345        self.planform().area_m2
346    }
347
348    /// Fin CP as a fraction of the MAC: quarter-chord at M ≤ 0.5, the
349    /// empirical supersonic formula at M ≥ 2, a linear join between
350    /// (OpenRocket's FinSetCalc.calculateCPPos; it fits a polynomial, but
351    /// the join error is small beside the component moment sum).
352    fn fin_cp_fraction(&self, mach: f64) -> f64 {
353        if mach <= 0.5 {
354            return 0.25;
355        }
356        let planform = self.planform();
357        let ar = 2.0 * planform.span_m.powi(2) / planform.area_m2;
358        let supersonic = |m: f64| {
359            let beta = (m * m - 1.0).sqrt();
360            (ar * beta - 0.67) / (2.0 * ar * beta - 1.0)
361        };
362        if mach >= 2.0 {
363            return supersonic(mach);
364        }
365        let hi = supersonic(2.0);
366        0.25 + (hi - 0.25) * (mach - 0.5) / 1.5
367    }
368
369    /// Fin CP measured from the fin's root leading edge.
370    fn fin_cp(&self, mach: f64) -> f64 {
371        let planform = self.planform();
372        planform.mac_lead_m + self.fin_cp_fraction(mach) * planform.mac_m
373    }
374
375    /// Fin pressure (form) drag: OpenRocket's rounded-section model —
376    /// a Mach-dependent leading-edge term times cos²(Γ_LE), plus half the
377    /// base drag for the trailing edge, scaled by span·thickness / A_ref.
378    fn fin_pressure_cd(&self, mach: f64) -> f64 {
379        let planform = self.planform();
380        if planform.span_m <= 0.0 || planform.thickness_m <= 0.0 {
381            return 0.0;
382        }
383        let leading_edge = if mach < 0.9 {
384            (1.0 - mach * mach).powf(-0.417) - 1.0
385        } else if mach < 1.0 {
386            1.0 - 1.785 * (mach - 0.9)
387        } else {
388            let m2 = mach * mach;
389            1.214 - 0.502 / m2 + 0.1095 / (m2 * m2)
390        };
391        let cos_le = planform.cos_leading_edge;
392        let cd = leading_edge * cos_le * cos_le + Self::base_cd(mach) / 2.0;
393        // OpenRocket instances the fin set per fin; the set's drag is n
394        // times the single-fin value.
395        cd * planform.span_m * planform.thickness_m / self.ref_area_m2() * planform.count as f64
396    }
397
398    /// Nose pressure drag per frontal area for conical and tangent-ogive
399    /// noses: OpenRocket's direct calculation (polynomial through M 1–1.3,
400    /// 2.1·sin²φ + 0.5·sinφ/β above, a·M^b + 0.8·sin²φ fitted subsonic).
401    /// Elliptical and parabolic noses are not modelled past Mach 1 yet and
402    /// return 0 there (their subsonic value is 0 in this subset).
403    fn nose_pressure_cd(&self, mach: f64) -> f64 {
404        // sinphi is the sine of the surface angle at 99% of the nose length
405        // (OpenRocket's SymmetricComponentCalc). For a cone that is the half
406        // angle; for a tangent ogive the surface at the base is nearly
407        // axial, so its wave drag is essentially zero — matching OpenRocket.
408        let sinphi = match self.nose.shape {
409            NoseShape::Conical | NoseShape::Ogive => self.nose_sinphi(),
410            NoseShape::Elliptical => return self.table_pressure_cd(mach, ELLIPSOID_TABLE),
411            NoseShape::Parabolic => return self.table_pressure_cd(mach, PARABOLIC_TABLE),
412        };
413        const GAMMA: f64 = 1.4;
414        let cd_mach0 = 0.8 * sinphi * sinphi;
415        let y1 = 2.1 * sinphi * sinphi + 0.6019 * sinphi;
416        let d0 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * sinphi);
417        let d1 = -1.1341 * sinphi;
418        if mach >= 1.3 {
419            return 2.1 * sinphi * sinphi + 0.5 * sinphi / (mach * mach - 1.0).sqrt();
420        }
421        if mach >= 1.0 {
422            return Self::hermite(mach, 1.0, 1.3, sinphi, y1, d0, d1);
423        }
424        // Subsonic: Cd = a·M^b + Cd(0), fitted to reach (1, sinphi) with the
425        // polynomial's derivative (OpenRocket's interpolation scheme).
426        let eps = 0.01;
427        let at_eps = Self::hermite(1.0 + eps, 1.0, 1.3, sinphi, y1, d0, d1);
428        let min_deriv = (at_eps - sinphi) / eps;
429        let gap = sinphi - cd_mach0;
430        if gap <= 0.01 || min_deriv <= 0.01 {
431            return cd_mach0;
432        }
433        let b = min_deriv / gap;
434        gap * mach.powf(b) + cd_mach0
435    }
436
437    /// sin(phi) of the nose surface at 99% of the length (OpenRocket's
438    /// SymmetricComponentCalc convention).
439    fn nose_sinphi(&self) -> f64 {
440        let l = self.nose.length_m;
441        let r = self.nose.diameter_m / 2.0;
442        let r_tip = self.nose_radius(0.99 * l);
443        (r - r_tip) / (r - r_tip).hypot(0.01 * l)
444    }
445
446    /// Stagnation-pressure Cd, OpenRocket's formula.
447    fn stagnation_cd(mach: f64) -> f64 {
448        let m2 = mach * mach;
449        if mach <= 1.0 {
450            0.85 * (1.0 + m2 / 4.0 + m2 * m2 / 40.0)
451        } else {
452            0.85 * (1.84 - 0.76 / m2 + 0.166 / (m2 * m2) + 0.035 / (m2 * m2 * m2))
453        }
454    }
455
456    /// Linear interpolation in a (mach, cd) table, clamped at the ends.
457    fn table_interp(table: &[(f64, f64)], mach: f64) -> f64 {
458        if mach <= table[0].0 {
459            return table[0].1;
460        }
461        for pair in table.windows(2) {
462            let (lo, hi) = (pair[0], pair[1]);
463            if mach <= hi.0 {
464                let f = (mach - lo.0) / (hi.0 - lo.0);
465                return lo.1 + f * (hi.1 - lo.1);
466            }
467        }
468        table[table.len() - 1].1
469    }
470
471    /// Nose pressure drag from the NASA TR-R-100 fineness-3 tables
472    /// (OpenRocket's SymmetricComponentCalc), extrapolated to this nose's
473    /// fineness ratio and joined subsonically by the a·M^b + Cd(0) fit.
474    fn table_pressure_cd(&self, mach: f64, table: &[(f64, f64)]) -> f64 {
475        let fineness = self.nose.length_m / self.nose.diameter_m;
476        let log4 = (fineness + 1.0).ln() / 4.0_f64.ln();
477        let value_at = |m: f64| {
478            let stag = Self::stagnation_cd(m);
479            stag * (Self::table_interp(table, m) / stag).powf(log4)
480        };
481        let min = table[0].0;
482        if mach >= min {
483            return value_at(mach);
484        }
485        let cd_mach0 = 0.8 * self.nose_sinphi().powi(2);
486        let min_value = value_at(min);
487        if min_value < 0.001 {
488            return cd_mach0;
489        }
490        let min_deriv = (value_at(min + 0.01) - min_value) / 0.01;
491        let gap = min_value - cd_mach0;
492        if gap <= 0.01 || min_deriv <= 0.01 {
493            return cd_mach0;
494        }
495        let b = min * min_deriv / gap;
496        let a = gap / min.powf(b);
497        a * mach.powf(b) + cd_mach0
498    }
499
500    #[cfg(test)]
501    fn midchord_cos(&self) -> f64 {
502        self.planform().cos_midchord
503    }
504
505    /// Cubic Hermite interpolation on [x0, x1] with endpoint values y0, y1
506    /// and endpoint derivatives d0, d1 per unit x.
507    fn hermite(x: f64, x0: f64, x1: f64, y0: f64, y1: f64, d0: f64, d1: f64) -> f64 {
508        let h = x1 - x0;
509        let t = (x - x0) / h;
510        let t2 = t * t;
511        let t3 = t2 * t;
512        (2.0 * t3 - 3.0 * t2 + 1.0) * y0
513            + (t3 - 2.0 * t2 + t) * h * d0
514            + (-2.0 * t3 + 3.0 * t2) * y1
515            + (t3 - t2) * h * d1
516    }
517
518    /// Mach bounds of OpenRocket's transonic interpolation.
519    const CNA_SUBSONIC: f64 = 0.9;
520    const CNA_SUPERSONIC: f64 = 1.5;
521
522    /// Normal-force slope of one fin, divided by the reference area.
523    /// Subsonic: OpenRocket's Diederich simplification. Supersonic:
524    /// 2·A_fin/(β·A_ref) (the K1 term of the Busemann expansion; the α
525    /// terms vanish for a slope table). Transonic: OpenRocket's cubic
526    /// Hermite join with matched endpoint derivatives.
527    fn fin_cna1(&self, mach: f64) -> f64 {
528        let planform = self.planform();
529        let span = planform.span_m;
530        let area = planform.area_m2;
531        let cos = planform.cos_midchord;
532        if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
533            return 0.0;
534        }
535        let ref_area = self.ref_area_m2();
536        let subsonic = |m: f64| {
537            let ratio = (span * span) / (area * cos);
538            2.0 * std::f64::consts::PI * span * span
539                / (1.0 + (1.0 + (1.0 - m * m) * ratio * ratio).sqrt())
540                / ref_area
541        };
542        let supersonic = |m: f64| 2.0 * area / (m * m - 1.0).sqrt() / ref_area;
543        if mach <= Self::CNA_SUBSONIC {
544            return subsonic(mach);
545        }
546        if mach >= Self::CNA_SUPERSONIC {
547            return supersonic(mach);
548        }
549        let sq = (1.0
550            + (1.0 - Self::CNA_SUBSONIC * Self::CNA_SUBSONIC)
551                * (span * span / (area * cos)).powi(2))
552        .sqrt();
553        let sub_v = 2.0 * std::f64::consts::PI * span * span / ref_area / (1.0 + sq);
554        let sub_d = 2.0 * Self::CNA_SUBSONIC * std::f64::consts::PI * span.powi(6)
555            / ((area * cos).powi(2) * ref_area * sq * (1.0 + sq).powi(2));
556        let super_v = supersonic(Self::CNA_SUPERSONIC);
557        let beta_b = (Self::CNA_SUPERSONIC * Self::CNA_SUPERSONIC - 1.0).powf(1.5);
558        let super_d = -area / ref_area * 2.0 * Self::CNA_SUPERSONIC / beta_b;
559        Self::hermite(
560            mach,
561            Self::CNA_SUBSONIC,
562            Self::CNA_SUPERSONIC,
563            sub_v,
564            super_v,
565            sub_d,
566            super_d,
567        )
568    }
569
570    /// Body-interference factor (1 + tau) with tau = r/(span + r).
571    fn interference_tau(&self) -> f64 {
572        let r = self.max_diameter_m() / 2.0;
573        r / (self.planform().span_m + r)
574    }
575
576    fn fin_count_factor(&self) -> f64 {
577        match self.planform().count {
578            5 => 0.948,
579            6 => 0.913,
580            7 => 0.854,
581            8 => 0.810,
582            n if n >= 9 => 0.75,
583            _ => 1.0,
584        }
585    }
586
587    /// Total fin-set normal-force slope for this Mach number.
588    ///
589    /// The Diederich `fin_cna1` is the slope of a fin *pair* (a wing of
590    /// semispan `span`), so a set of `n` fins contributes `n/2` pairs,
591    /// with fin-body carryover (1 + tau) and a many-fin interference
592    /// reduction. This matches OpenRocket's `FinSetCalc` convention,
593    /// verified against its BarrowmanCalculatorTest Alpha III values.
594    fn fin_set_cna(&self, mach: f64) -> f64 {
595        self.fin_cna1(mach)
596            * (1.0 + self.interference_tau())
597            * self.fin_count_factor()
598            * (self.planform().count as f64 / 2.0)
599    }
600
601    /// Fin CP measured from the nose tip (fin trailing edge at tube end).
602    fn fin_cp_abs(&self, mach: f64) -> f64 {
603        self.nose.length_m + self.tube.length_m - self.planform().root_chord_m + self.fin_cp(mach)
604    }
605
606    /// Nose radius profile r(x) with x measured from the tip.
607    fn nose_radius(&self, x: f64) -> f64 {
608        let l = self.nose.length_m;
609        let r = self.nose.diameter_m / 2.0;
610        let t = (x / l).clamp(0.0, 1.0);
611        match self.nose.shape {
612            NoseShape::Conical => r * t,
613            NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
614            NoseShape::Parabolic => r * t * (2.0 - t),
615            NoseShape::Ogive => {
616                let rho = (r * r + l * l) / (2.0 * r);
617                (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
618            }
619        }
620    }
621
622    /// Solid-nose CG (from the tip) and pitch inertia per unit mass about
623    /// that CG, from numerical integration of the radius profile.
624    fn nose_volume_properties(&self) -> (f64, f64) {
625        const N: usize = 1024;
626        let l = self.nose.length_m;
627        let dx = l / N as f64;
628        let mut volume = 0.0; // ∫ r² dx (up to the density·π factor)
629        let mut moment = 0.0; // ∫ x r² dx
630        for i in 0..N {
631            let x = (i as f64 + 0.5) * dx;
632            let r2 = self.nose_radius(x).powi(2);
633            volume += r2 * dx;
634            moment += x * r2 * dx;
635        }
636        let cg = moment / volume;
637        let mut inertia = 0.0; // ∫ r² (r²/4 + (x−cg)²) dx
638        for i in 0..N {
639            let x = (i as f64 + 0.5) * dx;
640            let r2 = self.nose_radius(x).powi(2);
641            inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
642        }
643        (cg, inertia / volume)
644    }
645
646    /// Fin planform centroid, measured from the root leading edge.
647    fn fin_planform_cg(&self) -> f64 {
648        self.planform().cg_x_m
649    }
650
651    /// Fin centroid height above the body surface.
652    fn fin_radial_cg(&self) -> f64 {
653        self.planform().cg_y_m
654    }
655
656    /// Combine component masses with the geometry's component CGs into a
657    /// dry CG and a pitch-inertia estimate about that CG.
658    ///
659    /// The nose is a solid of revolution (numerically integrated), the
660    /// tube a thin-walled cylinder, and the fins and optional motor casing
661    /// point masses; evenly spaced fins add n/2 · m · (r_body + y_cg)² for
662    /// their radial offset. `casing` is (mass_kg, CG from the nose tip).
663    pub fn mass_properties(
664        &self,
665        nose_kg: f64,
666        tube_kg: f64,
667        fins_kg: f64,
668        casing: Option<(f64, f64)>,
669    ) -> MassProperties {
670        self.mass_properties_with_boat(nose_kg, tube_kg, fins_kg, None, casing)
671    }
672
673    /// [`mass_properties`](Self::mass_properties) with an optional boat-tail
674    /// shell mass (treated as a thin-walled frustum).
675    pub fn mass_properties_with_boat(
676        &self,
677        nose_kg: f64,
678        tube_kg: f64,
679        fins_kg: f64,
680        boat_kg: Option<f64>,
681        casing: Option<(f64, f64)>,
682    ) -> MassProperties {
683        assert!(
684            nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
685            "component masses must be non-negative"
686        );
687        let (nose_cg, nose_inertia) = self.nose_volume_properties();
688        let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
689        let fin_cg = self.nose.length_m + self.tube.length_m - self.planform().root_chord_m
690            + self.fin_planform_cg();
691        let mut parts: Vec<(f64, f64, f64)> = vec![
692            (nose_kg, nose_cg, nose_kg * nose_inertia),
693            (
694                tube_kg,
695                tube_cg,
696                tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
697            ),
698            (fins_kg, fin_cg, 0.0),
699        ];
700        if let (Some(b), Some(kg)) = (self.boat_tail, boat_kg) {
701            let rf = b.fore_diameter_m / 2.0;
702            let ra = b.aft_diameter_m / 2.0;
703            // Thin-walled frustum shell: lateral centroid and a mean-radius
704            // thin-shell inertia estimate.
705            let cg = self.nose.length_m
706                + self.tube.length_m
707                + b.length_m * (rf + 2.0 * ra) / (3.0 * (rf + ra));
708            let mean_r = (rf + ra) / 2.0;
709            parts.push((
710                kg,
711                cg,
712                kg * (b.length_m.powi(2) / 12.0 + mean_r * mean_r / 2.0),
713            ));
714        }
715        if let Some((kg, cg)) = casing {
716            parts.push((kg, cg, 0.0));
717        }
718        let total: f64 = parts.iter().map(|p| p.0).sum();
719        assert!(total > 0.0, "component masses must sum to a positive mass");
720        let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
721        let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
722        inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
723        MassProperties {
724            dry_mass_kg: total,
725            dry_cg_m: cg,
726            pitch_inertia_kg_m2: inertia,
727        }
728    }
729
730    /// Boat-tail normal-force slope: 2·((r_aft/r_fore)² − 1), negative for a
731    /// diameter-reducing transition (Barrowman).
732    fn boat_cna(&self) -> f64 {
733        match self.boat_tail {
734            Some(b) => {
735                let ratio = b.aft_diameter_m / b.fore_diameter_m;
736                2.0 * (ratio * ratio - 1.0)
737            }
738            None => 0.0,
739        }
740    }
741
742    /// Boat-tail CP from the nose tip: the frustum planform centroid,
743    /// x̄ = L·(r_fore + 3·r_aft)/(4·(r_fore + r_aft)) from its forward end.
744    fn boat_cp_abs(&self) -> f64 {
745        match self.boat_tail {
746            Some(b) => {
747                let rf = b.fore_diameter_m / 2.0;
748                let ra = b.aft_diameter_m / 2.0;
749                self.nose.length_m
750                    + self.tube.length_m
751                    + b.length_m * (rf + 3.0 * ra) / (4.0 * (rf + ra))
752            }
753            None => 0.0,
754        }
755    }
756
757    /// Boat-tail pressure drag (OpenRocket's SymmetricComponentCalc):
758    /// long gentle tapers (fineness ≥ 3) are free; shorter ones pay the
759    /// base drag over the annulus area, scaled by (3 − fineness)/2.
760    fn boat_pressure_cd(&self, mach: f64) -> f64 {
761        let Some(b) = self.boat_tail else {
762            return 0.0;
763        };
764        let fineness = b.length_m / (b.fore_diameter_m - b.aft_diameter_m);
765        if fineness >= 3.0 {
766            return 0.0;
767        }
768        let annulus =
769            std::f64::consts::FRAC_PI_4 * (b.fore_diameter_m.powi(2) - b.aft_diameter_m.powi(2));
770        let cd = Self::base_cd(mach) * annulus / self.ref_area_m2();
771        if fineness <= 1.0 {
772            cd
773        } else {
774            cd * (3.0 - fineness) / 2.0
775        }
776    }
777
778    /// Base-drag scaling: the base area shrinks to the boat-tail's aft face.
779    fn base_area_fraction(&self) -> f64 {
780        match self.boat_tail {
781            Some(b) => (b.aft_diameter_m / self.max_diameter_m()).powi(2),
782            None => 1.0,
783        }
784    }
785
786    /// Total CP from the component moment sum (nose + fin set + boat-tail).
787    fn total_cp(&self, mach: f64) -> f64 {
788        let nose_cna = 2.0;
789        let fin_cna = self.fin_set_cna(mach);
790        let boat_cna = self.boat_cna();
791        let total = nose_cna + fin_cna + boat_cna;
792        if total <= 0.0 {
793            return f64::NAN;
794        }
795        (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
796            + fin_cna * self.fin_cp_abs(mach)
797            + boat_cna * self.boat_cp_abs())
798            / total
799    }
800
801    #[cfg(test)]
802    fn nose_cp(&self) -> f64 {
803        self.nose.length_m * self.nose.shape.cp_fraction()
804    }
805
806    fn base_cd(mach: f64) -> f64 {
807        if mach <= 1.0 {
808            0.12 + 0.13 * mach * mach
809        } else {
810            0.25 / mach
811        }
812    }
813
814    fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
815        let length = self.total_length_m();
816        let velocity = mach * sound_mps;
817        let reynolds = velocity * length / 1.5e-5;
818        let rough_ratio = self.roughness_m / length;
819        let cf_turbulent = if reynolds < 1.0e4 {
820            1.48e-2
821        } else {
822            1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
823        };
824        let cf_rough = 0.032 * rough_ratio.powf(0.2);
825        // Fully turbulent: take the larger of the smooth and roughness-limited
826        // coefficients, then the subsonic compressibility correction.
827        let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
828        let r = self.max_diameter_m() / 2.0;
829        let fineness = length / r;
830        let correction = 1.0 + 1.0 / (2.0 * fineness);
831        let boat_wet = match self.boat_tail {
832            Some(b) => {
833                let rf = b.fore_diameter_m / 2.0;
834                let ra = b.aft_diameter_m / 2.0;
835                let slant = (b.length_m.powi(2) + (rf - ra).powi(2)).sqrt();
836                std::f64::consts::PI * (rf + ra) * slant
837            }
838            None => 0.0,
839        };
840        let body_wet = self
841            .nose
842            .shape
843            .lateral_area(self.nose.length_m, self.nose.diameter_m)
844            + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m
845            + boat_wet;
846        let planform = self.planform();
847        let fin_wet = 2.0 * planform.count as f64 * planform.area_m2;
848        let thickness_factor = 1.0 + 2.0 * planform.thickness_m / planform.mac_m;
849        cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
850    }
851
852    /// Full analysis. Mach samples at 0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0,
853    /// 1.1, 1.3, 1.5, 2.0 and 3.0, computed across the transonic and
854    /// supersonic regimes (no clamp).
855    pub fn analyze(&self) -> AeroAnalysis {
856        self.validate();
857        let machs: [f64; 12] = [0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0, 1.1, 1.3, 1.5, 2.0, 3.0];
858        let mut cd_vs_mach = Vec::new();
859        let mut cna_vs_mach = Vec::new();
860        let mut cp_vs_mach = Vec::new();
861        let mut induced_k_vs_mach = Vec::new();
862        for &m in &machs {
863            cd_vs_mach.push((
864                m,
865                Self::base_cd(m) * self.base_area_fraction()
866                    + self.nose_pressure_cd(m)
867                    + self.fin_pressure_cd(m)
868                    + self.boat_pressure_cd(m)
869                    + self.friction_cd(m, 340.0),
870            ));
871            let fin_cna = self.fin_set_cna(m);
872            let total_cna = 2.0 + fin_cna + self.boat_cna();
873            cna_vs_mach.push((m, total_cna));
874            cp_vs_mach.push((m, self.total_cp(m)));
875            // Induced drag from the normal force's tilt onto the flight
876            // path: Cd_induced = Cnα·α², so k = Cnα (OpenRocket's model).
877            induced_k_vs_mach.push((m, total_cna));
878        }
879        AeroAnalysis {
880            cd_vs_mach,
881            cna_vs_mach,
882            cp_vs_mach,
883            induced_k_vs_mach,
884            ref_area_m2: self.ref_area_m2(),
885            total_length_m: self.total_length_m(),
886            max_diameter_m: self.max_diameter_m(),
887        }
888    }
889
890    fn validate(&self) {
891        assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
892        assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
893        let planform = self.planform();
894        assert!(planform.count > 0);
895        assert!(planform.span_m > 0.0);
896        assert!(planform.root_chord_m > 0.0 && planform.thickness_m > 0.0);
897    }
898}
899
900/// Parse the compact geometry spec used by the CLI:
901/// `nose=shape:length:diameter,tube=length:diameter,fins=count:root:tip:span:sweep:thickness`
902/// (or `efins=count:root:span:thickness` for elliptical fins), with an
903/// optional `boat=length:fore_diameter:aft_diameter` conical boat-tail at
904/// the aft end. The fin thickness is optional (default 2 mm). Lengths are
905/// in metres.
906pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
907    let mut nose = None;
908    let mut tube = None;
909    let mut fins: Option<FinSet> = None;
910    let mut boat_tail = None;
911    for item in spec.split(',') {
912        let (key, val) = item
913            .split_once('=')
914            .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
915        let parts: Vec<&str> = val.split(':').collect();
916        let number = |i: usize| -> Result<f64> {
917            parts
918                .get(i)
919                .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
920                .parse()
921                .map_err(|_| Error::invalid("geometry", "non-numeric field"))
922        };
923        match key {
924            "nose" => {
925                if parts.len() != 3 {
926                    return Err(Error::invalid("nose", "expected shape:length:diameter"));
927                }
928                let shape = parts[0].parse::<NoseShape>()?;
929                nose = Some(NoseCone {
930                    shape,
931                    length_m: number(1)?,
932                    diameter_m: number(2)?,
933                });
934            }
935            "tube" => {
936                if parts.len() != 2 {
937                    return Err(Error::invalid("tube", "expected length:diameter"));
938                }
939                tube = Some(BodyTube {
940                    length_m: number(0)?,
941                    diameter_m: number(1)?,
942                });
943            }
944            "fins" => {
945                if !(5..=6).contains(&parts.len()) {
946                    return Err(Error::invalid(
947                        "fins",
948                        "expected count:root:tip:span:sweep[:thickness]",
949                    ));
950                }
951                let count: u32 = parts[0]
952                    .parse()
953                    .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
954                fins = Some(
955                    TrapezoidalFins {
956                        count,
957                        root_chord_m: number(1)?,
958                        tip_chord_m: number(2)?,
959                        span_m: number(3)?,
960                        leading_sweep_m: number(4)?,
961                        thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
962                    }
963                    .into(),
964                );
965            }
966            "efins" => {
967                if !(3..=4).contains(&parts.len()) {
968                    return Err(Error::invalid(
969                        "efins",
970                        "expected count:root:span[:thickness]",
971                    ));
972                }
973                let count: u32 = parts[0]
974                    .parse()
975                    .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
976                fins = Some(
977                    EllipticalFins {
978                        count,
979                        root_chord_m: number(1)?,
980                        span_m: number(2)?,
981                        thickness_m: if parts.len() == 4 { number(3)? } else { 0.002 },
982                    }
983                    .into(),
984                );
985            }
986            "boat" => {
987                if parts.len() != 3 {
988                    return Err(Error::invalid(
989                        "boat",
990                        "expected boat=length:fore_diameter:aft_diameter",
991                    ));
992                }
993                boat_tail = Some(BoatTail {
994                    length_m: number(0)?,
995                    fore_diameter_m: number(1)?,
996                    aft_diameter_m: number(2)?,
997                });
998            }
999            other => {
1000                return Err(Error::invalid(
1001                    "geometry key",
1002                    match other {
1003                        "nose" | "tube" | "fins" => unreachable!(),
1004                        _ => "must be nose, tube, fins, efins or boat",
1005                    },
1006                ))
1007            }
1008        }
1009    }
1010    let geometry = RocketGeometry::new(
1011        nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
1012        tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
1013        fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
1014    );
1015    match boat_tail {
1016        Some(boat) => geometry.with_boat_tail(boat),
1017        None => Ok(geometry),
1018    }
1019}
1020
1021/// Dry-mass breakdown estimated from geometry and component masses by
1022/// [`RocketGeometry::mass_properties`].
1023#[derive(Debug, Clone, Copy)]
1024pub struct MassProperties {
1025    /// Sum of the component masses (structure plus motor casing).
1026    pub dry_mass_kg: f64,
1027    /// Dry centre of gravity, metres aft of the nose tip.
1028    pub dry_cg_m: f64,
1029    /// Pitch inertia about the dry CG, kg·m².
1030    pub pitch_inertia_kg_m2: f64,
1031}
1032
1033/// The curves and reference quantities produced by [`RocketGeometry::analyze`].
1034#[derive(Debug, Clone)]
1035pub struct AeroAnalysis {
1036    /// Axial drag coefficient vs Mach (subsonic, clamped above Mach 1).
1037    pub cd_vs_mach: Vec<(f64, f64)>,
1038    /// Normal-force coefficient slope per radian vs Mach (nose + fin set).
1039    pub cna_vs_mach: Vec<(f64, f64)>,
1040    /// Centre of pressure, metres aft of the nose tip, vs Mach.
1041    pub cp_vs_mach: Vec<(f64, f64)>,
1042    /// Induced-drag factor per radian² vs Mach (normal-force tilt: k = Cnα).
1043    pub induced_k_vs_mach: Vec<(f64, f64)>,
1044    /// Reference area, π/4·d² of the largest diameter.
1045    pub ref_area_m2: f64,
1046    /// Total aerodynamic length (nose + tube).
1047    pub total_length_m: f64,
1048    /// Largest body diameter.
1049    pub max_diameter_m: f64,
1050}
1051
1052#[cfg(feature = "ballistics")]
1053impl AeroAnalysis {
1054    /// Bundle the analysis with the remaining airframe parameters.
1055    pub fn airframe(
1056        &self,
1057        cg_dry_m: f64,
1058        cg_prop_m: f64,
1059        pitch_inertia_kg_m2: f64,
1060        recovery_cd_area_m2: f64,
1061    ) -> crate::Airframe {
1062        crate::Airframe {
1063            diameter_m: self.max_diameter_m,
1064            cd_vs_mach: self.cd_vs_mach.clone(),
1065            cna_vs_mach: self.cna_vs_mach.clone(),
1066            cp_vs_mach: self.cp_vs_mach.clone(),
1067            cg_dry_m,
1068            cg_prop_m,
1069            pitch_inertia_kg_m2,
1070            induced_k_vs_mach: self.induced_k_vs_mach.clone(),
1071            recovery_cd_area_m2,
1072        }
1073    }
1074
1075    /// Drag + atmosphere + body lift for this geometry in the given wind.
1076    pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
1077        crate::ballistics::BallisticForces::new(
1078            self.max_diameter_m,
1079            self.cd_vs_mach.clone(),
1080            crate::ballistics::Atmosphere::default(),
1081            wind_mps,
1082        )?
1083        .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
1084    }
1085
1086    /// Weathercock parameters for this geometry.
1087    pub fn weathercock_params(
1088        &self,
1089        cg_dry_m: f64,
1090        cg_prop_m: f64,
1091        pitch_inertia_kg_m2: f64,
1092    ) -> Result<crate::WeathercockParams> {
1093        crate::WeathercockParams::new(
1094            self.cna_vs_mach.clone(),
1095            self.cp_vs_mach.clone(),
1096            cg_dry_m,
1097            cg_prop_m,
1098            self.max_diameter_m,
1099            pitch_inertia_kg_m2,
1100            0.2,
1101        )
1102    }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108
1109    fn example() -> RocketGeometry {
1110        RocketGeometry::new(
1111            NoseCone {
1112                shape: NoseShape::Conical,
1113                length_m: 0.1,
1114                diameter_m: 0.024,
1115            },
1116            BodyTube {
1117                length_m: 0.4,
1118                diameter_m: 0.024,
1119            },
1120            TrapezoidalFins {
1121                count: 3,
1122                root_chord_m: 0.05,
1123                tip_chord_m: 0.03,
1124                span_m: 0.04,
1125                leading_sweep_m: 0.02,
1126                thickness_m: 0.002,
1127            },
1128        )
1129    }
1130
1131    #[test]
1132    fn nose_cp_fractions_match_barrowman() {
1133        assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
1134        assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
1135        assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
1136        assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
1137    }
1138
1139    #[test]
1140    fn conical_pressure_drag_uses_half_angle() {
1141        // The M->0 limit of the wave-drag model is Barrowman's 0.8·sin²(φ).
1142        let geo = example(); // conical nose, L = 0.1, R = 0.012
1143        let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
1144        assert!((geo.nose_pressure_cd(0.0) - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
1145    }
1146
1147    #[test]
1148    fn nose_wave_drag_matches_openrocket_formula() {
1149        let geo = example(); // conical, L = 0.1, R = 0.012
1150        let r = 0.012_f64;
1151        let sinphi = r / (r * r + 0.1 * 0.1).sqrt();
1152        // Supersonic direct formula: 2.1·sin²φ + 0.5·sinφ/β.
1153        let m = 1.5_f64;
1154        let expected = 2.1 * sinphi * sinphi + 0.5 * sinphi / (m * m - 1.0).sqrt();
1155        assert!(
1156            (geo.nose_pressure_cd(m) - expected).abs() < 1e-12,
1157            "{} vs {expected}",
1158            geo.nose_pressure_cd(m)
1159        );
1160        // Monotone rise through the transonic region, continuous at M = 1.
1161        let below = geo.nose_pressure_cd(0.999);
1162        let at = geo.nose_pressure_cd(1.0);
1163        assert!((at - sinphi).abs() < 0.02, "at {at}");
1164        assert!(below < at && below > 0.8 * sinphi * sinphi);
1165    }
1166
1167    #[test]
1168    fn fin_cp_matches_the_trapezoid_formula() {
1169        let geo = example();
1170        // Independently: MAC = (2/3)(cr+ct - cr*ct/(cr+ct)), yMAC = s(cr+2ct)/(3(cr+ct)).
1171        let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
1172        let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
1173        let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
1174        assert!(
1175            (geo.fin_cp(0.0) - expected).abs() < 1e-12,
1176            "{}",
1177            geo.fin_cp(0.0)
1178        );
1179    }
1180
1181    #[test]
1182    fn boat_tail_physics() {
1183        let base = example();
1184        let with_boat = base
1185            .with_boat_tail(BoatTail {
1186                length_m: 0.06,
1187                fore_diameter_m: 0.024,
1188                aft_diameter_m: 0.016,
1189            })
1190            .unwrap();
1191        // Cnα drops by 2·(1 − (2/3)²) = 10/9 ≈ 1.111.
1192        let delta = base.fin_set_cna(0.0) + 2.0 - (with_boat.fin_set_cna(0.0) + 2.0);
1193        let expected = 2.0 * (1.0 - (0.016_f64 / 0.024).powi(2));
1194        assert!((with_boat.boat_cna() + expected).abs() < 1e-12);
1195        assert!((delta - 0.0).abs() < 1e-12 || delta > 0.0); // fins unchanged
1196        let cna_base = base.analyze().cna_vs_mach[0].1;
1197        let cna_boat = with_boat.analyze().cna_vs_mach[0].1;
1198        assert!(
1199            (cna_base - cna_boat - expected).abs() < 1e-9,
1200            "{cna_base} vs {cna_boat}"
1201        );
1202        // CP: frustum centroid at nose+tube + L·(rf+3ra)/(4(rf+ra)).
1203        let cp = with_boat.boat_cp_abs();
1204        let want = 0.1 + 0.4 + 0.06 * (0.012 + 3.0 * 0.008) / (4.0 * (0.012 + 0.008));
1205        assert!((cp - want).abs() < 1e-12, "{cp} vs {want}");
1206        // Pressure drag: fineness = 0.06/0.008 = 7.5 ≥ 3 → zero; short tail pays.
1207        assert_eq!(with_boat.boat_pressure_cd(0.5), 0.0);
1208        let stubby = base
1209            .with_boat_tail(BoatTail {
1210                length_m: 0.012,
1211                fore_diameter_m: 0.024,
1212                aft_diameter_m: 0.016,
1213            })
1214            .unwrap();
1215        assert!(stubby.boat_pressure_cd(0.5) > 0.0);
1216        // Base area shrinks to (aft/fore)².
1217        assert!((with_boat.base_area_fraction() - (0.016_f64 / 0.024).powi(2)).abs() < 1e-12);
1218        // Total length includes the boat-tail; fins stay at the tube end.
1219        assert!((with_boat.total_length_m() - 0.56).abs() < 1e-12);
1220        assert!((with_boat.fin_cp_abs(0.0) - base.fin_cp_abs(0.0)).abs() < 1e-12);
1221        // Validation.
1222        assert!(base
1223            .with_boat_tail(BoatTail {
1224                length_m: 0.06,
1225                fore_diameter_m: 0.03,
1226                aft_diameter_m: 0.016,
1227            })
1228            .is_err());
1229        assert!(base
1230            .with_boat_tail(BoatTail {
1231                length_m: 0.06,
1232                fore_diameter_m: 0.024,
1233                aft_diameter_m: 0.03,
1234            })
1235            .is_err());
1236        // Parse end to end.
1237        let parsed = parse_geometry(
1238            "nose=conical:0.1:0.024,tube=0.4:0.024,fins=3:0.05:0.03:0.04:0.02:0.002,boat=0.06:0.024:0.016",
1239        )
1240        .unwrap();
1241        assert!(parsed.boat_tail.is_some());
1242        assert!(parse_geometry(
1243            "nose=conical:0.1:0.024,tube=0.4:0.024,fins=3:0.05:0.03:0.04:0.02:0.002,boat=0.06:0.024:0.03",
1244        )
1245        .is_err());
1246        // Mass model: the boat shell joins the component sum.
1247        let props = with_boat.mass_properties_with_boat(0.012, 0.020, 0.006, Some(0.004), None);
1248        assert!((props.dry_mass_kg - 0.042).abs() < 1e-12);
1249        assert!(props.pitch_inertia_kg_m2 > 0.0);
1250    }
1251
1252    #[test]
1253    fn elliptical_and_parabolic_noses_use_the_nasa_tables() {
1254        let elliptical = RocketGeometry::new(
1255            NoseCone {
1256                shape: NoseShape::Elliptical,
1257                length_m: 0.072,
1258                diameter_m: 0.024,
1259            },
1260            example().tube,
1261            example().fins,
1262        );
1263        // Fineness 3 reproduces the table value at Mach 1.4 (0.148).
1264        assert!(
1265            (elliptical.nose_pressure_cd(1.4) - 0.148).abs() < 0.005,
1266            "{}",
1267            elliptical.nose_pressure_cd(1.4)
1268        );
1269        // Subsonic is ~zero (the 99% surface angle is tiny).
1270        assert!(elliptical.nose_pressure_cd(0.5) < 0.02);
1271        let parabolic = RocketGeometry::new(
1272            NoseCone {
1273                shape: NoseShape::Parabolic,
1274                length_m: 0.072,
1275                diameter_m: 0.024,
1276            },
1277            example().tube,
1278            example().fins,
1279        );
1280        // Parabolic table is zero below 0.95 and 0.092 at 1.05.
1281        assert!(parabolic.nose_pressure_cd(0.8) < 1e-4);
1282        assert!(
1283            (parabolic.nose_pressure_cd(1.05) - 0.092).abs() < 0.005,
1284            "{}",
1285            parabolic.nose_pressure_cd(1.05)
1286        );
1287        // Stagnation Cd is continuous across Mach 1.
1288        let lo = RocketGeometry::stagnation_cd(0.999);
1289        let hi = RocketGeometry::stagnation_cd(1.001);
1290        assert!((lo - hi).abs() < 0.01, "{lo} vs {hi}");
1291    }
1292
1293    #[test]
1294    fn fin_cp_moves_aft_supersonic() {
1295        let geo = example();
1296        assert_eq!(geo.fin_cp_fraction(0.0), 0.25);
1297        assert_eq!(geo.fin_cp_fraction(0.5), 0.25);
1298        // Supersonic empirical formula: (AR·β − 0.67)/(2·AR·β − 1).
1299        let ar = 2.0 * 0.04_f64.powi(2) / geo.fin_area();
1300        let beta = (4.0_f64 - 1.0).sqrt();
1301        let expected = (ar * beta - 0.67) / (2.0 * ar * beta - 1.0);
1302        assert!((geo.fin_cp_fraction(2.0) - expected).abs() < 1e-12);
1303        assert!(geo.fin_cp_fraction(1.0) > 0.25 && geo.fin_cp_fraction(1.0) < expected);
1304    }
1305
1306    #[test]
1307    fn conical_nose_volume_properties_match_the_solid_cone() {
1308        let geo = example(); // conical nose, L = 0.1, R = 0.012
1309        let (cg, inertia) = geo.nose_volume_properties();
1310        assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
1311        let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
1312        assert!(
1313            (inertia - expected).abs() / expected < 1e-4,
1314            "inertia {inertia} expected {expected}"
1315        );
1316    }
1317
1318    #[test]
1319    fn fin_centroid_matches_polygon_limits() {
1320        let geo = example();
1321        // Rectangle limit (m = 0, ct = cr): centroid at cr/2, height s/2.
1322        let rectangular = RocketGeometry::new(
1323            geo.nose,
1324            geo.tube,
1325            TrapezoidalFins {
1326                count: 3,
1327                root_chord_m: 0.05,
1328                tip_chord_m: 0.05,
1329                span_m: 0.04,
1330                leading_sweep_m: 0.0,
1331                thickness_m: 0.002,
1332            },
1333        );
1334        assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
1335        assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
1336        // Triangle limit (ct = 0): (cr + m)/3 from the leading edge, s/3 up.
1337        let triangular = RocketGeometry::new(
1338            geo.nose,
1339            geo.tube,
1340            TrapezoidalFins {
1341                count: 3,
1342                root_chord_m: 0.06,
1343                tip_chord_m: 0.0,
1344                span_m: 0.03,
1345                leading_sweep_m: 0.03,
1346                thickness_m: 0.002,
1347            },
1348        );
1349        assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
1350        assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
1351    }
1352
1353    #[test]
1354    fn mass_properties_are_consistent() {
1355        let geo = example();
1356        let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1357        assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
1358        // CG is the mass-weighted component mean, inside the airframe.
1359        let (nose_cg, _) = geo.nose_volume_properties();
1360        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1361        let fin_cg = geo.total_length_m() - geo.planform().root_chord_m + geo.fin_planform_cg();
1362        let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
1363        assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
1364        assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
1365        assert!(props.pitch_inertia_kg_m2 > 0.0);
1366        // A casing at the aft end moves the CG aft and adds inertia.
1367        let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
1368        assert!(with_casing.dry_cg_m > props.dry_cg_m);
1369        assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
1370        assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
1371        // A heavier nose moves the CG forward.
1372        let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
1373        assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
1374    }
1375
1376    #[test]
1377    fn induced_k_tracks_cna() {
1378        // Tilt model: the induced-drag factor equals the normal-force slope.
1379        let geo = example();
1380        let analysis = geo.analyze();
1381        for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
1382            assert_eq!(k, cna, "mach {m}");
1383        }
1384    }
1385
1386    #[test]
1387    fn mass_properties_single_component_is_exact() {
1388        let geo = example();
1389        let props = geo.mass_properties(0.0, 0.05, 0.0, None);
1390        // Only the tube: CG at its midpoint, inertia its thin-cylinder value.
1391        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1392        assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
1393        let expected =
1394            0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
1395        assert!(
1396            (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
1397            "{} vs {expected}",
1398            props.pitch_inertia_kg_m2
1399        );
1400    }
1401
1402    #[test]
1403    fn elliptical_fins_match_the_half_ellipse_geometry() {
1404        let geo = RocketGeometry::new(
1405            example().nose,
1406            example().tube,
1407            EllipticalFins {
1408                count: 3,
1409                root_chord_m: 0.05,
1410                span_m: 0.05,
1411                thickness_m: 0.0032,
1412            },
1413        );
1414        let p = geo.planform();
1415        // Half-ellipse: area π·cr·s/4, unswept midchord, centroid of a
1416        // semi-ellipse at (cr/2, 4s/(3π)).
1417        let area = std::f64::consts::PI * 0.05 * 0.05 / 4.0;
1418        // The 48-strip sum (OpenRocket's own discretization) overestimates
1419        // the convex arc by ~1.2%.
1420        assert!(
1421            (p.area_m2 - area).abs() / area < 0.02,
1422            "{} vs {area}",
1423            p.area_m2
1424        );
1425        assert!(p.cos_midchord > 0.99, "cos_midchord {}", p.cos_midchord);
1426        assert!((p.cg_x_m - 0.025).abs() < 1e-12);
1427        assert!((p.cg_y_m - 4.0 * 0.05 / (3.0 * std::f64::consts::PI)).abs() < 1e-12);
1428        // MAC of a semi-ellipse: 8·cr/(3π).
1429        let mac = 8.0 * 0.05 / (3.0 * std::f64::consts::PI);
1430        assert!((p.mac_m - mac).abs() / mac < 0.02, "{} vs {mac}", p.mac_m);
1431        // Cnα is positive and CP sits inside the airframe.
1432        let analysis = geo.analyze();
1433        assert!(analysis.cna_vs_mach[0].1 > 2.0);
1434        assert!(analysis.cp_vs_mach[0].1 > 0.0 && analysis.cp_vs_mach[0].1 < geo.total_length_m());
1435        // Parse end to end.
1436        let parsed =
1437            parse_geometry("nose=conical:0.1:0.024,tube=0.4:0.024,efins=3:0.05:0.05:0.002")
1438                .unwrap();
1439        assert!(matches!(parsed.fins, FinSet::Elliptical(_)));
1440        // Mass model uses the elliptical centroid.
1441        let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1442        assert!(props.pitch_inertia_kg_m2 > 0.0);
1443    }
1444
1445    #[test]
1446    fn fin_set_cna_matches_diederich() {
1447        let geo = example();
1448        // Fin pair: 2π·span²/(1+sqrt(1+(span²/(A·cosΓ))²)) / Aref, then
1449        // (N/2)·(1+tau) — the Diederich value is a wing (two fins).
1450        let area = geo.fin_area();
1451        let cos = geo.midchord_cos();
1452        let span = geo.planform().span_m;
1453        let ratio = span * span / (area * cos);
1454        let one = 2.0 * std::f64::consts::PI * span * span
1455            / (1.0 + (1.0 + ratio * ratio).sqrt())
1456            / geo.ref_area_m2();
1457        let expected = 1.5 * one * (1.0 + geo.interference_tau());
1458        let actual = geo.fin_set_cna(0.0);
1459        assert!(
1460            (actual - expected).abs() / expected < 1e-9,
1461            "actual {actual} expected {expected}"
1462        );
1463        // And the classic closed form 4N(s/d)²/(1+sqrt(1+(2l/(cr+ct))²))·K.
1464        let d = geo.max_diameter_m();
1465        let classic = 4.0 * 3.0 * (geo.planform().span_m / d).powi(2)
1466            / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
1467            * (1.0 + geo.interference_tau());
1468        assert!(
1469            (actual - classic).abs() / classic < 0.02,
1470            "actual {actual} classic {classic}"
1471        );
1472    }
1473
1474    #[test]
1475    fn total_cp_is_the_moment_sum() {
1476        let geo = example();
1477        let fin_cna = geo.fin_set_cna(0.0);
1478        let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs(0.0)) / (2.0 + fin_cna);
1479        assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
1480        // CP must sit aft of the nose CP (fins pull it back).
1481        assert!(geo.total_cp(0.0) > geo.nose_cp());
1482    }
1483
1484    #[test]
1485    fn base_cd_matches_openrocket() {
1486        assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
1487        assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
1488        assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
1489    }
1490
1491    #[test]
1492    fn analysis_produces_finite_curves_and_roughness_costs_drag() {
1493        let geo = example();
1494        let analysis = geo.analyze();
1495        for &(m, cd) in &analysis.cd_vs_mach {
1496            assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
1497        }
1498        for &(m, cp) in &analysis.cp_vs_mach {
1499            assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
1500        }
1501        let smooth = geo.with_roughness(1e-6).analyze();
1502        let rough = geo.with_roughness(500e-6).analyze();
1503        assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
1504    }
1505}