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