Skip to main content

rockets_engine/
geometry.rs

1//! Geometry-to-aerodynamics: a Barrowman subset for slender model rockets
2//! (nose cone + body tube + trapezoidal fins), implemented against the
3//! OpenRocket technical documentation and its `FinSetCalc` and
4//! `SymmetricComponentCalc` sources. CP comes from the component moment sum
5//! (with the supersonic aft shift); drag is friction + base + nose wave drag
6//! + 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α·α²). Not covered (deliberately): boat-tails/transitions,
10//! elliptical or free-form fins, fin-body interference drag, and wave drag
11//! of elliptical/parabolic noses past Mach 1 (conical and ogive are fully
12//! modelled).
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#[derive(Debug, Clone, Copy)]
109pub struct RocketGeometry {
110    /// Nose cone.
111    pub nose: NoseCone,
112    /// Cylindrical body tube.
113    pub tube: BodyTube,
114    /// Aft trapezoidal fin set.
115    pub fins: TrapezoidalFins,
116    /// Roughness height in metres (default 20 µm, painted surface).
117    pub roughness_m: f64,
118}
119
120impl RocketGeometry {
121    /// Build with the default painted-surface roughness (20 µm).
122    pub fn new(nose: NoseCone, tube: BodyTube, fins: TrapezoidalFins) -> Self {
123        Self {
124            nose,
125            tube,
126            fins,
127            roughness_m: 20e-6,
128        }
129    }
130
131    /// Set the roughness height in metres.
132    pub fn with_roughness(mut self, roughness_m: f64) -> Self {
133        self.roughness_m = roughness_m;
134        self
135    }
136
137    /// Total aerodynamic length (nose + tube).
138    pub fn total_length_m(&self) -> f64 {
139        self.nose.length_m + self.tube.length_m
140    }
141
142    /// Largest body diameter (nose base or tube).
143    pub fn max_diameter_m(&self) -> f64 {
144        self.nose.diameter_m.max(self.tube.diameter_m)
145    }
146
147    /// Reference area, π/4·d² of the largest diameter.
148    pub fn ref_area_m2(&self) -> f64 {
149        std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
150    }
151
152    fn fin_area(&self) -> f64 {
153        self.fins.span_m * (self.fins.root_chord_m + self.fins.tip_chord_m) / 2.0
154    }
155
156    /// Mean aerodynamic chord of one fin (trapezoid formula).
157    fn mac(&self) -> f64 {
158        let (cr, ct) = (self.fins.root_chord_m, self.fins.tip_chord_m);
159        (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct))
160    }
161
162    /// Fin CP as a fraction of the MAC: quarter-chord at M ≤ 0.5, the
163    /// empirical supersonic formula at M ≥ 2, a linear join between
164    /// (OpenRocket's FinSetCalc.calculateCPPos; it fits a polynomial, but
165    /// the join error is small beside the component moment sum).
166    fn fin_cp_fraction(&self, mach: f64) -> f64 {
167        if mach <= 0.5 {
168            return 0.25;
169        }
170        let ar = 2.0 * self.fins.span_m.powi(2) / self.fin_area();
171        let supersonic = |m: f64| {
172            let beta = (m * m - 1.0).sqrt();
173            (ar * beta - 0.67) / (2.0 * ar * beta - 1.0)
174        };
175        if mach >= 2.0 {
176            return supersonic(mach);
177        }
178        let hi = supersonic(2.0);
179        0.25 + (hi - 0.25) * (mach - 0.5) / 1.5
180    }
181
182    /// Fin CP measured from the fin's root leading edge.
183    fn fin_cp(&self, mach: f64) -> f64 {
184        let (cr, ct, s, m) = (
185            self.fins.root_chord_m,
186            self.fins.tip_chord_m,
187            self.fins.span_m,
188            self.fins.leading_sweep_m,
189        );
190        let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
191        let x_mac_le = m * y_mac / s;
192        x_mac_le + self.fin_cp_fraction(mach) * self.mac()
193    }
194
195    /// Cosine of the leading-edge sweep angle.
196    fn leading_edge_cos(&self) -> f64 {
197        let (s, m) = (self.fins.span_m, self.fins.leading_sweep_m);
198        s / (s * s + m * m).sqrt()
199    }
200
201    /// Fin pressure (form) drag: OpenRocket's rounded-section model —
202    /// a Mach-dependent leading-edge term times cos²(Γ_LE), plus half the
203    /// base drag for the trailing edge, scaled by span·thickness / A_ref.
204    fn fin_pressure_cd(&self, mach: f64) -> f64 {
205        let span = self.fins.span_m;
206        let thickness = self.fins.thickness_m;
207        if span <= 0.0 || thickness <= 0.0 {
208            return 0.0;
209        }
210        let leading_edge = if mach < 0.9 {
211            (1.0 - mach * mach).powf(-0.417) - 1.0
212        } else if mach < 1.0 {
213            1.0 - 1.785 * (mach - 0.9)
214        } else {
215            let m2 = mach * mach;
216            1.214 - 0.502 / (m2 * m2) + 0.1095 / (m2 * m2 * m2 * m2)
217        };
218        let cos_le = self.leading_edge_cos();
219        let cd = leading_edge * cos_le * cos_le + Self::base_cd(mach) / 2.0;
220        cd * span * thickness / self.ref_area_m2()
221    }
222
223    /// Nose pressure drag per frontal area for conical and tangent-ogive
224    /// noses: OpenRocket's direct calculation (polynomial through M 1–1.3,
225    /// 2.1·sin²φ + 0.5·sinφ/β above, a·M^b + 0.8·sin²φ fitted subsonic).
226    /// Elliptical and parabolic noses are not modelled past Mach 1 yet and
227    /// return 0 there (their subsonic value is 0 in this subset).
228    fn nose_pressure_cd(&self, mach: f64) -> f64 {
229        let sinphi = match self.nose.shape {
230            NoseShape::Conical | NoseShape::Ogive => {
231                let r = self.nose.diameter_m / 2.0;
232                r / (r * r + self.nose.length_m * self.nose.length_m).sqrt()
233            }
234            _ => return 0.0,
235        };
236        const GAMMA: f64 = 1.4;
237        let cd_mach0 = 0.8 * sinphi * sinphi;
238        let y1 = 2.1 * sinphi * sinphi + 0.6019 * sinphi;
239        let d0 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * sinphi);
240        let d1 = -1.1341 * sinphi;
241        if mach >= 1.3 {
242            return 2.1 * sinphi * sinphi + 0.5 * sinphi / (mach * mach - 1.0).sqrt();
243        }
244        if mach >= 1.0 {
245            return Self::hermite(mach, 1.0, 1.3, sinphi, y1, d0, d1);
246        }
247        // Subsonic: Cd = a·M^b + Cd(0), fitted to reach (1, sinphi) with the
248        // polynomial's derivative (OpenRocket's interpolation scheme).
249        let eps = 0.01;
250        let at_eps = Self::hermite(1.0 + eps, 1.0, 1.3, sinphi, y1, d0, d1);
251        let min_deriv = (at_eps - sinphi) / eps;
252        let gap = sinphi - cd_mach0;
253        if gap <= 0.01 || min_deriv <= 0.01 {
254            return cd_mach0;
255        }
256        let b = min_deriv / gap;
257        gap * mach.powf(b) + cd_mach0
258    }
259
260    fn midchord_cos(&self) -> f64 {
261        let (cr, ct, s, m) = (
262            self.fins.root_chord_m,
263            self.fins.tip_chord_m,
264            self.fins.span_m,
265            self.fins.leading_sweep_m,
266        );
267        let gamma = ((m + ct / 2.0 - cr / 2.0) / s).atan();
268        gamma.cos()
269    }
270
271    /// Cubic Hermite interpolation on [x0, x1] with endpoint values y0, y1
272    /// and endpoint derivatives d0, d1 per unit x.
273    fn hermite(x: f64, x0: f64, x1: f64, y0: f64, y1: f64, d0: f64, d1: f64) -> f64 {
274        let h = x1 - x0;
275        let t = (x - x0) / h;
276        let t2 = t * t;
277        let t3 = t2 * t;
278        (2.0 * t3 - 3.0 * t2 + 1.0) * y0
279            + (t3 - 2.0 * t2 + t) * h * d0
280            + (-2.0 * t3 + 3.0 * t2) * y1
281            + (t3 - t2) * h * d1
282    }
283
284    /// Mach bounds of OpenRocket's transonic interpolation.
285    const CNA_SUBSONIC: f64 = 0.9;
286    const CNA_SUPERSONIC: f64 = 1.5;
287
288    /// Normal-force slope of one fin, divided by the reference area.
289    /// Subsonic: OpenRocket's Diederich simplification. Supersonic:
290    /// 2·A_fin/(β·A_ref) (the K1 term of the Busemann expansion; the α
291    /// terms vanish for a slope table). Transonic: OpenRocket's cubic
292    /// Hermite join with matched endpoint derivatives.
293    fn fin_cna1(&self, mach: f64) -> f64 {
294        let span = self.fins.span_m;
295        let area = self.fin_area();
296        let cos = self.midchord_cos();
297        if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
298            return 0.0;
299        }
300        let ref_area = self.ref_area_m2();
301        let subsonic = |m: f64| {
302            let ratio = (span * span) / (area * cos);
303            2.0 * std::f64::consts::PI * span * span
304                / (1.0 + (1.0 + (1.0 - m * m) * ratio * ratio).sqrt())
305                / ref_area
306        };
307        let supersonic = |m: f64| 2.0 * area / (m * m - 1.0).sqrt() / ref_area;
308        if mach <= Self::CNA_SUBSONIC {
309            return subsonic(mach);
310        }
311        if mach >= Self::CNA_SUPERSONIC {
312            return supersonic(mach);
313        }
314        let sq = (1.0
315            + (1.0 - Self::CNA_SUBSONIC * Self::CNA_SUBSONIC)
316                * (span * span / (area * cos)).powi(2))
317        .sqrt();
318        let sub_v = 2.0 * std::f64::consts::PI * span * span / ref_area / (1.0 + sq);
319        let sub_d = 2.0 * Self::CNA_SUBSONIC * std::f64::consts::PI * span.powi(6)
320            / ((area * cos).powi(2) * ref_area * sq * (1.0 + sq).powi(2));
321        let super_v = supersonic(Self::CNA_SUPERSONIC);
322        let beta_b = (Self::CNA_SUPERSONIC * Self::CNA_SUPERSONIC - 1.0).powf(1.5);
323        let super_d = -area / ref_area * 2.0 * Self::CNA_SUPERSONIC / beta_b;
324        Self::hermite(
325            mach,
326            Self::CNA_SUBSONIC,
327            Self::CNA_SUPERSONIC,
328            sub_v,
329            super_v,
330            sub_d,
331            super_d,
332        )
333    }
334
335    /// Body-interference factor (1 + tau) with tau = r/(span + r).
336    fn interference_tau(&self) -> f64 {
337        let r = self.max_diameter_m() / 2.0;
338        r / (self.fins.span_m + r)
339    }
340
341    fn fin_count_factor(&self) -> f64 {
342        match self.fins.count {
343            5 => 0.948,
344            6 => 0.913,
345            7 => 0.854,
346            8 => 0.810,
347            n if n >= 9 => 0.75,
348            _ => 1.0,
349        }
350    }
351
352    /// Total fin-set normal-force slope for this Mach number.
353    ///
354    /// The Diederich `fin_cna1` is the slope of a fin *pair* (a wing of
355    /// semispan `span`), so a set of `n` fins contributes `n/2` pairs,
356    /// with fin-body carryover (1 + tau) and a many-fin interference
357    /// reduction. This matches OpenRocket's `FinSetCalc` convention,
358    /// verified against its BarrowmanCalculatorTest Alpha III values.
359    fn fin_set_cna(&self, mach: f64) -> f64 {
360        self.fin_cna1(mach)
361            * (1.0 + self.interference_tau())
362            * self.fin_count_factor()
363            * (self.fins.count as f64 / 2.0)
364    }
365
366    /// Fin CP measured from the nose tip (fin trailing edge at body end).
367    fn fin_cp_abs(&self, mach: f64) -> f64 {
368        self.total_length_m() - self.fins.root_chord_m + self.fin_cp(mach)
369    }
370
371    /// Nose radius profile r(x) with x measured from the tip.
372    fn nose_radius(&self, x: f64) -> f64 {
373        let l = self.nose.length_m;
374        let r = self.nose.diameter_m / 2.0;
375        let t = (x / l).clamp(0.0, 1.0);
376        match self.nose.shape {
377            NoseShape::Conical => r * t,
378            NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
379            NoseShape::Parabolic => r * t * (2.0 - t),
380            NoseShape::Ogive => {
381                let rho = (r * r + l * l) / (2.0 * r);
382                (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
383            }
384        }
385    }
386
387    /// Solid-nose CG (from the tip) and pitch inertia per unit mass about
388    /// that CG, from numerical integration of the radius profile.
389    fn nose_volume_properties(&self) -> (f64, f64) {
390        const N: usize = 1024;
391        let l = self.nose.length_m;
392        let dx = l / N as f64;
393        let mut volume = 0.0; // ∫ r² dx (up to the density·π factor)
394        let mut moment = 0.0; // ∫ x r² dx
395        for i in 0..N {
396            let x = (i as f64 + 0.5) * dx;
397            let r2 = self.nose_radius(x).powi(2);
398            volume += r2 * dx;
399            moment += x * r2 * dx;
400        }
401        let cg = moment / volume;
402        let mut inertia = 0.0; // ∫ r² (r²/4 + (x−cg)²) dx
403        for i in 0..N {
404            let x = (i as f64 + 0.5) * dx;
405            let r2 = self.nose_radius(x).powi(2);
406            inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
407        }
408        (cg, inertia / volume)
409    }
410
411    /// Fin planform centroid, measured from the root leading edge.
412    fn fin_planform_cg(&self) -> f64 {
413        let (cr, ct, m) = (
414            self.fins.root_chord_m,
415            self.fins.tip_chord_m,
416            self.fins.leading_sweep_m,
417        );
418        (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct))
419    }
420
421    /// Fin centroid height above the body surface.
422    fn fin_radial_cg(&self) -> f64 {
423        let (cr, ct, s) = (
424            self.fins.root_chord_m,
425            self.fins.tip_chord_m,
426            self.fins.span_m,
427        );
428        s * (cr + 2.0 * ct) / (3.0 * (cr + ct))
429    }
430
431    /// Combine component masses with the geometry's component CGs into a
432    /// dry CG and a pitch-inertia estimate about that CG.
433    ///
434    /// The nose is a solid of revolution (numerically integrated), the
435    /// tube a thin-walled cylinder, and the fins and optional motor casing
436    /// point masses; evenly spaced fins add n/2 · m · (r_body + y_cg)² for
437    /// their radial offset. `casing` is (mass_kg, CG from the nose tip).
438    pub fn mass_properties(
439        &self,
440        nose_kg: f64,
441        tube_kg: f64,
442        fins_kg: f64,
443        casing: Option<(f64, f64)>,
444    ) -> MassProperties {
445        assert!(
446            nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
447            "component masses must be non-negative"
448        );
449        let (nose_cg, nose_inertia) = self.nose_volume_properties();
450        let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
451        let fin_cg = self.total_length_m() - self.fins.root_chord_m + self.fin_planform_cg();
452        let mut parts: Vec<(f64, f64, f64)> = vec![
453            (nose_kg, nose_cg, nose_kg * nose_inertia),
454            (
455                tube_kg,
456                tube_cg,
457                tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
458            ),
459            (fins_kg, fin_cg, 0.0),
460        ];
461        if let Some((kg, cg)) = casing {
462            parts.push((kg, cg, 0.0));
463        }
464        let total: f64 = parts.iter().map(|p| p.0).sum();
465        assert!(total > 0.0, "component masses must sum to a positive mass");
466        let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
467        let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
468        inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
469        MassProperties {
470            dry_mass_kg: total,
471            dry_cg_m: cg,
472            pitch_inertia_kg_m2: inertia,
473        }
474    }
475
476    /// Total CP from the component moment sum (nose + fin set).
477    fn total_cp(&self, mach: f64) -> f64 {
478        let nose_cna = 2.0;
479        let fin_cna = self.fin_set_cna(mach);
480        (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
481            + fin_cna * self.fin_cp_abs(mach))
482            / (nose_cna + fin_cna)
483    }
484
485    #[cfg(test)]
486    fn nose_cp(&self) -> f64 {
487        self.nose.length_m * self.nose.shape.cp_fraction()
488    }
489
490    fn base_cd(mach: f64) -> f64 {
491        if mach <= 1.0 {
492            0.12 + 0.13 * mach * mach
493        } else {
494            0.25 / mach
495        }
496    }
497
498    fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
499        let length = self.total_length_m();
500        let velocity = mach * sound_mps;
501        let reynolds = velocity * length / 1.5e-5;
502        let rough_ratio = self.roughness_m / length;
503        let cf_turbulent = if reynolds < 1.0e4 {
504            1.48e-2
505        } else {
506            1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
507        };
508        let cf_rough = 0.032 * rough_ratio.powf(0.2);
509        // Fully turbulent: take the larger of the smooth and roughness-limited
510        // coefficients, then the subsonic compressibility correction.
511        let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
512        let r = self.max_diameter_m() / 2.0;
513        let fineness = length / r;
514        let correction = 1.0 + 1.0 / (2.0 * fineness);
515        let body_wet = self
516            .nose
517            .shape
518            .lateral_area(self.nose.length_m, self.nose.diameter_m)
519            + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m;
520        let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
521        let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
522        cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
523    }
524
525    /// Full analysis. Mach samples at 0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0,
526    /// 1.1, 1.3, 1.5, 2.0 and 3.0, computed across the transonic and
527    /// supersonic regimes (no clamp).
528    pub fn analyze(&self) -> AeroAnalysis {
529        self.validate();
530        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];
531        let mut cd_vs_mach = Vec::new();
532        let mut cna_vs_mach = Vec::new();
533        let mut cp_vs_mach = Vec::new();
534        let mut induced_k_vs_mach = Vec::new();
535        for &m in &machs {
536            cd_vs_mach.push((
537                m,
538                Self::base_cd(m)
539                    + self.nose_pressure_cd(m)
540                    + self.fin_pressure_cd(m)
541                    + self.friction_cd(m, 340.0),
542            ));
543            let fin_cna = self.fin_set_cna(m);
544            cna_vs_mach.push((m, 2.0 + fin_cna));
545            cp_vs_mach.push((m, self.total_cp(m)));
546            // Induced drag from the normal force's tilt onto the flight
547            // path: Cd_induced = Cnα·α², so k = Cnα (OpenRocket's model).
548            induced_k_vs_mach.push((m, 2.0 + fin_cna));
549        }
550        AeroAnalysis {
551            cd_vs_mach,
552            cna_vs_mach,
553            cp_vs_mach,
554            induced_k_vs_mach,
555            ref_area_m2: self.ref_area_m2(),
556            total_length_m: self.total_length_m(),
557            max_diameter_m: self.max_diameter_m(),
558        }
559    }
560
561    fn validate(&self) {
562        assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
563        assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
564        assert!(self.fins.count > 0);
565        assert!(self.fins.span_m > 0.0);
566        assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
567        assert!(self.fins.thickness_m > 0.0);
568    }
569}
570
571/// Parse the compact geometry spec used by the CLI:
572/// `nose=shape:length:diameter,tube=length:diameter,fins=count:root:tip:span:sweep:thickness`.
573/// The fin thickness is optional (default 2 mm). Lengths are in metres.
574pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
575    let mut nose = None;
576    let mut tube = None;
577    let mut fins = None;
578    for item in spec.split(',') {
579        let (key, val) = item
580            .split_once('=')
581            .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
582        let parts: Vec<&str> = val.split(':').collect();
583        let number = |i: usize| -> Result<f64> {
584            parts
585                .get(i)
586                .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
587                .parse()
588                .map_err(|_| Error::invalid("geometry", "non-numeric field"))
589        };
590        match key {
591            "nose" => {
592                if parts.len() != 3 {
593                    return Err(Error::invalid("nose", "expected shape:length:diameter"));
594                }
595                let shape = parts[0].parse::<NoseShape>()?;
596                nose = Some(NoseCone {
597                    shape,
598                    length_m: number(1)?,
599                    diameter_m: number(2)?,
600                });
601            }
602            "tube" => {
603                if parts.len() != 2 {
604                    return Err(Error::invalid("tube", "expected length:diameter"));
605                }
606                tube = Some(BodyTube {
607                    length_m: number(0)?,
608                    diameter_m: number(1)?,
609                });
610            }
611            "fins" => {
612                if !(5..=6).contains(&parts.len()) {
613                    return Err(Error::invalid(
614                        "fins",
615                        "expected count:root:tip:span:sweep[:thickness]",
616                    ));
617                }
618                let count: u32 = parts[0]
619                    .parse()
620                    .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
621                fins = Some(TrapezoidalFins {
622                    count,
623                    root_chord_m: number(1)?,
624                    tip_chord_m: number(2)?,
625                    span_m: number(3)?,
626                    leading_sweep_m: number(4)?,
627                    thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
628                });
629            }
630            other => {
631                return Err(Error::invalid(
632                    "geometry key",
633                    match other {
634                        "nose" | "tube" | "fins" => unreachable!(),
635                        _ => "must be nose, tube or fins",
636                    },
637                ))
638            }
639        }
640    }
641    Ok(RocketGeometry::new(
642        nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
643        tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
644        fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
645    ))
646}
647
648/// Dry-mass breakdown estimated from geometry and component masses by
649/// [`RocketGeometry::mass_properties`].
650#[derive(Debug, Clone, Copy)]
651pub struct MassProperties {
652    /// Sum of the component masses (structure plus motor casing).
653    pub dry_mass_kg: f64,
654    /// Dry centre of gravity, metres aft of the nose tip.
655    pub dry_cg_m: f64,
656    /// Pitch inertia about the dry CG, kg·m².
657    pub pitch_inertia_kg_m2: f64,
658}
659
660/// The curves and reference quantities produced by [`RocketGeometry::analyze`].
661#[derive(Debug, Clone)]
662pub struct AeroAnalysis {
663    /// Axial drag coefficient vs Mach (subsonic, clamped above Mach 1).
664    pub cd_vs_mach: Vec<(f64, f64)>,
665    /// Normal-force coefficient slope per radian vs Mach (nose + fin set).
666    pub cna_vs_mach: Vec<(f64, f64)>,
667    /// Centre of pressure, metres aft of the nose tip, vs Mach.
668    pub cp_vs_mach: Vec<(f64, f64)>,
669    /// Induced-drag factor per radian² vs Mach (normal-force tilt: k = Cnα).
670    pub induced_k_vs_mach: Vec<(f64, f64)>,
671    /// Reference area, π/4·d² of the largest diameter.
672    pub ref_area_m2: f64,
673    /// Total aerodynamic length (nose + tube).
674    pub total_length_m: f64,
675    /// Largest body diameter.
676    pub max_diameter_m: f64,
677}
678
679#[cfg(feature = "ballistics")]
680impl AeroAnalysis {
681    /// Bundle the analysis with the remaining airframe parameters.
682    pub fn airframe(
683        &self,
684        cg_dry_m: f64,
685        cg_prop_m: f64,
686        pitch_inertia_kg_m2: f64,
687        recovery_cd_area_m2: f64,
688    ) -> crate::Airframe {
689        crate::Airframe {
690            diameter_m: self.max_diameter_m,
691            cd_vs_mach: self.cd_vs_mach.clone(),
692            cna_vs_mach: self.cna_vs_mach.clone(),
693            cp_vs_mach: self.cp_vs_mach.clone(),
694            cg_dry_m,
695            cg_prop_m,
696            pitch_inertia_kg_m2,
697            induced_k_vs_mach: self.induced_k_vs_mach.clone(),
698            recovery_cd_area_m2,
699        }
700    }
701
702    /// Drag + atmosphere + body lift for this geometry in the given wind.
703    pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
704        crate::ballistics::BallisticForces::new(
705            self.max_diameter_m,
706            self.cd_vs_mach.clone(),
707            crate::ballistics::Atmosphere::default(),
708            wind_mps,
709        )?
710        .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
711    }
712
713    /// Weathercock parameters for this geometry.
714    pub fn weathercock_params(
715        &self,
716        cg_dry_m: f64,
717        cg_prop_m: f64,
718        pitch_inertia_kg_m2: f64,
719    ) -> Result<crate::WeathercockParams> {
720        crate::WeathercockParams::new(
721            self.cna_vs_mach.clone(),
722            self.cp_vs_mach.clone(),
723            cg_dry_m,
724            cg_prop_m,
725            self.max_diameter_m,
726            pitch_inertia_kg_m2,
727            0.2,
728        )
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    fn example() -> RocketGeometry {
737        RocketGeometry::new(
738            NoseCone {
739                shape: NoseShape::Conical,
740                length_m: 0.1,
741                diameter_m: 0.024,
742            },
743            BodyTube {
744                length_m: 0.4,
745                diameter_m: 0.024,
746            },
747            TrapezoidalFins {
748                count: 3,
749                root_chord_m: 0.05,
750                tip_chord_m: 0.03,
751                span_m: 0.04,
752                leading_sweep_m: 0.02,
753                thickness_m: 0.002,
754            },
755        )
756    }
757
758    #[test]
759    fn nose_cp_fractions_match_barrowman() {
760        assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
761        assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
762        assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
763        assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
764    }
765
766    #[test]
767    fn conical_pressure_drag_uses_half_angle() {
768        // The M->0 limit of the wave-drag model is Barrowman's 0.8·sin²(φ).
769        let geo = example(); // conical nose, L = 0.1, R = 0.012
770        let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
771        assert!((geo.nose_pressure_cd(0.0) - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
772    }
773
774    #[test]
775    fn nose_wave_drag_matches_openrocket_formula() {
776        let geo = example(); // conical, L = 0.1, R = 0.012
777        let r = 0.012_f64;
778        let sinphi = r / (r * r + 0.1 * 0.1).sqrt();
779        // Supersonic direct formula: 2.1·sin²φ + 0.5·sinφ/β.
780        let m = 1.5_f64;
781        let expected = 2.1 * sinphi * sinphi + 0.5 * sinphi / (m * m - 1.0).sqrt();
782        assert!(
783            (geo.nose_pressure_cd(m) - expected).abs() < 1e-12,
784            "{} vs {expected}",
785            geo.nose_pressure_cd(m)
786        );
787        // Monotone rise through the transonic region, continuous at M = 1.
788        let below = geo.nose_pressure_cd(0.999);
789        let at = geo.nose_pressure_cd(1.0);
790        assert!((at - sinphi).abs() < 0.02, "at {at}");
791        assert!(below < at && below > 0.8 * sinphi * sinphi);
792    }
793
794    #[test]
795    fn fin_cp_matches_the_trapezoid_formula() {
796        let geo = example();
797        // Independently: MAC = (2/3)(cr+ct - cr*ct/(cr+ct)), yMAC = s(cr+2ct)/(3(cr+ct)).
798        let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
799        let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
800        let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
801        assert!(
802            (geo.fin_cp(0.0) - expected).abs() < 1e-12,
803            "{}",
804            geo.fin_cp(0.0)
805        );
806    }
807
808    #[test]
809    fn fin_cp_moves_aft_supersonic() {
810        let geo = example();
811        assert_eq!(geo.fin_cp_fraction(0.0), 0.25);
812        assert_eq!(geo.fin_cp_fraction(0.5), 0.25);
813        // Supersonic empirical formula: (AR·β − 0.67)/(2·AR·β − 1).
814        let ar = 2.0 * 0.04_f64.powi(2) / geo.fin_area();
815        let beta = (4.0_f64 - 1.0).sqrt();
816        let expected = (ar * beta - 0.67) / (2.0 * ar * beta - 1.0);
817        assert!((geo.fin_cp_fraction(2.0) - expected).abs() < 1e-12);
818        assert!(geo.fin_cp_fraction(1.0) > 0.25 && geo.fin_cp_fraction(1.0) < expected);
819    }
820
821    #[test]
822    fn conical_nose_volume_properties_match_the_solid_cone() {
823        let geo = example(); // conical nose, L = 0.1, R = 0.012
824        let (cg, inertia) = geo.nose_volume_properties();
825        assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
826        let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
827        assert!(
828            (inertia - expected).abs() / expected < 1e-4,
829            "inertia {inertia} expected {expected}"
830        );
831    }
832
833    #[test]
834    fn fin_centroid_matches_polygon_limits() {
835        let geo = example();
836        // Rectangle limit (m = 0, ct = cr): centroid at cr/2, height s/2.
837        let rectangular = RocketGeometry::new(
838            geo.nose,
839            geo.tube,
840            TrapezoidalFins {
841                count: 3,
842                root_chord_m: 0.05,
843                tip_chord_m: 0.05,
844                span_m: 0.04,
845                leading_sweep_m: 0.0,
846                thickness_m: 0.002,
847            },
848        );
849        assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
850        assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
851        // Triangle limit (ct = 0): (cr + m)/3 from the leading edge, s/3 up.
852        let triangular = RocketGeometry::new(
853            geo.nose,
854            geo.tube,
855            TrapezoidalFins {
856                count: 3,
857                root_chord_m: 0.06,
858                tip_chord_m: 0.0,
859                span_m: 0.03,
860                leading_sweep_m: 0.03,
861                thickness_m: 0.002,
862            },
863        );
864        assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
865        assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
866    }
867
868    #[test]
869    fn mass_properties_are_consistent() {
870        let geo = example();
871        let props = geo.mass_properties(0.012, 0.020, 0.006, None);
872        assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
873        // CG is the mass-weighted component mean, inside the airframe.
874        let (nose_cg, _) = geo.nose_volume_properties();
875        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
876        let fin_cg = geo.total_length_m() - geo.fins.root_chord_m + geo.fin_planform_cg();
877        let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
878        assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
879        assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
880        assert!(props.pitch_inertia_kg_m2 > 0.0);
881        // A casing at the aft end moves the CG aft and adds inertia.
882        let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
883        assert!(with_casing.dry_cg_m > props.dry_cg_m);
884        assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
885        assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
886        // A heavier nose moves the CG forward.
887        let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
888        assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
889    }
890
891    #[test]
892    fn induced_k_tracks_cna() {
893        // Tilt model: the induced-drag factor equals the normal-force slope.
894        let geo = example();
895        let analysis = geo.analyze();
896        for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
897            assert_eq!(k, cna, "mach {m}");
898        }
899    }
900
901    #[test]
902    fn mass_properties_single_component_is_exact() {
903        let geo = example();
904        let props = geo.mass_properties(0.0, 0.05, 0.0, None);
905        // Only the tube: CG at its midpoint, inertia its thin-cylinder value.
906        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
907        assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
908        let expected =
909            0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
910        assert!(
911            (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
912            "{} vs {expected}",
913            props.pitch_inertia_kg_m2
914        );
915    }
916
917    #[test]
918    fn fin_set_cna_matches_diederich() {
919        let geo = example();
920        // Fin pair: 2π·span²/(1+sqrt(1+(span²/(A·cosΓ))²)) / Aref, then
921        // (N/2)·(1+tau) — the Diederich value is a wing (two fins).
922        let area = geo.fin_area();
923        let cos = geo.midchord_cos();
924        let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
925        let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
926            / (1.0 + (1.0 + ratio * ratio).sqrt())
927            / geo.ref_area_m2();
928        let expected = 1.5 * one * (1.0 + geo.interference_tau());
929        let actual = geo.fin_set_cna(0.0);
930        assert!(
931            (actual - expected).abs() / expected < 1e-9,
932            "actual {actual} expected {expected}"
933        );
934        // And the classic closed form 4N(s/d)²/(1+sqrt(1+(2l/(cr+ct))²))·K.
935        let d = geo.max_diameter_m();
936        let classic = 4.0 * 3.0 * (geo.fins.span_m / d).powi(2)
937            / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
938            * (1.0 + geo.interference_tau());
939        assert!(
940            (actual - classic).abs() / classic < 0.02,
941            "actual {actual} classic {classic}"
942        );
943    }
944
945    #[test]
946    fn total_cp_is_the_moment_sum() {
947        let geo = example();
948        let fin_cna = geo.fin_set_cna(0.0);
949        let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs(0.0)) / (2.0 + fin_cna);
950        assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
951        // CP must sit aft of the nose CP (fins pull it back).
952        assert!(geo.total_cp(0.0) > geo.nose_cp());
953    }
954
955    #[test]
956    fn base_cd_matches_openrocket() {
957        assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
958        assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
959        assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
960    }
961
962    #[test]
963    fn analysis_produces_finite_curves_and_roughness_costs_drag() {
964        let geo = example();
965        let analysis = geo.analyze();
966        for &(m, cd) in &analysis.cd_vs_mach {
967            assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
968        }
969        for &(m, cp) in &analysis.cp_vs_mach {
970            assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
971        }
972        let smooth = geo.with_roughness(1e-6).analyze();
973        let rough = geo.with_roughness(500e-6).analyze();
974        assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
975    }
976}